Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: PDF and PPTX file import support (#608)

- PDF → doc: drop or pick a .pdf on landing/docs editor; pdf.js (pdfjs-dist,
dynamically imported) extracts text page-by-page, heuristics promote large
text to headings, page breaks become <hr> separators
- PPTX → slides: drop or pick a .pptx on landing; JSZip unpacks the archive,
DOMParser reads slide XML, shapes/text/notes mapped to our DeckState canvas
element model with EMU→pixel coordinate conversion; speaker notes preserved
- Updated landing accept attributes and drop overlay hint
- 25 new tests across pdf-import.test.ts and pptx-import.test.ts
- Version 0.35.0 → 0.36.0

+1194 -12
+7
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.36.0] — 2026-04-13 11 + 12 + ### Added 13 + - PDF import: drop or import a `.pdf` file from the landing page or docs in-editor import menu — text is extracted page-by-page via pdf.js (dynamically loaded) and converted to headings and paragraphs in the TipTap editor (#608) 14 + - PPTX import: drop or import a `.pptx` file from the landing page — slides are parsed from the ZIP+XML format using JSZip (no new deps), mapped to our canvas element model with title, body, and other text shapes; speaker notes are preserved (#608) 15 + 10 16 ## [0.35.0] — 2026-04-13 11 17 12 18 ### Added 19 + - Add calendar event reminders and notifications (#587) 13 20 - Calendar: Web Push reminders now sync to the server scheduler so notifications fire even when the browser tab is closed — all events with reminders within the next 30 days are pushed to `/api/push/schedule` on load and after every save (#588, #590) 14 21 - Calendar: persistent notifications toggle in the Settings popover — users who dismissed the one-time banner can enable or disable push reminders at any time (#594) 15 22 - Calendar: external ICS subscription events now appear in week view and day view (timed blocks and all-day bar), consistent with the existing month and agenda view overlays (#595)
+273 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.30.0", 3 + "version": "0.35.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.30.0", 9 + "version": "0.35.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-code-block-lowlight": "^2.27.2", ··· 42 42 "lowlight": "^3.3.0", 43 43 "mammoth": "^1.12.0", 44 44 "markdown-it": "^14.1.1", 45 + "pdfjs-dist": "^5.6.205", 45 46 "turndown": "^7.2.2", 46 47 "turndown-plugin-gfm": "^1.0.2", 47 48 "web-push": "^3.6.7", ··· 1599 1600 "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", 1600 1601 "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", 1601 1602 "license": "BSD-2-Clause" 1603 + }, 1604 + "node_modules/@napi-rs/canvas": { 1605 + "version": "0.1.97", 1606 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", 1607 + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", 1608 + "license": "MIT", 1609 + "optional": true, 1610 + "workspaces": [ 1611 + "e2e/*" 1612 + ], 1613 + "engines": { 1614 + "node": ">= 10" 1615 + }, 1616 + "funding": { 1617 + "type": "github", 1618 + "url": "https://github.com/sponsors/Brooooooklyn" 1619 + }, 1620 + "optionalDependencies": { 1621 + "@napi-rs/canvas-android-arm64": "0.1.97", 1622 + "@napi-rs/canvas-darwin-arm64": "0.1.97", 1623 + "@napi-rs/canvas-darwin-x64": "0.1.97", 1624 + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", 1625 + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", 1626 + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", 1627 + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", 1628 + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", 1629 + "@napi-rs/canvas-linux-x64-musl": "0.1.97", 1630 + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", 1631 + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" 1632 + } 1633 + }, 1634 + "node_modules/@napi-rs/canvas-android-arm64": { 1635 + "version": "0.1.97", 1636 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", 1637 + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", 1638 + "cpu": [ 1639 + "arm64" 1640 + ], 1641 + "license": "MIT", 1642 + "optional": true, 1643 + "os": [ 1644 + "android" 1645 + ], 1646 + "engines": { 1647 + "node": ">= 10" 1648 + }, 1649 + "funding": { 1650 + "type": "github", 1651 + "url": "https://github.com/sponsors/Brooooooklyn" 1652 + } 1653 + }, 1654 + "node_modules/@napi-rs/canvas-darwin-arm64": { 1655 + "version": "0.1.97", 1656 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", 1657 + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", 1658 + "cpu": [ 1659 + "arm64" 1660 + ], 1661 + "license": "MIT", 1662 + "optional": true, 1663 + "os": [ 1664 + "darwin" 1665 + ], 1666 + "engines": { 1667 + "node": ">= 10" 1668 + }, 1669 + "funding": { 1670 + "type": "github", 1671 + "url": "https://github.com/sponsors/Brooooooklyn" 1672 + } 1673 + }, 1674 + "node_modules/@napi-rs/canvas-darwin-x64": { 1675 + "version": "0.1.97", 1676 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", 1677 + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", 1678 + "cpu": [ 1679 + "x64" 1680 + ], 1681 + "license": "MIT", 1682 + "optional": true, 1683 + "os": [ 1684 + "darwin" 1685 + ], 1686 + "engines": { 1687 + "node": ">= 10" 1688 + }, 1689 + "funding": { 1690 + "type": "github", 1691 + "url": "https://github.com/sponsors/Brooooooklyn" 1692 + } 1693 + }, 1694 + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { 1695 + "version": "0.1.97", 1696 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", 1697 + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", 1698 + "cpu": [ 1699 + "arm" 1700 + ], 1701 + "license": "MIT", 1702 + "optional": true, 1703 + "os": [ 1704 + "linux" 1705 + ], 1706 + "engines": { 1707 + "node": ">= 10" 1708 + }, 1709 + "funding": { 1710 + "type": "github", 1711 + "url": "https://github.com/sponsors/Brooooooklyn" 1712 + } 1713 + }, 1714 + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { 1715 + "version": "0.1.97", 1716 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", 1717 + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", 1718 + "cpu": [ 1719 + "arm64" 1720 + ], 1721 + "license": "MIT", 1722 + "optional": true, 1723 + "os": [ 1724 + "linux" 1725 + ], 1726 + "engines": { 1727 + "node": ">= 10" 1728 + }, 1729 + "funding": { 1730 + "type": "github", 1731 + "url": "https://github.com/sponsors/Brooooooklyn" 1732 + } 1733 + }, 1734 + "node_modules/@napi-rs/canvas-linux-arm64-musl": { 1735 + "version": "0.1.97", 1736 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", 1737 + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", 1738 + "cpu": [ 1739 + "arm64" 1740 + ], 1741 + "license": "MIT", 1742 + "optional": true, 1743 + "os": [ 1744 + "linux" 1745 + ], 1746 + "engines": { 1747 + "node": ">= 10" 1748 + }, 1749 + "funding": { 1750 + "type": "github", 1751 + "url": "https://github.com/sponsors/Brooooooklyn" 1752 + } 1753 + }, 1754 + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { 1755 + "version": "0.1.97", 1756 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", 1757 + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", 1758 + "cpu": [ 1759 + "riscv64" 1760 + ], 1761 + "license": "MIT", 1762 + "optional": true, 1763 + "os": [ 1764 + "linux" 1765 + ], 1766 + "engines": { 1767 + "node": ">= 10" 1768 + }, 1769 + "funding": { 1770 + "type": "github", 1771 + "url": "https://github.com/sponsors/Brooooooklyn" 1772 + } 1773 + }, 1774 + "node_modules/@napi-rs/canvas-linux-x64-gnu": { 1775 + "version": "0.1.97", 1776 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", 1777 + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", 1778 + "cpu": [ 1779 + "x64" 1780 + ], 1781 + "license": "MIT", 1782 + "optional": true, 1783 + "os": [ 1784 + "linux" 1785 + ], 1786 + "engines": { 1787 + "node": ">= 10" 1788 + }, 1789 + "funding": { 1790 + "type": "github", 1791 + "url": "https://github.com/sponsors/Brooooooklyn" 1792 + } 1793 + }, 1794 + "node_modules/@napi-rs/canvas-linux-x64-musl": { 1795 + "version": "0.1.97", 1796 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", 1797 + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", 1798 + "cpu": [ 1799 + "x64" 1800 + ], 1801 + "license": "MIT", 1802 + "optional": true, 1803 + "os": [ 1804 + "linux" 1805 + ], 1806 + "engines": { 1807 + "node": ">= 10" 1808 + }, 1809 + "funding": { 1810 + "type": "github", 1811 + "url": "https://github.com/sponsors/Brooooooklyn" 1812 + } 1813 + }, 1814 + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { 1815 + "version": "0.1.97", 1816 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", 1817 + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", 1818 + "cpu": [ 1819 + "arm64" 1820 + ], 1821 + "license": "MIT", 1822 + "optional": true, 1823 + "os": [ 1824 + "win32" 1825 + ], 1826 + "engines": { 1827 + "node": ">= 10" 1828 + }, 1829 + "funding": { 1830 + "type": "github", 1831 + "url": "https://github.com/sponsors/Brooooooklyn" 1832 + } 1833 + }, 1834 + "node_modules/@napi-rs/canvas-win32-x64-msvc": { 1835 + "version": "0.1.97", 1836 + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", 1837 + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", 1838 + "cpu": [ 1839 + "x64" 1840 + ], 1841 + "license": "MIT", 1842 + "optional": true, 1843 + "os": [ 1844 + "win32" 1845 + ], 1846 + "engines": { 1847 + "node": ">= 10" 1848 + }, 1849 + "funding": { 1850 + "type": "github", 1851 + "url": "https://github.com/sponsors/Brooooooklyn" 1852 + } 1602 1853 }, 1603 1854 "node_modules/@npmcli/agent": { 1604 1855 "version": "3.0.0", ··· 7601 7852 "node": "^18.17.0 || >=20.5.0" 7602 7853 } 7603 7854 }, 7855 + "node_modules/node-readable-to-web-readable-stream": { 7856 + "version": "0.4.2", 7857 + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", 7858 + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", 7859 + "license": "MIT", 7860 + "optional": true 7861 + }, 7604 7862 "node_modules/nopt": { 7605 7863 "version": "8.1.0", 7606 7864 "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", ··· 7897 8155 "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 7898 8156 "dev": true, 7899 8157 "license": "MIT" 8158 + }, 8159 + "node_modules/pdfjs-dist": { 8160 + "version": "5.6.205", 8161 + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", 8162 + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", 8163 + "license": "Apache-2.0", 8164 + "engines": { 8165 + "node": ">=20.19.0 || >=22.13.0 || >=24" 8166 + }, 8167 + "optionalDependencies": { 8168 + "@napi-rs/canvas": "^0.1.96", 8169 + "node-readable-to-web-readable-stream": "^0.4.2" 8170 + } 7900 8171 }, 7901 8172 "node_modules/pe-library": { 7902 8173 "version": "0.4.1",
+2 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.35.0", 3 + "version": "0.36.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 51 51 "lowlight": "^3.3.0", 52 52 "mammoth": "^1.12.0", 53 53 "markdown-it": "^14.1.1", 54 + "pdfjs-dist": "^5.6.205", 54 55 "turndown": "^7.2.2", 55 56 "turndown-plugin-gfm": "^1.0.2", 56 57 "web-push": "^3.6.7",
+8 -1
src/docs/export-import.ts
··· 8 8 import { exportPdf } from './pdf-export.js'; 9 9 import { exportDocx } from './docx-export.js'; 10 10 import { importDocx } from './docx-import.js'; 11 + import { importPdf } from './pdf-import.js'; 11 12 import { markdownToHtml } from './markdown-parser.js'; 12 13 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 13 14 ··· 103 104 return; 104 105 } 105 106 107 + if (ext === 'pdf') { 108 + await importPdf(file, editor, showToast); 109 + await provider._saveSnapshot(); 110 + return; 111 + } 112 + 106 113 const reader = new FileReader(); 107 114 reader.onload = (e: any) => { 108 115 const content = e.target.result; ··· 123 130 export function importFile(editor: any, provider: any): void { 124 131 const input = document.createElement('input'); 125 132 input.type = 'file'; 126 - input.accept = '.txt,.html,.htm,.md,.docx'; 133 + input.accept = '.txt,.html,.htm,.md,.docx,.pdf'; 127 134 input.addEventListener('change', () => { 128 135 if (input.files!.length > 0) handleImportedFile(input.files![0], editor, provider); 129 136 });
+197
src/docs/pdf-import.ts
··· 1 + /** 2 + * PDF import module for Tools Docs. 3 + * 4 + * Uses pdf.js to extract text content from a PDF, then converts it 5 + * to HTML suitable for the TipTap editor. Large text blocks become 6 + * headings; normal text becomes paragraphs; page breaks become <hr>. 7 + */ 8 + import type { Editor } from '@tiptap/core'; 9 + 10 + /** One logical text chunk from a PDF page. */ 11 + interface PdfTextItem { 12 + str: string; 13 + /** Approximate font size in points (estimated from transform matrix height). */ 14 + fontSize: number; 15 + /** Y position on the page (ascending from bottom in PDF coordinates). */ 16 + y: number; 17 + } 18 + 19 + /** Collect text items from all pages and return them as structured chunks. */ 20 + async function extractTextItems(arrayBuffer: ArrayBuffer): Promise<PdfTextItem[][]> { 21 + // Dynamic import — pdfjs-dist is large; only load when needed. 22 + const pdfjsLib = await import('pdfjs-dist'); 23 + 24 + // Point the worker at the bundled worker file. Vite resolves ?url to the 25 + // asset path at build time; in dev the dev server serves it directly. 26 + if (!pdfjsLib.GlobalWorkerOptions.workerSrc) { 27 + // @ts-ignore — Vite ?url suffix is not typed in TypeScript 28 + const { default: workerUrl } = await import('pdfjs-dist/build/pdf.worker.min.mjs?url'); 29 + pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl as string; 30 + } 31 + 32 + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }); 33 + const pdf = await loadingTask.promise; 34 + const pages: PdfTextItem[][] = []; 35 + 36 + for (let p = 1; p <= pdf.numPages; p++) { 37 + const page = await pdf.getPage(p); 38 + const content = await page.getTextContent(); 39 + const viewport = page.getViewport({ scale: 1 }); 40 + const pageHeight = viewport.height; 41 + const items: PdfTextItem[] = []; 42 + 43 + for (const item of content.items) { 44 + if ('str' in item && item.str.trim()) { 45 + // transform is a 6-element matrix [a, b, c, d, e, f]. 46 + // d (index 3) is approximately the font height in user units. 47 + // e (index 4) = x, f (index 5) = y from bottom-left. 48 + const transform = (item as any).transform as number[]; 49 + const fontSize = Math.abs(transform?.[3] ?? 12); 50 + const yFromBottom = transform?.[5] ?? 0; 51 + // Convert to distance from top (PDF origin is bottom-left). 52 + const y = pageHeight - yFromBottom; 53 + items.push({ str: item.str, fontSize, y }); 54 + } 55 + } 56 + pages.push(items); 57 + } 58 + 59 + return pages; 60 + } 61 + 62 + /** Heuristically convert extracted PDF items to HTML. */ 63 + function itemsToHtml(pages: PdfTextItem[][]): string { 64 + if (pages.length === 0) return ''; 65 + 66 + // Find the median font size across all pages to calibrate heading detection. 67 + const allSizes = pages.flatMap(p => p.map(i => i.fontSize)).filter(s => s > 0); 68 + allSizes.sort((a, b) => a - b); 69 + const median = allSizes[Math.floor(allSizes.length / 2)] ?? 12; 70 + const headingThreshold = median * 1.4; // 40% larger than median = heading 71 + 72 + const parts: string[] = []; 73 + 74 + for (let pi = 0; pi < pages.length; pi++) { 75 + if (pi > 0) parts.push('<hr>'); 76 + 77 + const items = pages[pi]!; 78 + if (items.length === 0) continue; 79 + 80 + // Group items into lines by proximity of y coordinates. 81 + const lines: PdfTextItem[][] = []; 82 + let currentLine: PdfTextItem[] = []; 83 + let lastY = items[0]!.y; 84 + 85 + for (const item of items) { 86 + // Items more than 2pt apart vertically are on different lines. 87 + if (Math.abs(item.y - lastY) > 2 && currentLine.length > 0) { 88 + lines.push(currentLine); 89 + currentLine = []; 90 + } 91 + currentLine.push(item); 92 + lastY = item.y; 93 + } 94 + if (currentLine.length > 0) lines.push(currentLine); 95 + 96 + // Merge lines into paragraphs (blank line gap = new paragraph). 97 + let prevLineY = lines[0]?.[0]?.y ?? 0; 98 + let paragraph: string[] = []; 99 + 100 + const flushParagraph = (lineItems: PdfTextItem[]) => { 101 + const text = lineItems.map(i => i.str).join(' ').trim(); 102 + if (!text) return; 103 + const avgFontSize = lineItems.reduce((s, i) => s + i.fontSize, 0) / lineItems.length; 104 + if (avgFontSize >= headingThreshold) { 105 + // Larger text = heading 106 + const level = avgFontSize >= headingThreshold * 1.4 ? 1 : 2; 107 + parts.push(`<h${level}>${escapeHtml(text)}</h${level}>`); 108 + } else { 109 + paragraph.push(text); 110 + } 111 + }; 112 + 113 + const flushBlock = () => { 114 + if (paragraph.length > 0) { 115 + parts.push(`<p>${paragraph.map(escapeHtml).join('<br>')}</p>`); 116 + paragraph = []; 117 + } 118 + }; 119 + 120 + for (const line of lines) { 121 + const y = line[0]?.y ?? 0; 122 + // Gap larger than 1.5× typical line spacing = new paragraph. 123 + const gap = Math.abs(y - prevLineY); 124 + const lineHeight = (line[0]?.fontSize ?? 12) * 1.5; 125 + if (gap > lineHeight && paragraph.length > 0) { 126 + flushBlock(); 127 + } 128 + 129 + const avgFontSize = line.reduce((s, i) => s + i.fontSize, 0) / line.length; 130 + if (avgFontSize >= headingThreshold) { 131 + flushBlock(); 132 + flushParagraph(line); 133 + } else { 134 + const text = line.map(i => i.str).join(' ').trim(); 135 + if (text) paragraph.push(text); 136 + } 137 + prevLineY = y; 138 + } 139 + flushBlock(); 140 + } 141 + 142 + return parts.join('\n'); 143 + } 144 + 145 + function escapeHtml(s: string): string { 146 + return s 147 + .replace(/&/g, '&amp;') 148 + .replace(/</g, '&lt;') 149 + .replace(/>/g, '&gt;') 150 + .replace(/"/g, '&quot;'); 151 + } 152 + 153 + /** 154 + * Validate that an ArrayBuffer looks like a PDF (starts with %PDF-). 155 + */ 156 + export function isValidPdf(arrayBuffer: ArrayBuffer): boolean { 157 + if (!arrayBuffer || arrayBuffer.byteLength < 5) return false; 158 + const view = new Uint8Array(arrayBuffer); 159 + // %PDF- = 0x25 0x50 0x44 0x46 0x2D 160 + return view[0] === 0x25 && view[1] === 0x50 && view[2] === 0x44 && view[3] === 0x46 && view[4] === 0x2D; 161 + } 162 + 163 + /** 164 + * Extract text from a PDF ArrayBuffer and return as TipTap-compatible HTML. 165 + * Pure async — testable without DOM. 166 + */ 167 + export async function convertPdfToHtml(arrayBuffer: ArrayBuffer): Promise<string> { 168 + const pages = await extractTextItems(arrayBuffer); 169 + const html = itemsToHtml(pages); 170 + return html || '<p></p>'; 171 + } 172 + 173 + /** 174 + * Import a PDF File into the TipTap editor. 175 + * DOM-coupled entry point. 176 + */ 177 + export async function importPdf( 178 + file: File, 179 + editor: Editor, 180 + showToast: (message: string, duration: number) => void, 181 + ): Promise<void> { 182 + try { 183 + const arrayBuffer = await file.arrayBuffer(); 184 + 185 + if (!isValidPdf(arrayBuffer)) { 186 + showToast('Invalid PDF — the file appears to be corrupt or not a PDF', 5000); 187 + return; 188 + } 189 + 190 + const html = await convertPdfToHtml(arrayBuffer); 191 + editor.commands.setContent(html); 192 + showToast(`Imported "${file.name}" successfully`, 3000); 193 + } catch (err) { 194 + console.error('pdf import error:', err); 195 + showToast('Failed to import PDF — it may be corrupt, encrypted, or use unsupported features', 5000); 196 + } 197 + }
+2 -2
src/index.html
··· 126 126 </button> 127 127 </div> 128 128 </div> 129 - <input type="file" id="file-import-input" accept=".docx,.xlsx,.xls,.csv,.tsv,.md,.txt" style="display:none"> 129 + <input type="file" id="file-import-input" accept=".docx,.xlsx,.xls,.csv,.tsv,.md,.txt,.pdf,.pptx" style="display:none"> 130 130 <input type="file" id="backup-import-input" accept=".json" style="display:none"> 131 131 </div> 132 132 </div> ··· 200 200 <div class="drop-overlay-content"> 201 201 <span class="drop-overlay-icon">&#8681;</span> 202 202 <span class="drop-overlay-text">Drop to import</span> 203 - <span class="drop-overlay-hint">.docx, .xlsx, .csv, .md</span> 203 + <span class="drop-overlay-hint">.docx, .xlsx, .csv, .md, .pdf, .pptx</span> 204 204 </div> 205 205 </div> 206 206
+4
src/landing-dragdrop.ts
··· 8 8 const EXT_TO_DOC_TYPE: Record<string, DocType> = { 9 9 docx: 'doc', 10 10 md: 'doc', 11 + pdf: 'doc', 11 12 xlsx: 'sheet', 12 13 csv: 'sheet', 14 + pptx: 'slide', 13 15 }; 14 16 15 17 /** File extension to import type mapping. */ 16 18 const EXT_TO_IMPORT_TYPE: Record<string, ImportType> = { 17 19 docx: 'docx', 18 20 md: 'md', 21 + pdf: 'pdf', 19 22 xlsx: 'xlsx', 20 23 csv: 'csv', 24 + pptx: 'pptx', 21 25 }; 22 26 23 27 /**
+1 -1
src/landing-types.ts
··· 56 56 } 57 57 58 58 export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar'; 59 - export type ImportType = 'docx' | 'xlsx' | 'csv' | 'md'; 59 + export type ImportType = 'docx' | 'xlsx' | 'csv' | 'md' | 'pdf' | 'pptx'; 60 60 61 61 export interface SortLabels { 62 62 [key: string]: string;
+24
src/slides/main.ts
··· 138 138 provider.on('sync', () => { 139 139 loadDeckFromYjs(); 140 140 actions.render(); 141 + 142 + // Check for pending PPTX import from landing page drag-and-drop 143 + const pendingKey = `pending-import-${state.docId}`; 144 + const pendingRaw = sessionStorage.getItem(pendingKey); 145 + if (pendingRaw) { 146 + sessionStorage.removeItem(pendingKey); 147 + (async () => { 148 + try { 149 + const pending = JSON.parse(pendingRaw) as { name: string; type: string; data: string }; 150 + if (pending.type !== 'pptx') return; 151 + const { convertPptxToDeck } = await import('./pptx-import.js'); 152 + const resp = await fetch(pending.data); 153 + const arrayBuffer = await resp.arrayBuffer(); 154 + const deck = await convertPptxToDeck(arrayBuffer); 155 + state.deck = deck; 156 + state.themedDeck = (await import('./layouts-themes.js')).createThemedDeck(deck.slides.length); 157 + syncDeckToYjs(); 158 + await provider._saveSnapshot(); 159 + actions.render(); 160 + } catch (err) { 161 + console.error('PPTX import error:', err); 162 + } 163 + })(); 164 + } 141 165 }); 142 166 } 143 167
+393
src/slides/pptx-import.ts
··· 1 + /** 2 + * PPTX import module for Tools Slides. 3 + * 4 + * Parses a .pptx ArrayBuffer (ZIP + XML) using JSZip, then converts 5 + * each slide's shapes and text into our DeckState format. 6 + * 7 + * Limitations (acceptable first pass): 8 + * - Images in slides are skipped (media extraction is a future enhancement) 9 + * - Slide master/layout backgrounds are not applied (uses white or extracted bg) 10 + * - Complex table and SmartArt shapes are extracted as plain text 11 + */ 12 + 13 + import JSZip from 'jszip'; 14 + import { 15 + type DeckState, 16 + type Slide, 17 + type SlideElement, 18 + createSlide, 19 + SLIDE_WIDTH, 20 + SLIDE_HEIGHT, 21 + DEFAULT_ASPECT_RATIO, 22 + } from './canvas-engine.js'; 23 + 24 + // --------------------------------------------------------------------------- 25 + // EMU → pixel conversion 26 + // PPTX uses English Metric Units: 1 inch = 914400 EMU, 1 pt = 12700 EMU. 27 + // Standard slide: 9144000 × 5143500 EMU = 10" × 5.625" (16:9). 28 + // We target 960 × 540 px. 29 + // --------------------------------------------------------------------------- 30 + 31 + const PPTX_WIDTH_EMU = 9144000; 32 + const PPTX_HEIGHT_EMU = 5143500; 33 + 34 + function emuToX(emu: number): number { 35 + return Math.round((emu / PPTX_WIDTH_EMU) * SLIDE_WIDTH); 36 + } 37 + function emuToY(emu: number): number { 38 + return Math.round((emu / PPTX_HEIGHT_EMU) * SLIDE_HEIGHT); 39 + } 40 + function emuToW(emu: number): number { 41 + return Math.max(10, Math.round((emu / PPTX_WIDTH_EMU) * SLIDE_WIDTH)); 42 + } 43 + function emuToH(emu: number): number { 44 + return Math.max(10, Math.round((emu / PPTX_HEIGHT_EMU) * SLIDE_HEIGHT)); 45 + } 46 + 47 + // --------------------------------------------------------------------------- 48 + // XML helpers 49 + // --------------------------------------------------------------------------- 50 + 51 + function parseXml(xmlStr: string): Document { 52 + return new DOMParser().parseFromString(xmlStr, 'application/xml'); 53 + } 54 + 55 + /** Get all elements by local name, ignoring namespace prefix. */ 56 + function byName(el: Element | Document, localName: string): Element[] { 57 + // querySelectorAll with namespace wildcards isn't reliably cross-browser, 58 + // so we iterate children and match by localName. 59 + const results: Element[] = []; 60 + const walk = (node: Element | Document) => { 61 + const children = (node as Element).children ?? (node as Document).documentElement?.children ?? []; 62 + for (const child of Array.from(children)) { 63 + if (child.localName === localName) results.push(child); 64 + walk(child); 65 + } 66 + }; 67 + walk(el); 68 + return results; 69 + } 70 + 71 + function attr(el: Element, name: string): string { 72 + return el.getAttribute(name) ?? ''; 73 + } 74 + 75 + /** Extract hex color from a solidFill/srgbClr element chain, or '' if absent. */ 76 + function extractColor(container: Element): string { 77 + const srgb = byName(container, 'srgbClr')[0]; 78 + if (srgb) return '#' + attr(srgb, 'val').toLowerCase(); 79 + const sys = byName(container, 'sysClr')[0]; 80 + if (sys) { 81 + const lastClr = attr(sys, 'lastClr'); 82 + if (lastClr) return '#' + lastClr.toLowerCase(); 83 + } 84 + return ''; 85 + } 86 + 87 + // --------------------------------------------------------------------------- 88 + // Shape text extraction 89 + // --------------------------------------------------------------------------- 90 + 91 + interface ParsedShape { 92 + phType: string | null; // 'title' | 'body' | 'ctrTitle' | 'subTitle' | null 93 + x: number; 94 + y: number; 95 + width: number; 96 + height: number; 97 + text: string; 98 + fontSize: number; // pt 99 + bold: boolean; 100 + color: string; 101 + align: string; // 'left' | 'center' | 'right' 102 + } 103 + 104 + function extractParagraphText(para: Element): string { 105 + const runs = byName(para, 'r'); 106 + if (runs.length === 0) return ''; 107 + return runs.map(r => { 108 + const t = byName(r, 't')[0]; 109 + return t?.textContent ?? ''; 110 + }).join(''); 111 + } 112 + 113 + function extractShapeText(sp: Element): string { 114 + const txBody = byName(sp, 'txBody')[0]; 115 + if (!txBody) return ''; 116 + return byName(txBody, 'p') 117 + .map(extractParagraphText) 118 + .filter(Boolean) 119 + .join('\n'); 120 + } 121 + 122 + function extractFirstFontSize(sp: Element): number { 123 + // Look for <a:rPr sz="..."> (in hundredths of a point) or <a:defRPr sz="..."> 124 + for (const localName of ['rPr', 'defRPr', 'endParaRPr']) { 125 + const els = byName(sp, localName); 126 + for (const el of els) { 127 + const sz = attr(el, 'sz'); 128 + if (sz) return Math.round(parseInt(sz, 10) / 100); 129 + } 130 + } 131 + return 18; // default 132 + } 133 + 134 + function extractFirstAlign(sp: Element): string { 135 + const pPr = byName(sp, 'pPr')[0]; 136 + if (pPr) { 137 + const algn = attr(pPr, 'algn'); 138 + if (algn === 'ctr') return 'center'; 139 + if (algn === 'r') return 'right'; 140 + } 141 + return 'left'; 142 + } 143 + 144 + function extractFirstColor(sp: Element): string { 145 + const solidFills = byName(sp, 'solidFill'); 146 + for (const fill of solidFills) { 147 + const c = extractColor(fill); 148 + if (c) return c; 149 + } 150 + return '#000000'; 151 + } 152 + 153 + function extractFirstBold(sp: Element): boolean { 154 + const rPrs = byName(sp, 'rPr'); 155 + for (const rPr of rPrs) { 156 + const b = attr(rPr, 'b'); 157 + if (b === '1' || b === 'true') return true; 158 + } 159 + return false; 160 + } 161 + 162 + function parseShape(sp: Element): ParsedShape | null { 163 + const text = extractShapeText(sp); 164 + if (!text.trim()) return null; 165 + 166 + // Placeholder type 167 + const nvSpPr = byName(sp, 'nvSpPr')[0]; 168 + const ph = nvSpPr ? byName(nvSpPr, 'ph')[0] : null; 169 + const phType = ph ? (attr(ph, 'type') || 'body') : null; 170 + 171 + // Position/size from spPr > xfrm 172 + const spPr = byName(sp, 'spPr')[0]; 173 + const xfrm = spPr ? byName(spPr, 'xfrm')[0] : null; 174 + const off = xfrm ? byName(xfrm, 'off')[0] : null; 175 + const ext = xfrm ? byName(xfrm, 'ext')[0] : null; 176 + 177 + const x = off ? emuToX(parseInt(attr(off, 'x'), 10) || 0) : 0; 178 + const y = off ? emuToY(parseInt(attr(off, 'y'), 10) || 0) : 0; 179 + const width = ext ? emuToW(parseInt(attr(ext, 'cx'), 10) || PPTX_WIDTH_EMU) : SLIDE_WIDTH; 180 + const height = ext ? emuToH(parseInt(attr(ext, 'cy'), 10) || 100000) : 60; 181 + 182 + return { 183 + phType, 184 + x, 185 + y, 186 + width, 187 + height, 188 + text, 189 + fontSize: extractFirstFontSize(sp), 190 + bold: extractFirstBold(sp), 191 + color: extractFirstColor(sp), 192 + align: extractFirstAlign(sp), 193 + }; 194 + } 195 + 196 + // --------------------------------------------------------------------------- 197 + // Background extraction 198 + // --------------------------------------------------------------------------- 199 + 200 + function extractBackground(slideDoc: Document): string { 201 + const bg = byName(slideDoc, 'bg')[0]; 202 + if (!bg) return '#ffffff'; 203 + const bgPr = byName(bg, 'bgPr')[0]; 204 + if (bgPr) { 205 + const solidFill = byName(bgPr, 'solidFill')[0]; 206 + if (solidFill) { 207 + const c = extractColor(solidFill); 208 + if (c) return c; 209 + } 210 + } 211 + return '#ffffff'; 212 + } 213 + 214 + // --------------------------------------------------------------------------- 215 + // Notes extraction 216 + // --------------------------------------------------------------------------- 217 + 218 + async function extractNotes(zip: JSZip, slideIndex: number): Promise<string> { 219 + const notesPath = `ppt/notesSlides/notesSlide${slideIndex}.xml`; 220 + const file = zip.file(notesPath); 221 + if (!file) return ''; 222 + try { 223 + const xml = await file.async('string'); 224 + const doc = parseXml(xml); 225 + // Notes body is in the second sp (first is slide number placeholder) 226 + const shapes = byName(doc, 'sp'); 227 + const texts: string[] = []; 228 + for (const sp of shapes) { 229 + const nvSpPr = byName(sp, 'nvSpPr')[0]; 230 + const ph = nvSpPr ? byName(nvSpPr, 'ph')[0] : null; 231 + const phType = ph ? attr(ph, 'type') : ''; 232 + // Skip slide number placeholder (type="sldNum") 233 + if (phType === 'sldNum') continue; 234 + const t = extractShapeText(sp); 235 + if (t.trim()) texts.push(t.trim()); 236 + } 237 + return texts.join('\n'); 238 + } catch { 239 + return ''; 240 + } 241 + } 242 + 243 + // --------------------------------------------------------------------------- 244 + // Slide order from presentation.xml 245 + // --------------------------------------------------------------------------- 246 + 247 + async function getSlideOrder(zip: JSZip): Promise<number[]> { 248 + const presFile = zip.file('ppt/presentation.xml'); 249 + if (!presFile) { 250 + // Fall back to numeric order based on files present 251 + const files = Object.keys(zip.files) 252 + .filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f)) 253 + .map(f => parseInt(f.match(/slide(\d+)\.xml/)![1]!, 10)) 254 + .sort((a, b) => a - b); 255 + return files; 256 + } 257 + 258 + const xml = await presFile.async('string'); 259 + const doc = parseXml(xml); 260 + const sldIdLst = byName(doc, 'sldIdLst')[0]; 261 + if (!sldIdLst) return []; 262 + 263 + // Map r:id → slide file index via _rels/presentation.xml.rels 264 + const relsFile = zip.file('ppt/_rels/presentation.xml.rels'); 265 + if (!relsFile) return []; 266 + const relsXml = await relsFile.async('string'); 267 + const relsDoc = parseXml(relsXml); 268 + const rels = byName(relsDoc, 'Relationship'); 269 + const ridToIndex = new Map<string, number>(); 270 + for (const rel of rels) { 271 + const target = attr(rel, 'Target'); 272 + const m = target.match(/slides\/slide(\d+)\.xml/); 273 + if (m) ridToIndex.set(attr(rel, 'Id'), parseInt(m[1]!, 10)); 274 + } 275 + 276 + const sldIds = byName(sldIdLst, 'sldId'); 277 + return sldIds 278 + .map(el => ridToIndex.get(attr(el, 'r:id') || el.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id') || '') ?? 0) 279 + .filter(n => n > 0); 280 + } 281 + 282 + // --------------------------------------------------------------------------- 283 + // Shape → SlideElement conversion 284 + // --------------------------------------------------------------------------- 285 + 286 + let _elCounter = 0; 287 + function makeElementId(): string { 288 + return `imported-${Date.now()}-${++_elCounter}`; 289 + } 290 + 291 + function shapeToElement(shape: ParsedShape, zIndex: number): SlideElement { 292 + const isTitle = shape.phType === 'title' || shape.phType === 'ctrTitle'; 293 + const effectiveFontSize = shape.fontSize > 0 ? shape.fontSize : (isTitle ? 36 : 18); 294 + 295 + const style: Record<string, string> = { 296 + fontSize: `${effectiveFontSize}px`, 297 + color: shape.color || (isTitle ? '#111111' : '#333333'), 298 + textAlign: shape.align, 299 + fontWeight: shape.bold || isTitle ? 'bold' : 'normal', 300 + lineHeight: '1.4', 301 + padding: '4px', 302 + wordBreak: 'break-word', 303 + whiteSpace: 'pre-wrap', 304 + }; 305 + 306 + return { 307 + id: makeElementId(), 308 + type: 'text', 309 + x: shape.x, 310 + y: shape.y, 311 + width: shape.width, 312 + height: shape.height, 313 + rotation: 0, 314 + zIndex, 315 + content: shape.text, 316 + style, 317 + }; 318 + } 319 + 320 + // --------------------------------------------------------------------------- 321 + // Public API 322 + // --------------------------------------------------------------------------- 323 + 324 + /** 325 + * Validate that an ArrayBuffer is a ZIP file (PPTX is a ZIP). 326 + */ 327 + export function isValidPptx(arrayBuffer: ArrayBuffer): boolean { 328 + if (!arrayBuffer || arrayBuffer.byteLength < 4) return false; 329 + const view = new Uint8Array(arrayBuffer); 330 + // PK\x03\x04 = 0x50 0x4B 0x03 0x04 331 + return view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04; 332 + } 333 + 334 + /** 335 + * Convert a .pptx ArrayBuffer to a DeckState. 336 + * Pure async — testable without DOM beyond DOMParser (available in jsdom). 337 + */ 338 + export async function convertPptxToDeck(arrayBuffer: ArrayBuffer): Promise<DeckState> { 339 + const zip = await JSZip.loadAsync(arrayBuffer); 340 + 341 + const order = await getSlideOrder(zip); 342 + if (order.length === 0) { 343 + // Nothing in presentation.xml — try all slide files 344 + const keys = Object.keys(zip.files) 345 + .filter(k => /^ppt\/slides\/slide\d+\.xml$/.test(k)) 346 + .sort(); 347 + order.push(...keys.map(k => parseInt(k.match(/slide(\d+)/)![1]!, 10))); 348 + } 349 + 350 + const slides: Slide[] = []; 351 + 352 + for (let i = 0; i < order.length; i++) { 353 + const slideIndex = order[i]!; 354 + const slidePath = `ppt/slides/slide${slideIndex}.xml`; 355 + const slideFile = zip.file(slidePath); 356 + if (!slideFile) continue; 357 + 358 + const xml = await slideFile.async('string'); 359 + const doc = parseXml(xml); 360 + 361 + const background = extractBackground(doc); 362 + const notes = await extractNotes(zip, slideIndex); 363 + 364 + // Collect all sp (shape) elements 365 + const shapes = byName(doc, 'sp'); 366 + const elements: SlideElement[] = []; 367 + let zIndex = 0; 368 + 369 + for (const sp of shapes) { 370 + const parsed = parseShape(sp); 371 + if (!parsed) continue; 372 + elements.push(shapeToElement(parsed, zIndex++)); 373 + } 374 + 375 + const slide: Slide = { 376 + ...createSlide(background), 377 + elements, 378 + notes, 379 + }; 380 + slides.push(slide); 381 + } 382 + 383 + if (slides.length === 0) { 384 + // Fallback: return a single blank slide so the editor doesn't crash 385 + slides.push(createSlide()); 386 + } 387 + 388 + return { 389 + slides, 390 + currentSlide: 0, 391 + aspectRatio: DEFAULT_ASPECT_RATIO, 392 + }; 393 + }
+22 -5
tests/landing-dragdrop.test.ts
··· 31 31 expect(getFileType('budget.XLSX')).toBe('sheet'); 32 32 }); 33 33 34 + it('returns "doc" for .pdf files', () => { 35 + expect(getFileType('document.pdf')).toBe('doc'); 36 + }); 37 + 38 + it('returns "slide" for .pptx files', () => { 39 + expect(getFileType('deck.pptx')).toBe('slide'); 40 + }); 41 + 34 42 it('returns null for unsupported extensions', () => { 35 43 expect(getFileType('image.png')).toBeNull(); 36 44 expect(getFileType('script.js')).toBeNull(); 37 45 expect(getFileType('archive.zip')).toBeNull(); 38 - expect(getFileType('document.pdf')).toBeNull(); 39 46 expect(getFileType('readme.txt')).toBeNull(); 40 47 }); 41 48 ··· 78 85 expect(getImportType('DATA.CSV')).toBe('csv'); 79 86 }); 80 87 88 + it('returns "pdf" for .pdf files', () => { 89 + expect(getImportType('document.pdf')).toBe('pdf'); 90 + }); 91 + 92 + it('returns "pptx" for .pptx files', () => { 93 + expect(getImportType('deck.pptx')).toBe('pptx'); 94 + }); 95 + 81 96 it('returns null for unsupported extensions', () => { 82 97 expect(getImportType('image.png')).toBeNull(); 83 - expect(getImportType('document.pdf')).toBeNull(); 98 + expect(getImportType('archive.zip')).toBeNull(); 84 99 }); 85 100 86 101 it('returns null for empty or invalid input', () => { ··· 112 127 }); 113 128 114 129 describe('SUPPORTED_EXTENSIONS', () => { 115 - it('includes all four supported extensions', () => { 130 + it('includes all supported extensions', () => { 116 131 expect(SUPPORTED_EXTENSIONS).toContain('docx'); 117 132 expect(SUPPORTED_EXTENSIONS).toContain('md'); 133 + expect(SUPPORTED_EXTENSIONS).toContain('pdf'); 118 134 expect(SUPPORTED_EXTENSIONS).toContain('xlsx'); 119 135 expect(SUPPORTED_EXTENSIONS).toContain('csv'); 136 + expect(SUPPORTED_EXTENSIONS).toContain('pptx'); 120 137 }); 121 138 122 - it('has exactly four entries', () => { 123 - expect(SUPPORTED_EXTENSIONS).toHaveLength(4); 139 + it('has exactly six entries', () => { 140 + expect(SUPPORTED_EXTENSIONS).toHaveLength(6); 124 141 }); 125 142 });
+44
tests/pdf-import.test.ts
··· 1 + /** 2 + * Tests for PDF import validation and HTML conversion helpers. 3 + * 4 + * pdf.js requires browser APIs so convertPdfToHtml is not tested here — 5 + * only the pure isValidPdf validator is covered, which is DOM-free. 6 + */ 7 + import { describe, it, expect } from 'vitest'; 8 + import { isValidPdf } from '../src/docs/pdf-import.js'; 9 + 10 + function makeBuffer(bytes: number[]): ArrayBuffer { 11 + return new Uint8Array(bytes).buffer; 12 + } 13 + 14 + describe('isValidPdf', () => { 15 + it('returns true for a buffer starting with %PDF-', () => { 16 + // %PDF- = 0x25 0x50 0x44 0x46 0x2D 17 + const buf = makeBuffer([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); 18 + expect(isValidPdf(buf)).toBe(true); 19 + }); 20 + 21 + it('returns false for a buffer that does not start with %PDF-', () => { 22 + const buf = makeBuffer([0x50, 0x4B, 0x03, 0x04]); // PK (ZIP) 23 + expect(isValidPdf(buf)).toBe(false); 24 + }); 25 + 26 + it('returns false for an empty buffer', () => { 27 + expect(isValidPdf(new ArrayBuffer(0))).toBe(false); 28 + }); 29 + 30 + it('returns false for a buffer shorter than 5 bytes', () => { 31 + expect(isValidPdf(makeBuffer([0x25, 0x50, 0x44]))).toBe(false); 32 + }); 33 + 34 + it('returns false for a buffer with correct first byte but wrong rest', () => { 35 + expect(isValidPdf(makeBuffer([0x25, 0x00, 0x00, 0x00, 0x00]))).toBe(false); 36 + }); 37 + 38 + it('returns false for null/undefined-like (non-ArrayBuffer) via type safety', () => { 39 + // TypeScript won't allow passing null, but check the guard still works for 40 + // runtime safety (cast to any for the test). 41 + expect(isValidPdf(null as unknown as ArrayBuffer)).toBe(false); 42 + expect(isValidPdf(undefined as unknown as ArrayBuffer)).toBe(false); 43 + }); 44 + });
+217
tests/pptx-import.test.ts
··· 1 + /** 2 + * Tests for PPTX import — isValidPptx and convertPptxToDeck. 3 + * 4 + * convertPptxToDeck uses DOMParser (available in jsdom) and JSZip, 5 + * so we can test it end-to-end with minimal synthetic PPTX buffers. 6 + */ 7 + 8 + // @vitest-environment jsdom 9 + 10 + import { describe, it, expect } from 'vitest'; 11 + import JSZip from 'jszip'; 12 + import { isValidPptx, convertPptxToDeck } from '../src/slides/pptx-import.js'; 13 + 14 + function makeBuffer(bytes: number[]): ArrayBuffer { 15 + return new Uint8Array(bytes).buffer; 16 + } 17 + 18 + // --------------------------------------------------------------------------- 19 + // isValidPptx 20 + // --------------------------------------------------------------------------- 21 + 22 + describe('isValidPptx', () => { 23 + it('returns true for a ZIP-signature buffer', () => { 24 + // PK\x03\x04 25 + expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03, 0x04, 0x00]))).toBe(true); 26 + }); 27 + 28 + it('returns false for a non-ZIP buffer', () => { 29 + expect(isValidPptx(makeBuffer([0x25, 0x50, 0x44, 0x46, 0x2D]))).toBe(false); // PDF 30 + }); 31 + 32 + it('returns false for an empty buffer', () => { 33 + expect(isValidPptx(new ArrayBuffer(0))).toBe(false); 34 + }); 35 + 36 + it('returns false for a buffer shorter than 4 bytes', () => { 37 + expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03]))).toBe(false); 38 + }); 39 + 40 + it('returns false for null/undefined', () => { 41 + expect(isValidPptx(null as unknown as ArrayBuffer)).toBe(false); 42 + expect(isValidPptx(undefined as unknown as ArrayBuffer)).toBe(false); 43 + }); 44 + }); 45 + 46 + // --------------------------------------------------------------------------- 47 + // Helpers to build minimal synthetic PPTX ZIPs 48 + // --------------------------------------------------------------------------- 49 + 50 + /** Minimal slide XML with one title shape and one body shape. */ 51 + function makeSlideXml(title: string, body: string): string { 52 + return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 53 + <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 54 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" 55 + xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> 56 + <p:cSld> 57 + <p:spTree> 58 + <p:sp> 59 + <p:nvSpPr> 60 + <p:ph type="title"/> 61 + </p:nvSpPr> 62 + <p:spPr> 63 + <a:xfrm> 64 + <a:off x="457200" y="274638"/> 65 + <a:ext cx="8229600" cy="1143000"/> 66 + </a:xfrm> 67 + </p:spPr> 68 + <p:txBody> 69 + <a:p><a:r><a:rPr sz="4400" b="1"/><a:t>${title}</a:t></a:r></a:p> 70 + </p:txBody> 71 + </p:sp> 72 + <p:sp> 73 + <p:nvSpPr> 74 + <p:ph type="body"/> 75 + </p:nvSpPr> 76 + <p:spPr> 77 + <a:xfrm> 78 + <a:off x="457200" y="1600200"/> 79 + <a:ext cx="8229600" cy="3700620"/> 80 + </a:xfrm> 81 + </p:spPr> 82 + <p:txBody> 83 + <a:p><a:r><a:rPr sz="2400"/><a:t>${body}</a:t></a:r></a:p> 84 + </p:txBody> 85 + </p:sp> 86 + </p:spTree> 87 + </p:cSld> 88 + </p:sld>`; 89 + } 90 + 91 + /** Build a minimal PPTX zip with the given slide XMLs (no presentation.xml, uses file order). */ 92 + async function buildMinimalPptx(slides: string[]): Promise<ArrayBuffer> { 93 + const zip = new JSZip(); 94 + for (let i = 0; i < slides.length; i++) { 95 + zip.file(`ppt/slides/slide${i + 1}.xml`, slides[i]!); 96 + } 97 + return zip.generateAsync({ type: 'arraybuffer' }); 98 + } 99 + 100 + // --------------------------------------------------------------------------- 101 + // convertPptxToDeck 102 + // --------------------------------------------------------------------------- 103 + 104 + describe('convertPptxToDeck', () => { 105 + it('produces a deck with one slide for a single-slide PPTX', async () => { 106 + const buf = await buildMinimalPptx([makeSlideXml('Hello World', 'Slide content')]); 107 + const deck = await convertPptxToDeck(buf); 108 + expect(deck.slides).toHaveLength(1); 109 + expect(deck.currentSlide).toBe(0); 110 + expect(deck.aspectRatio).toBeCloseTo(16 / 9, 2); 111 + }); 112 + 113 + it('extracts title text into a slide element', async () => { 114 + const buf = await buildMinimalPptx([makeSlideXml('My Title', 'Body text here')]); 115 + const deck = await convertPptxToDeck(buf); 116 + const slide = deck.slides[0]!; 117 + const titleEl = slide.elements.find(e => e.content === 'My Title'); 118 + expect(titleEl).toBeDefined(); 119 + expect(titleEl!.type).toBe('text'); 120 + }); 121 + 122 + it('extracts body text into a slide element', async () => { 123 + const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body text here')]); 124 + const deck = await convertPptxToDeck(buf); 125 + const slide = deck.slides[0]!; 126 + const bodyEl = slide.elements.find(e => e.content === 'Body text here'); 127 + expect(bodyEl).toBeDefined(); 128 + }); 129 + 130 + it('handles multiple slides', async () => { 131 + const buf = await buildMinimalPptx([ 132 + makeSlideXml('Slide One', 'First slide content'), 133 + makeSlideXml('Slide Two', 'Second slide content'), 134 + makeSlideXml('Slide Three', 'Third slide content'), 135 + ]); 136 + const deck = await convertPptxToDeck(buf); 137 + expect(deck.slides).toHaveLength(3); 138 + }); 139 + 140 + it('maps EMU coordinates to pixel positions within canvas bounds', async () => { 141 + const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]); 142 + const deck = await convertPptxToDeck(buf); 143 + const slide = deck.slides[0]!; 144 + for (const el of slide.elements) { 145 + expect(el.x).toBeGreaterThanOrEqual(0); 146 + expect(el.y).toBeGreaterThanOrEqual(0); 147 + expect(el.width).toBeGreaterThan(0); 148 + expect(el.height).toBeGreaterThan(0); 149 + } 150 + }); 151 + 152 + it('returns a single blank slide for an empty PPTX (no slides)', async () => { 153 + const zip = new JSZip(); 154 + zip.file('ppt/placeholder.txt', 'empty'); 155 + const buf = await zip.generateAsync({ type: 'arraybuffer' }); 156 + const deck = await convertPptxToDeck(buf); 157 + expect(deck.slides).toHaveLength(1); 158 + expect(deck.slides[0]!.elements).toHaveLength(0); 159 + }); 160 + 161 + it('skips shapes with no text content', async () => { 162 + const xml = `<?xml version="1.0" encoding="UTF-8"?> 163 + <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 164 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 165 + <p:cSld> 166 + <p:spTree> 167 + <p:sp> 168 + <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 169 + <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr> 170 + <p:txBody><a:p><a:r><a:t> </a:t></a:r></a:p></p:txBody> 171 + </p:sp> 172 + </p:spTree> 173 + </p:cSld> 174 + </p:sld>`; 175 + const buf = await buildMinimalPptx([xml]); 176 + const deck = await convertPptxToDeck(buf); 177 + // Whitespace-only shape should be skipped 178 + expect(deck.slides[0]!.elements).toHaveLength(0); 179 + }); 180 + 181 + it('applies title font weight bold', async () => { 182 + const buf = await buildMinimalPptx([makeSlideXml('Bold Title', 'content')]); 183 + const deck = await convertPptxToDeck(buf); 184 + const titleEl = deck.slides[0]!.elements.find(e => e.content === 'Bold Title'); 185 + expect(titleEl?.style['fontWeight']).toBe('bold'); 186 + }); 187 + 188 + it('extracts background color from solidFill', async () => { 189 + const xml = `<?xml version="1.0" encoding="UTF-8"?> 190 + <p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 191 + xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> 192 + <p:cSld> 193 + <p:bg> 194 + <p:bgPr> 195 + <a:solidFill><a:srgbClr val="1a2b3c"/></a:solidFill> 196 + </p:bgPr> 197 + </p:bg> 198 + <p:spTree> 199 + <p:sp> 200 + <p:nvSpPr><p:ph type="title"/></p:nvSpPr> 201 + <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr> 202 + <p:txBody><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:txBody> 203 + </p:sp> 204 + </p:spTree> 205 + </p:cSld> 206 + </p:sld>`; 207 + const buf = await buildMinimalPptx([xml]); 208 + const deck = await convertPptxToDeck(buf); 209 + expect(deck.slides[0]!.background).toBe('#1a2b3c'); 210 + }); 211 + 212 + it('defaults to white background when none specified', async () => { 213 + const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]); 214 + const deck = await convertPptxToDeck(buf); 215 + expect(deck.slides[0]!.background).toBe('#ffffff'); 216 + }); 217 + });