feat: docx export

This commit is contained in:
Steffen Becker committed 2026-06-30 02:21:10 +02:00
1 parent dc1352b9cd
commit 9700487353
6 files changed
+920 -4

No files matched your search

+194 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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>
+529
View File
@@ -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());
}
+19 -2
View File
@@ -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> {
+146
View File
@@ -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');
});
});