feat: docx export
This commit is contained in:
1 parent
dc1352b9cd
commit
9700487353
6 files changed
+920
-4
No files matched your search
Generated
+194
-1
@@ -19,7 +19,7 @@
|
||||
"@tiptap/extension-highlight": "3.23.6",
|
||||
"@tiptap/extension-history": "^3.23.6",
|
||||
"@tiptap/extension-italic": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
"@tiptap/extension-link": "3.23.6",
|
||||
"@tiptap/extension-list-item": "^3.23.6",
|
||||
"@tiptap/extension-ordered-list": "^3.23.6",
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/extension-underline": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"docx": "^9.7.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"nspell": "^2.1.5",
|
||||
@@ -1108,6 +1109,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
|
||||
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
@@ -1378,6 +1388,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
@@ -1460,6 +1476,41 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
@@ -1585,6 +1636,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
@@ -1611,6 +1672,18 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -1657,6 +1730,12 @@
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
@@ -1715,6 +1794,33 @@
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -2028,6 +2134,12 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -2175,6 +2287,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
@@ -2324,6 +2442,21 @@
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2418,6 +2551,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -2431,6 +2579,12 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -2472,6 +2626,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.9",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
|
||||
@@ -2673,6 +2836,18 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
@@ -2941,6 +3116,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/extension-underline": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"docx": "^9.7.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"nspell": "^2.1.5",
|
||||
|
||||
+31
-1
@@ -8,7 +8,7 @@
|
||||
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
|
||||
import { buildOdt, deriveFilename } from './lib/export/odt';
|
||||
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
|
||||
import { supportsFsAccess, saveOdt, saveAsOdt, openOdt } from './lib/export/saveFile';
|
||||
import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, openOdt } from './lib/export/saveFile';
|
||||
import { importOdt } from './lib/import/odt';
|
||||
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
|
||||
import { getColorDebug } from './lib/utils/colorDebug';
|
||||
@@ -104,6 +104,11 @@
|
||||
return n ? `${sanitizeNameForFile(n)}.odt` : deriveFilename(json);
|
||||
}
|
||||
|
||||
function suggestedFilenameDocx(json: Parameters<typeof buildOdt>[0]): string {
|
||||
const n = documentName.trim();
|
||||
return n ? `${sanitizeNameForFile(n)}.docx` : deriveFilename(json).replace(/\.odt$/, '.docx');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
saveFormattingMarks(showFormattingMarks);
|
||||
});
|
||||
@@ -259,6 +264,7 @@
|
||||
const fsSupported = supportsFsAccess();
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
let pdfBusy = $state(false);
|
||||
let docxBusy = $state(false);
|
||||
let exportMenuOpen = $state(false);
|
||||
|
||||
// odf-kit export options for the current header/footer + page geometry.
|
||||
@@ -388,6 +394,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Export to Word .docx. The exporter (and the `docx` library) is lazy-loaded so it
|
||||
// never enters the initial bundle. Export-style like PDF: always prompt, no handle.
|
||||
async function handleSaveDocx() {
|
||||
if (!editor || docxBusy) return;
|
||||
exportMenuOpen = false;
|
||||
docxBusy = true;
|
||||
try {
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
|
||||
await saveAsDocx(bytes, suggestedFilenameDocx(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
console.error('[docx] Export failed:', err);
|
||||
alert('Could not export to Word (.docx).');
|
||||
} finally {
|
||||
docxBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lay the document out into A4 pages (Paged.js) and open the print dialog so the
|
||||
// user can "Save as PDF" — vector text matching the editor, with header/footer.
|
||||
async function handleExportPdf() {
|
||||
@@ -601,6 +627,10 @@
|
||||
<span>ODT</span>
|
||||
<span class="theme-option-hint">OpenDocument · fully editable</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={handleSaveDocx} disabled={docxBusy} role="menuitem">
|
||||
<span>{docxBusy ? 'Exporting…' : 'Word (.docx)'}</span>
|
||||
<span class="theme-option-hint">Microsoft Word · editable</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
|
||||
<span>{pdfBusy ? 'Exporting…' : 'Raster PDF'}</span>
|
||||
<span class="theme-option-hint">Exact copy of the editor</span>
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import {
|
||||
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
|
||||
Table, TableRow, TableCell, Header, Footer, PageNumber,
|
||||
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType,
|
||||
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
|
||||
TextWrappingType, TextWrappingSide,
|
||||
} from 'docx';
|
||||
import type { TiptapNode } from 'odf-kit';
|
||||
import type {
|
||||
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
|
||||
ILevelsOptions, IFloating, IBorderOptions,
|
||||
} from 'docx';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeDef } from '../utils/orderedListTypes';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
|
||||
|
||||
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
|
||||
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
|
||||
// module nor `docx` enters the initial bundle. Import is a later, separate step.
|
||||
|
||||
type Writable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
// Font shown on screen (bundled, metric-identical to Times New Roman) vs. the font
|
||||
// declared in the file — same trade-off as odt.ts.
|
||||
const SCREEN_FONT = 'Liberation Serif';
|
||||
const DOC_FONT = 'Times New Roman';
|
||||
|
||||
// Word's bullet glyph per nesting level (matches odt.ts CELL_BULLET_CHARS).
|
||||
const BULLET_CHARS = ['•', '◦', '▪', '▸', '–', '·'];
|
||||
|
||||
// List geometry: 0.5in left step per level, 0.25in hanging for the marker (Word defaults).
|
||||
const LIST_LEFT_STEP_CM = 1.27;
|
||||
const LIST_HANGING_CM = 0.635;
|
||||
|
||||
// ODF num-format char → Word numbering format.
|
||||
const ORDERED_FORMAT: Record<string, (typeof LevelFormat)[keyof typeof LevelFormat]> = {
|
||||
'1': LevelFormat.DECIMAL,
|
||||
a: LevelFormat.LOWER_LETTER,
|
||||
A: LevelFormat.UPPER_LETTER,
|
||||
i: LevelFormat.LOWER_ROMAN,
|
||||
I: LevelFormat.UPPER_ROMAN,
|
||||
};
|
||||
|
||||
// ---- unit conversions ------------------------------------------------------
|
||||
const TWIPS_PER_CM = 1440 / 2.54; // 566.929
|
||||
const cmToTwip = (cm: number) => Math.round(cm * TWIPS_PER_CM);
|
||||
const ptToTwip = (pt: number) => Math.round(pt * 20);
|
||||
const pxToTwip = (px: number) => Math.round((px * 1440) / 96); // px @96dpi → twips
|
||||
|
||||
// CSS font-size string ("12pt", "16px", "1.2em") → Word half-points. em is relative
|
||||
// to the 12pt body. Returns undefined when unparseable.
|
||||
function fontSizeToHalfPoints(css: string): number | undefined {
|
||||
const m = /^([\d.]+)\s*(pt|px|em|rem)?$/.exec(css.trim());
|
||||
if (!m) return undefined;
|
||||
const n = parseFloat(m[1]);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
const unit = m[2] || 'pt';
|
||||
const pt = unit === 'px' ? n * 0.75 : unit === 'em' || unit === 'rem' ? n * 12 : n;
|
||||
return Math.max(1, Math.round(pt * 2));
|
||||
}
|
||||
|
||||
// normalizeColor → Word's #-less uppercase hex; named colors pass through (rarely used).
|
||||
function hexColor(input: string): string | undefined {
|
||||
const n = normalizeColor(input);
|
||||
if (!n) return undefined;
|
||||
return n.startsWith('#') ? n.slice(1) : n;
|
||||
}
|
||||
|
||||
// ---- numbering registry ----------------------------------------------------
|
||||
// docx needs all numbering definitions up front (passed to the Document). We build
|
||||
// the body paragraphs first, allocating one reference per top-level list and filling
|
||||
// in each nesting level's format the first time we meet a list node at that depth.
|
||||
class Numbering {
|
||||
readonly config: { reference: string; levels: ILevelsOptions[] }[] = [];
|
||||
private map = new Map<string, ILevelsOptions[]>();
|
||||
private counter = 0;
|
||||
|
||||
newReference(): string {
|
||||
const reference = `num-${this.counter++}`;
|
||||
const levels: ILevelsOptions[] = [];
|
||||
this.map.set(reference, levels);
|
||||
this.config.push({ reference, levels });
|
||||
return reference;
|
||||
}
|
||||
|
||||
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number): void {
|
||||
const levels = this.map.get(reference)!;
|
||||
if (levels.some((l) => l.level === depth)) return;
|
||||
const indent: IIndentAttributesProperties = {
|
||||
left: cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + extraIndentCm),
|
||||
hanging: cmToTwip(LIST_HANGING_CM),
|
||||
};
|
||||
if (node.type === 'orderedList') {
|
||||
const def = orderedTypeDef(node.attrs?.listStyleType as string | undefined);
|
||||
levels.push({
|
||||
level: depth,
|
||||
format: ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL,
|
||||
text: `%${depth + 1}${def.numSuffix}`,
|
||||
alignment: AlignmentType.LEFT,
|
||||
start: typeof node.attrs?.start === 'number' ? node.attrs.start : 1,
|
||||
style: { paragraph: { indent } },
|
||||
});
|
||||
} else {
|
||||
levels.push({
|
||||
level: depth,
|
||||
format: LevelFormat.BULLET,
|
||||
text: BULLET_CHARS[depth % BULLET_CHARS.length],
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inline runs -----------------------------------------------------------
|
||||
function markPresent(marks: TiptapNode['marks'], type: string): boolean {
|
||||
return !!marks?.some((m) => m.type === type);
|
||||
}
|
||||
|
||||
// TipTap marks + textStyle attrs → Word run properties. forceBold bakes bold onto
|
||||
// every run (header-row cells), respecting an explicit fontWeight:normal un-bold.
|
||||
function runPropsFromMarks(marks: TiptapNode['marks'] = [], forceBold = false): Writable<IRunStylePropertiesOptions> {
|
||||
const ts = marks.find((m) => m.type === 'textStyle');
|
||||
const props: Writable<IRunStylePropertiesOptions> = {};
|
||||
|
||||
let bold: boolean | undefined = markPresent(marks, 'bold') || undefined;
|
||||
const fw = ts?.attrs?.fontWeight;
|
||||
if (fw != null) {
|
||||
const s = String(fw);
|
||||
if (s === 'normal' || s === '400') bold = false;
|
||||
else if (s === 'bold' || /^[5-9]\d\d$/.test(s)) bold = true;
|
||||
}
|
||||
if (forceBold && bold !== false) bold = true;
|
||||
if (bold !== undefined) props.bold = bold;
|
||||
|
||||
if (markPresent(marks, 'italic')) props.italics = true;
|
||||
if (markPresent(marks, 'underline')) props.underline = { type: UnderlineType.SINGLE };
|
||||
if (markPresent(marks, 'strike')) props.strike = true;
|
||||
if (markPresent(marks, 'superscript')) props.superScript = true;
|
||||
else if (markPresent(marks, 'subscript')) props.subScript = true;
|
||||
|
||||
const ff = ts?.attrs?.fontFamily;
|
||||
if (ff) props.font = String(ff) === SCREEN_FONT ? DOC_FONT : String(ff);
|
||||
const fs = ts?.attrs?.fontSize;
|
||||
if (fs) {
|
||||
const hp = fontSizeToHalfPoints(String(fs));
|
||||
if (hp) props.size = hp;
|
||||
}
|
||||
const col = ts?.attrs?.color;
|
||||
if (col) {
|
||||
const h = hexColor(String(col));
|
||||
if (h) props.color = h;
|
||||
}
|
||||
const hl = marks.find((m) => m.type === 'highlight');
|
||||
if (hl?.attrs?.color) {
|
||||
const h = hexColor(String(hl.attrs.color));
|
||||
if (h) props.shading = { type: ShadingType.CLEAR, fill: h };
|
||||
}
|
||||
// Visible hyperlink styling (the ExternalHyperlink wrapper carries the target).
|
||||
if (markPresent(marks, 'link')) {
|
||||
if (!props.color) props.color = '0563C1';
|
||||
if (!props.underline) props.underline = { type: UnderlineType.SINGLE };
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
// A text node → one run; tab chars become <w:tab/> via mixed children (a literal \t
|
||||
// would otherwise be dropped). Returned as an array so a link wrapper can adopt it.
|
||||
function textNodeToRuns(node: TiptapNode, forceBold: boolean): TextRun[] {
|
||||
const text = node.text ?? '';
|
||||
const props = runPropsFromMarks(node.marks, forceBold);
|
||||
if (!text.includes('\t')) return [new TextRun({ text, ...props })];
|
||||
const parts = text.split('\t');
|
||||
const children: (string | Tab)[] = [];
|
||||
parts.forEach((p, i) => {
|
||||
if (i > 0) children.push(new Tab());
|
||||
if (p) children.push(p);
|
||||
});
|
||||
return [new TextRun({ children, ...props })];
|
||||
}
|
||||
|
||||
type Inline = TextRun | ImageRun | ExternalHyperlink;
|
||||
|
||||
function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] {
|
||||
const out: Inline[] = [];
|
||||
for (const node of content) {
|
||||
if (node.type === 'text' && node.text) {
|
||||
const runs = textNodeToRuns(node, forceBold);
|
||||
const href = node.marks?.find((m) => m.type === 'link')?.attrs?.href;
|
||||
if (href) out.push(new ExternalHyperlink({ link: String(href), children: runs }));
|
||||
else out.push(...runs);
|
||||
} else if (node.type === 'hardBreak') {
|
||||
out.push(new TextRun({ break: 1 }));
|
||||
} else if (node.type === 'image') {
|
||||
const img = imageRun(node);
|
||||
if (img) out.push(img);
|
||||
} else if (node.type === 'pageNumber') {
|
||||
out.push(new TextRun({ children: [PageNumber.CURRENT] }));
|
||||
} else if (node.type === 'pageCount') {
|
||||
out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES] }));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- images ----------------------------------------------------------------
|
||||
const DOCX_IMG_TYPE: Record<string, 'png' | 'jpg' | 'gif' | 'bmp'> = {
|
||||
'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/bmp': 'bmp',
|
||||
};
|
||||
|
||||
function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' | 'gif' | 'bmp' } | null {
|
||||
const m = /^data:([^;,]+)(;[^,]*)?,([\s\S]*)$/.exec(src);
|
||||
if (!m) return null;
|
||||
const type = DOCX_IMG_TYPE[m[1] || 'image/png'];
|
||||
if (!type) return null; // svg / unsupported → skip (Word needs a raster fallback)
|
||||
const isB64 = (m[2] ?? '').includes('base64');
|
||||
try {
|
||||
if (isB64) {
|
||||
const bin = atob(m[3]);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.length ? { bytes, type } : null;
|
||||
}
|
||||
const bytes = new TextEncoder().encode(decodeURIComponent(m[3]));
|
||||
return bytes.length ? { bytes, type } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function floatingFor(wrap: string): IFloating | undefined {
|
||||
if (wrap === 'inline') return undefined;
|
||||
const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: 0 };
|
||||
if (wrap === 'topBottom') {
|
||||
return {
|
||||
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
|
||||
verticalPosition,
|
||||
wrap: { type: TextWrappingType.TOP_AND_BOTTOM },
|
||||
allowOverlap: false,
|
||||
};
|
||||
}
|
||||
// left: image at left, text on the right; right: mirror.
|
||||
const align = wrap === 'right' ? HorizontalPositionAlign.RIGHT : HorizontalPositionAlign.LEFT;
|
||||
const side = wrap === 'right' ? TextWrappingSide.LEFT : TextWrappingSide.RIGHT;
|
||||
return {
|
||||
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align },
|
||||
verticalPosition,
|
||||
wrap: { type: TextWrappingType.SQUARE, side },
|
||||
allowOverlap: false,
|
||||
};
|
||||
}
|
||||
|
||||
function imageRun(node: TiptapNode): ImageRun | null {
|
||||
const src = node.attrs?.src;
|
||||
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
|
||||
const decoded = decodeDataUri(src);
|
||||
if (!decoded) return null;
|
||||
const width = typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? Math.round(node.attrs.width) : 200;
|
||||
const height = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? Math.round(node.attrs.height) : 150;
|
||||
const rotation = typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0;
|
||||
const wrap = String(node.attrs?.wrap ?? 'inline');
|
||||
return new ImageRun({
|
||||
type: decoded.type,
|
||||
data: decoded.bytes,
|
||||
altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined,
|
||||
transformation: { width, height, rotation: rotation || undefined },
|
||||
floating: floatingFor(wrap),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- paragraphs ------------------------------------------------------------
|
||||
function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
|
||||
switch (attrs?.textAlign) {
|
||||
case 'center': return AlignmentType.CENTER;
|
||||
case 'right': return AlignmentType.RIGHT;
|
||||
case 'justify': return AlignmentType.BOTH;
|
||||
case 'left': return AlignmentType.LEFT;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
|
||||
const s: Writable<ISpacingProperties> = {};
|
||||
if (typeof attrs?.spaceBefore === 'number') s.before = ptToTwip(attrs.spaceBefore);
|
||||
if (typeof attrs?.spaceAfter === 'number') s.after = ptToTwip(attrs.spaceAfter);
|
||||
const lh = attrs?.lineHeight;
|
||||
if (lh != null) {
|
||||
const n = parseFloat(String(lh));
|
||||
if (Number.isFinite(n) && !/pt|px/.test(String(lh))) {
|
||||
s.line = Math.round(n * 240);
|
||||
s.lineRule = LineRuleType.AUTO;
|
||||
} else if (Number.isFinite(n)) {
|
||||
s.line = ptToTwip(/px/.test(String(lh)) ? n * 0.75 : n);
|
||||
s.lineRule = LineRuleType.EXACT;
|
||||
}
|
||||
}
|
||||
return Object.keys(s).length ? s : undefined;
|
||||
}
|
||||
|
||||
const HEADING_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3,
|
||||
};
|
||||
|
||||
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
|
||||
|
||||
function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
|
||||
const attrs = node.attrs ?? {};
|
||||
const indent: Writable<IIndentAttributesProperties> = {};
|
||||
if (!opts.numbering) {
|
||||
if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent);
|
||||
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
|
||||
}
|
||||
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(3, Math.max(1, Number(attrs.level) || 1))] : undefined;
|
||||
return new Paragraph({
|
||||
heading,
|
||||
alignment: alignOf(attrs),
|
||||
spacing: spacingOf(attrs),
|
||||
indent: indent.left != null ? indent : undefined,
|
||||
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
|
||||
numbering: opts.numbering,
|
||||
children: inlineToRuns(node.content, opts.forceBold),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- lists -----------------------------------------------------------------
|
||||
function listToParagraphs(
|
||||
node: TiptapNode, depth: number, reference: string, extraIndentCm: number,
|
||||
num: Numbering, out: (Paragraph | Table)[],
|
||||
): void {
|
||||
const indentCm = extraIndentCm + (typeof node.attrs?.indent === 'number' ? node.attrs.indent : 0);
|
||||
num.ensureLevel(reference, depth, node, indentCm);
|
||||
const levelLeftTwip = cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + indentCm);
|
||||
for (const item of node.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
let numberedFirst = false;
|
||||
for (const child of item.content ?? []) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||||
listToParagraphs(child, depth + 1, reference, indentCm, num, out);
|
||||
} else if (child.type === 'paragraph' || child.type === 'heading') {
|
||||
if (!numberedFirst) {
|
||||
out.push(paragraphToDocx(child, { numbering: { reference, level: depth } }));
|
||||
numberedFirst = true;
|
||||
} else {
|
||||
out.push(paragraphToDocx(child, { indentLeftTwip: levelLeftTwip }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tables ----------------------------------------------------------------
|
||||
// Per-column widths (cm) from the first row's proportional colwidth weights, scaled
|
||||
// to fill the text column exactly. Mirrors odt.ts tableColumnWidthsCm.
|
||||
function columnWidthsCm(node: TiptapNode, contentWidthCm: number): number[] | undefined {
|
||||
const firstRow = (node.content ?? []).find((r) => r.type === 'tableRow');
|
||||
if (!firstRow) return undefined;
|
||||
const weights: (number | null)[] = [];
|
||||
for (const cell of firstRow.content ?? []) {
|
||||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||||
const cw = cell.attrs?.colwidth as number[] | null | undefined;
|
||||
for (let k = 0; k < colspan; k++) weights.push(cw && cw[k] ? cw[k] : null);
|
||||
}
|
||||
if (weights.length === 0) return undefined;
|
||||
const present = weights.filter((w): w is number => w != null);
|
||||
const avg = present.length ? present.reduce((a, b) => a + b, 0) / present.length : 1;
|
||||
const filled = weights.map((w) => (w != null ? w : avg));
|
||||
const total = filled.reduce((a, b) => a + b, 0) || 1;
|
||||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
const cm = filled.map((w) => round3((w / total) * contentWidthCm));
|
||||
const sum = cm.reduce((a, b) => a + b, 0);
|
||||
cm[cm.length - 1] = round3(cm[cm.length - 1] + (contentWidthCm - sum));
|
||||
return cm;
|
||||
}
|
||||
|
||||
const cellBorder: IBorderOptions = { style: BorderStyle.SINGLE, size: 4, color: '000000' }; // 0.5pt
|
||||
|
||||
function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
|
||||
const out: (Paragraph | Table)[] = [];
|
||||
for (const child of content) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||||
listToParagraphs(child, 0, num.newReference(), 0, num, out);
|
||||
} else if (child.type === 'table') {
|
||||
out.push(tableToDocx(child, contentWidthCm, num));
|
||||
} else if (child.type === 'paragraph' || child.type === 'heading') {
|
||||
out.push(paragraphToDocx(child, { forceBold: headerBold }));
|
||||
}
|
||||
}
|
||||
if (out.length === 0) out.push(new Paragraph({}));
|
||||
return out;
|
||||
}
|
||||
|
||||
function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): Table {
|
||||
const rows = (node.content ?? []).filter((r) => r.type === 'tableRow');
|
||||
const colsCm = columnWidthsCm(node, contentWidthCm);
|
||||
const colsTwip = colsCm?.map(cmToTwip);
|
||||
const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm);
|
||||
|
||||
const tableRows = rows.map((row) => {
|
||||
const rh = row.attrs?.rowHeight;
|
||||
let col = 0;
|
||||
const cells: TableCell[] = [];
|
||||
for (const cell of row.content ?? []) {
|
||||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||||
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
|
||||
const bg = cell.attrs?.backgroundColor;
|
||||
const fill = typeof bg === 'string' ? hexColor(bg) : undefined;
|
||||
const widthTwip = colsTwip ? colsTwip.slice(col, col + colspan).reduce((a, b) => a + b, 0) : undefined;
|
||||
col += colspan;
|
||||
cells.push(new TableCell({
|
||||
columnSpan: colspan > 1 ? colspan : undefined,
|
||||
rowSpan: rowspan > 1 ? rowspan : undefined,
|
||||
shading: fill ? { type: ShadingType.CLEAR, fill } : undefined,
|
||||
width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined,
|
||||
borders: { top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder },
|
||||
children: cellBlocksToDocx(cell.content, bg === HEADER_SHADE, num, contentWidthCm),
|
||||
}));
|
||||
}
|
||||
return new TableRow({
|
||||
height: typeof rh === 'number' && rh > 0 ? { value: pxToTwip(rh), rule: HeightRule.ATLEAST } : undefined,
|
||||
children: cells,
|
||||
});
|
||||
});
|
||||
|
||||
return new Table({
|
||||
rows: tableRows,
|
||||
columnWidths: colsTwip,
|
||||
width: { size: totalTwip, type: WidthType.DXA },
|
||||
layout: TableLayoutType.FIXED,
|
||||
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) },
|
||||
borders: {
|
||||
top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder,
|
||||
insideHorizontal: cellBorder, insideVertical: cellBorder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- top-level walk --------------------------------------------------------
|
||||
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
|
||||
const out: (Paragraph | Table)[] = [];
|
||||
for (const node of content) {
|
||||
if (node.type === 'paragraph' || node.type === 'heading') {
|
||||
out.push(paragraphToDocx(node));
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
||||
listToParagraphs(node, 0, num.newReference(), 0, num, out);
|
||||
} else if (node.type === 'table') {
|
||||
out.push(tableToDocx(node, contentWidthCm, num));
|
||||
} else if (node.type === 'image') {
|
||||
out.push(new Paragraph({ children: inlineToRuns([node]) }));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- document styles -------------------------------------------------------
|
||||
function headingStyle(o: { fontSize: string; marginTop: string; marginBottom: string }) {
|
||||
return {
|
||||
run: { bold: true, color: '000000', font: DOC_FONT, size: Math.round(parseFloat(o.fontSize) * 2) },
|
||||
paragraph: { spacing: { before: cmToTwip(parseFloat(o.marginTop)), after: cmToTwip(parseFloat(o.marginBottom)), line: 240, lineRule: LineRuleType.AUTO }, keepNext: true },
|
||||
};
|
||||
}
|
||||
|
||||
function buildStyles(language?: { language: string; country: string } | null) {
|
||||
const run: Writable<IRunStylePropertiesOptions> = { font: DOC_FONT, size: 24 };
|
||||
if (language) run.language = { value: `${language.language}-${language.country}` };
|
||||
return {
|
||||
default: {
|
||||
document: { run, paragraph: { spacing: { after: 0, line: 240, lineRule: LineRuleType.AUTO } } },
|
||||
heading1: headingStyle(HEADING_STYLE_OVERRIDES[0]),
|
||||
heading2: headingStyle(HEADING_STYLE_OVERRIDES[1]),
|
||||
heading3: headingStyle(HEADING_STYLE_OVERRIDES[2]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- entry point -----------------------------------------------------------
|
||||
export async function buildDocx(
|
||||
docJson: TiptapNode,
|
||||
margins: PageMargins = DEFAULT_MARGINS,
|
||||
orientation: Orientation = 'portrait',
|
||||
hf?: HfExport,
|
||||
language?: { language: string; country: string } | null,
|
||||
): Promise<Uint8Array> {
|
||||
const num = new Numbering();
|
||||
const landscape = orientation === 'landscape';
|
||||
const pageWidthCm = landscape ? 29.7 : 21;
|
||||
const pageHeightCm = landscape ? 21 : 29.7;
|
||||
const contentWidthCm = pageWidthCm - margins.left - margins.right;
|
||||
|
||||
const body = blocksToDocx(docJson.content ?? [], num, contentWidthCm);
|
||||
if (body.length === 0) body.push(new Paragraph({}));
|
||||
|
||||
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
// Word's model: header/footer distance is from the page edge; the body still starts
|
||||
// at the body margin. Clamp the distance below the margin (matches odt.ts).
|
||||
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
|
||||
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Web ODF Editor',
|
||||
defaultTabStop: cmToTwip(1.25),
|
||||
styles: buildStyles(language),
|
||||
numbering: { config: num.config },
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
size: { width: cmToTwip(pageWidthCm), height: cmToTwip(pageHeightCm), orientation: landscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT },
|
||||
margin: {
|
||||
top: cmToTwip(margins.top), bottom: cmToTwip(margins.bottom),
|
||||
left: cmToTwip(margins.left), right: cmToTwip(margins.right),
|
||||
header: cmToTwip(headerDist), footer: cmToTwip(footerDist),
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: headerPara ? { default: new Header({ children: [paragraphToDocx(headerPara)] }) } : undefined,
|
||||
footers: footerPara ? { default: new Footer({ children: [paragraphToDocx(footerPara)] }) } : undefined,
|
||||
children: body,
|
||||
}],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
@@ -2,11 +2,16 @@
|
||||
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
|
||||
|
||||
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
const PICKER_TYPES = [
|
||||
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } },
|
||||
];
|
||||
|
||||
const DOCX_PICKER_TYPES = [
|
||||
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
|
||||
];
|
||||
|
||||
// showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.
|
||||
type WinFs = Window & {
|
||||
showSaveFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle>;
|
||||
@@ -17,8 +22,8 @@ export function supportsFsAccess(): boolean {
|
||||
return typeof (window as WinFs).showSaveFilePicker === 'function';
|
||||
}
|
||||
|
||||
function download(bytes: Uint8Array, name: string): void {
|
||||
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: ODT_MIME });
|
||||
function download(bytes: Uint8Array, name: string, mime: string = ODT_MIME): void {
|
||||
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -64,6 +69,18 @@ export async function saveAsOdt(
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Export a .docx: always prompt for a location (no handle is tracked — .docx is an
|
||||
// export target, like PDF, while DOCX import doesn't exist yet). Falls back to a
|
||||
// plain download. Returns null. Throws AbortError if the user cancels.
|
||||
export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Promise<void> {
|
||||
if (!supportsFsAccess()) {
|
||||
download(bytes, suggestedName, DOCX_MIME);
|
||||
return;
|
||||
}
|
||||
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCX_PICKER_TYPES });
|
||||
await writeHandle(handle, bytes);
|
||||
}
|
||||
|
||||
// Prompt for an .odt to open, capturing its handle so a later save can overwrite
|
||||
// the same file. Returns null if cancelled. Only call when supportsFsAccess().
|
||||
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { HEADER_SHADE } from '../../src/lib/editor/extensions/tableHeaderRow';
|
||||
|
||||
// 1×1 transparent PNG.
|
||||
const PNG =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
const text = (t: string, marks?: any[]) => ({ type: 'text', text: t, ...(marks ? { marks } : {}) });
|
||||
const para = (content: any, attrs: any = {}) => ({ type: 'paragraph', attrs, content: Array.isArray(content) ? content : [text(content)] });
|
||||
const heading = (level: number, t: string, attrs: any = {}) => ({ type: 'heading', attrs: { level, ...attrs }, content: [text(t)] });
|
||||
const li = (...content: any[]) => ({ type: 'listItem', content });
|
||||
const cell = (t: string, attrs: any = {}) => ({ type: 'tableCell', attrs, content: [para(t)] });
|
||||
const headerCell = (t: string, attrs: any = {}) => ({ type: 'tableHeader', attrs: { backgroundColor: HEADER_SHADE, ...attrs }, content: [para(t)] });
|
||||
|
||||
const fixture = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
heading(1, 'Title', { textAlign: 'center' }),
|
||||
para(
|
||||
[
|
||||
text('plain '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' '),
|
||||
text('red', [{ type: 'textStyle', attrs: { color: '#FF0000' } }]),
|
||||
text(' '),
|
||||
text('hi', [{ type: 'highlight', attrs: { color: '#00FF00' } }]),
|
||||
text(' '),
|
||||
text('site', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
],
|
||||
{ spaceBefore: 6, spaceAfter: 6, lineHeight: '1.5', indent: 1 },
|
||||
),
|
||||
para([text('a\tb')]),
|
||||
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
|
||||
{ type: 'bulletList', attrs: { indent: 0 }, content: [
|
||||
li(para('one')),
|
||||
li(para('two'), { type: 'bulletList', content: [li(para('nested'))] }),
|
||||
] },
|
||||
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
|
||||
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
|
||||
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
|
||||
{ type: 'tableRow', content: [cell('2')] },
|
||||
] },
|
||||
],
|
||||
} as any;
|
||||
|
||||
const hf = {
|
||||
header: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [text('My header')] }] },
|
||||
footer: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('Page '), { type: 'pageNumber' }, text(' of '), { type: 'pageCount' }] }] },
|
||||
pageCount: 1,
|
||||
} as any;
|
||||
|
||||
describe('buildDocx', () => {
|
||||
let files: Record<string, Uint8Array>;
|
||||
let bytes: Uint8Array;
|
||||
let docXml: string;
|
||||
let names: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait', hf, { language: 'en', country: 'US' });
|
||||
files = unzipSync(bytes);
|
||||
names = Object.keys(files);
|
||||
docXml = strFromU8(files['word/document.xml']);
|
||||
});
|
||||
|
||||
it('produces a valid zip (PK signature) with the core OOXML parts', () => {
|
||||
expect(bytes[0]).toBe(0x50);
|
||||
expect(bytes[1]).toBe(0x4b);
|
||||
expect(names).toContain('[Content_Types].xml');
|
||||
expect(names).toContain('word/document.xml');
|
||||
expect(names).toContain('word/numbering.xml');
|
||||
expect(names.some((n) => /^word\/media\/.*\.png$/.test(n))).toBe(true);
|
||||
expect(names.some((n) => /^word\/header\d*\.xml$/.test(n))).toBe(true);
|
||||
expect(names.some((n) => /^word\/footer\d*\.xml$/.test(n))).toBe(true);
|
||||
});
|
||||
|
||||
it('emits well-formed XML in every part (Word rejects malformed XML)', () => {
|
||||
const parser = new DOMParser();
|
||||
for (const name of names.filter((n) => n.endsWith('.xml'))) {
|
||||
const doc = parser.parseFromString(strFromU8(files[name]), 'application/xml');
|
||||
expect(doc.getElementsByTagName('parsererror').length, `parse error in ${name}`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits headings, alignment, spacing, indent and line spacing', () => {
|
||||
expect(docXml).toContain('w:pStyle w:val="Heading1"');
|
||||
expect(docXml).toContain('w:jc w:val="center"');
|
||||
expect(docXml).toContain('w:line="360"'); // 1.5 × 240
|
||||
expect(docXml).toMatch(/w:ind[^>]*w:left="567"/); // 1cm
|
||||
});
|
||||
|
||||
it('emits run formatting: bold, color, highlight shading, hyperlink', () => {
|
||||
expect(docXml).toContain('<w:b/>');
|
||||
expect(docXml).toContain('w:color w:val="FF0000"');
|
||||
expect(docXml).toMatch(/w:shd[^>]*w:fill="00FF00"/); // highlight
|
||||
expect(docXml).toContain('<w:hyperlink');
|
||||
});
|
||||
|
||||
it('emits tabs and hard breaks as elements', () => {
|
||||
expect(docXml).toContain('<w:tab/>');
|
||||
expect(docXml).toContain('<w:br/>');
|
||||
});
|
||||
|
||||
it('emits numbered/bulleted paragraphs and a numbering definition', () => {
|
||||
expect(docXml).toContain('<w:numPr>');
|
||||
expect(docXml).toContain('<w:numId');
|
||||
const numXml = strFromU8(files['word/numbering.xml']);
|
||||
expect(numXml).toContain('w:val="bullet"');
|
||||
expect(numXml).toContain('w:val="lowerLetter"');
|
||||
});
|
||||
|
||||
it('emits a fixed-layout table with merged cells and shading', () => {
|
||||
expect(docXml).toContain('<w:tbl>');
|
||||
expect(docXml).toContain('<w:tblGrid>');
|
||||
expect(docXml).toContain('w:vMerge'); // rowspan
|
||||
expect(docXml).toMatch(/w:shd[^>]*w:fill="F2F2F2"/); // header shade
|
||||
expect(docXml).toMatch(/w:shd[^>]*w:fill="FFFF00"/); // custom cell fill
|
||||
expect(docXml).toMatch(/w:trHeight[^>]*w:val="600"/); // 40px → 600 twips
|
||||
});
|
||||
|
||||
it('bakes bold onto header-row cells', () => {
|
||||
// The header cells carry no bold mark; bold must be baked into their runs.
|
||||
const tbl = docXml.slice(docXml.indexOf('<w:tbl>'));
|
||||
expect(tbl).toContain('<w:b/>');
|
||||
});
|
||||
|
||||
it('embeds the image as a floating (wrapped) drawing', () => {
|
||||
expect(docXml).toContain('<w:drawing>');
|
||||
expect(docXml).toContain('wp:anchor'); // floating, not inline
|
||||
});
|
||||
|
||||
it('writes page geometry with header/footer distances', () => {
|
||||
expect(docXml).toMatch(/<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/);
|
||||
expect(docXml).toMatch(/<w:pgMar[^>]*w:header="/);
|
||||
});
|
||||
|
||||
it('puts page-number / page-count fields in the footer', () => {
|
||||
const footerName = names.find((n) => /^word\/footer\d*\.xml$/.test(n))!;
|
||||
const footerXml = strFromU8(files[footerName]);
|
||||
expect(footerXml).toContain('PAGE');
|
||||
expect(footerXml).toContain('NUMPAGES');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user