From e12a564f52e3feadeb4d40e218d367907dd0a4b6 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 09:58:15 +0200 Subject: [PATCH] fix: a numbered heading crosses into .docx as a heading, not a list item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapter numbering rides the heading styles' w:numPr, so re-import read every chapter as a list item — which swallowed the page break in front of it, since the break is read for body paragraphs only. Headings now also write their own w:outlineLvl, so a document setting its chapters in a style of its own ("Appendix 1") keeps them headings. SVG goes out rasterized: Word reads none, and the importer makes one of every EMF too. The corpus cross-format leg now carries the file's own stylesheet, where both the numbering and a heading's style live; measured on one fixture, the leg ODT → .docx → back loses 0 of 34 headings and 0 of 7 page breaks, was 7 and 7. --- src/lib/export/CLAUDE.md | 8 ++++++ src/lib/export/docx.ts | 21 ++++++++++++++++ src/lib/import/CLAUDE.md | 8 ++++++ src/lib/import/docx.ts | 8 +++++- src/lib/import/imageFormats.ts | 32 +++++++++++++++++++++++ tests/corpus.test.ts | 30 ++++++++++++++-------- tests/docx-roundtrip.test.ts | 46 ++++++++++++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 037cc13..6be89db 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -59,6 +59,14 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex). - **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness). +- **A heading writes its own `w:outlineLvl`** beside its `w:pStyle`: the style carries the + look, the level says it is a heading at all, and a document setting its chapters in a + style of its own leaves the importer nothing else to go by. +- **SVG is rasterized on the way into a .docx** (`rasterizeSvgImages`, before the walk — + the emitters that place pictures are synchronous): Word reads no SVG, and an EMF arrives + as one too, so a vector picture is drawn to PNG at 2× its placed size. Off the main + thread there is no canvas and the picture is skipped, as it always was. + - **Factory style slots** (`FACTORY_SLOTS`, `buildStyles`): the `docx` package always writes its own Title/Heading1–6 into styles.xml, so the registry's versions ride `styles.default.title/headingN` instead of `paragraphStyles` — a second definition under the same `w:styleId` makes Word **and** LibreOffice drop the `basedOn` chain (headings lose their sans/bold). Table and numbering styles are **spliced post-pack** (`applyRawStylesDocx`): passing them as `importedStyles` makes the package's Styles merge replace its whole factory set, `w:docDefaults` (default font/size/language) included. The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 3535e6f..512803e 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -15,6 +15,7 @@ import type { ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions, ICharacterStyleOptions, } from 'docx'; import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; +import { isSvgDataUrl, svgToPngDataUrl } from '../import/imageFormats'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { SHAPES, isShapeKind, drawingMlPath, type ShapeKind } from '../utils/shapes'; import { cellFormatCode, isCellFormat } from '../utils/cellFormat'; @@ -894,6 +895,20 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | }; } +// An SVG picture — which is also what the importer makes of an EMF — has no place in a +// .docx, so it is drawn to PNG first. Where no canvas can (the test suite), the node is +// left alone and the emitters skip it, as they did before. +async function rasterizeSvgImages(node: TiptapNode): Promise { + if (node.type === 'image' && isSvgDataUrl(node.attrs?.src)) { + const w = typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? node.attrs.width : 200; + const h = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? node.attrs.height : 150; + const png = await svgToPngDataUrl(node.attrs.src as string, w, h); + return png ? { ...node, attrs: { ...node.attrs, src: png } } : node; + } + if (!node.content?.length) return node; + return { ...node, content: await Promise.all(node.content.map(rasterizeSvgImages)) }; +} + function imageRun(node: TiptapNode): ImageRun | null { const src = node.attrs?.src; if (typeof src !== 'string' || !src.startsWith('data:')) return null; @@ -2122,6 +2137,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { spacing: opts.inCell ? cellSpacingOf(attrs, node) : spacingOf(attrs), indent: Object.keys(indent).length ? indent : undefined, pageBreakBefore: attrs.breakBefore === 'page' || undefined, + // w:outlineLvl — the locale- and name-independent heading signal. A heading wearing + // a style of its own ("Appendix 1") has nothing else that says what level it is. + outlineLevel: node.type === 'heading' ? ((attrs.level as number) ?? 1) - 1 : undefined, widowControl: attrs.widowControl === false ? false : undefined, keepNext: attrs.keepNext === true || undefined, keepLines: attrs.keepLines === true || undefined, @@ -2695,6 +2713,9 @@ export async function buildDocx( spacingAtPageStart = true, ): Promise { docLangTag = localeTag(language ? language.language : 'en'); + // Before the walk: every picture the file can hold has to be a raster by then, and + // rasterizing is async where the emitters that place them are not. + docJson = await rasterizeSvgImages(docJson); exportSheet = styles; exportSpacingModel = spacingModel; docFormulas = []; diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 79c7537..08dd789 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -110,3 +110,11 @@ Body text with no resolved font falls back to the *document's own theme minor fo document's own comes from the **first** section, as the paper and the margins already do. - **Chapter numbering** — `text:outline-style` on the ODF side, the heading styles' `w:numPr` on Word's — becomes `StyleSheet.outline`; see `src/lib/styles/CLAUDE.md`. + A numbered heading is therefore **never a list item** (`paragraphNum` returns null for + anything with an outline level): read as one, every chapter came back wrapped in an + `orderedList` that swallowed the `w:pageBreakBefore` in front of it — `blockAttrs` + reads the break for `kind === 'body'` only. +- **A heading is found by `w:outlineLvl`, not only by its style's name.** A file may set + its chapters in a style of its own ("Appendix 1") whose `w:basedOn` chain reaches + Heading1; `styleOutlineLvl` walks that chain, and the exporter writes the level on the + paragraph too, so nothing rests on a style being called "Heading n". diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index c816dcc..1882f66 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -644,7 +644,13 @@ function paragraphNum(el: Element, ctx: Ctx): { numId: number; ilvl: number } | let np: { numId: number; ilvl: number } | null = null; const numPr = fc(ppr, 'numPr'); if (numPr) np = readNumPr(numPr); - if (!np) { const ps = fc(ppr, 'pStyle'); np = ctx.styles.styleNumPr(ps ? wVal(ps) : null); } + if (!np) { + // Chapter numbering rides the heading styles' own w:numPr, which is how both + // products write it — a numbered heading is a heading, never a list item. + if (headingLevelOf(ppr, ctx) != null) return null; + const ps = fc(ppr, 'pStyle'); + np = ctx.styles.styleNumPr(ps ? wVal(ps) : null); + } return np && np.numId !== 0 ? np : null; // numId 0 = "no list" } diff --git a/src/lib/import/imageFormats.ts b/src/lib/import/imageFormats.ts index f9108f8..5329e02 100644 --- a/src/lib/import/imageFormats.ts +++ b/src/lib/import/imageFormats.ts @@ -203,3 +203,35 @@ export function placeholderImage(label: string, widthPx: number, heightPx: numbe + ` text-anchor="middle" dominant-baseline="middle">${text}`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; } + +// Word reads no SVG — a vector picture goes into a .docx as pixels or not at all. The +// browser is the only renderer at hand, so the export draws it onto a canvas; off the +// main thread (the test suite) there is none and the caller keeps the vector. +const SVG_RASTER_SCALE = 2; // the picture is placed at CSS px, so raster at 2× for print + +export function isSvgDataUrl(src: unknown): src is string { + return typeof src === 'string' && src.startsWith('data:image/svg+xml'); +} + +export async function svgToPngDataUrl(src: string, widthPx: number, heightPx: number): Promise { + if (typeof document === 'undefined' || typeof Image === 'undefined') return null; + const w = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE)); + const h = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE)); + try { + const img = new Image(); + await new Promise((ok, fail) => { + img.onload = () => ok(); + img.onerror = () => fail(new Error('svg')); + img.src = src; + }); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL('image/png'); + } catch { + return null; + } +} diff --git a/tests/corpus.test.ts b/tests/corpus.test.ts index 148b38d..b7eb2e5 100644 --- a/tests/corpus.test.ts +++ b/tests/corpus.test.ts @@ -21,14 +21,19 @@ const files = existsSync(FIX) : []; const load = (f: string) => new Uint8Array(readFileSync(join(FIX, f))); -const importAny = (f: string, bytes: Uint8Array): N => - (f.endsWith('.odt') ? importOdt(bytes) : importDocx(bytes)).content; +const readAny = (f: string, bytes: Uint8Array) => + f.endsWith('.odt') ? importOdt(bytes) : importDocx(bytes); +const importAny = (f: string, bytes: Uint8Array): N => readAny(f, bytes).content; // A block's type and its text, flattened depth-first: the shape a round trip must keep. +// The heading attrs ride along: a chapter that loses its level, its own style or the +// break in front of it still says the same words, and reflows the document anyway. function outline(node: N, out: string[] = []): string[] { for (const child of node.content ?? []) { if (child.type === 'text') continue; - out.push(`${child.type}:${textOf(child).replace(/\s+/g, ' ').trim()}`); + const a = child.type === 'heading' ? child.attrs ?? {} : null; + const head = a ? `heading${a.level}${a.styleName ? `[${a.styleName}]` : ''}${a.breakBefore ? '+brk' : ''}` : child.type; + out.push(`${head}:${textOf(child).replace(/\s+/g, ' ').trim()}`); outline(child, out); } return out; @@ -76,17 +81,20 @@ describe.skipIf(!files.length)('the authored corpus', () => { } // The same document in both formats, and each one exported as the other: the four - // legs a document takes through this editor have to agree on what it says. + // legs a document takes through this editor have to agree on what it says. The file's + // **own stylesheet** rides the cross legs, as it does when the app saves — chapter + // numbering and a heading's own style live there and nowhere in the document tree, so + // an export handed builtinStyleSheet() never carries either. for (const name of files.filter((f) => f.endsWith('.docx')).map((f) => f.slice(0, -5))) { it(`${name} reads the same out of either format`, async () => { - const fromDocx = importAny(`${name}.docx`, load(`${name}.docx`)); - const fromOdt = importAny(`${name}.odt`, load(`${name}.odt`)); - expect(outline(fromOdt)).toEqual(outline(fromDocx)); + const docx = readAny(`${name}.docx`, load(`${name}.docx`)); + const odt = readAny(`${name}.odt`, load(`${name}.odt`)); + expect(outline(odt.content)).toEqual(outline(docx.content)); const margins = { top: 2, bottom: 2, left: 2, right: 2 }; - expect(outline(importOdt(await buildOdt(fromDocx, margins, 'portrait')).content)) - .toEqual(outline(fromDocx)); - expect(outline(importDocx(await buildDocx(fromOdt, margins, 'portrait')).content)) - .toEqual(outline(fromOdt)); + const asOdt = await buildOdt(docx.content, margins, 'portrait', undefined, undefined, undefined, docx.styles); + expect(outline(importOdt(asOdt).content)).toEqual(outline(docx.content)); + const asDocx = await buildDocx(odt.content, margins, 'portrait', undefined, undefined, undefined, odt.styles); + expect(outline(importDocx(asDocx).content)).toEqual(outline(odt.content)); }); } }); diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index fbf0b28..4f6086c 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -1371,3 +1371,49 @@ describe('DOCX named list styles', () => { expect(imported?.levels[0]?.indentCm).toBeCloseTo(0.73, 2); // 1134 twips = 2cm → +0.73 }); }); + +// Chapter numbering rides the heading styles (w:numPr in styles.xml), and a heading may +// wear a style of its own — both only reach the export through the document's own +// stylesheet, so a fixture built on builtinStyleSheet() never sees either. +describe('numbered headings under a document stylesheet', () => { + const outline = Array.from({ length: 10 }, (_, i) => ({ + format: '1' as const, prefix: '', suffix: '', displayLevels: i + 1, start: 1, + })); + const sheet = () => { + const s = builtinStyleSheet(); + s.outline = outline; + // A heading style the name test can't recognise: only its outline level says so. + s.paragraph['Appendix 1'] = { name: 'Appendix 1', parent: 'Heading 1', next: 'Standard', outlineLevel: 1, para: {}, text: {} }; + return s; + }; + const doc = { + type: 'doc', + content: [ + heading(1, 'Plain chapter', { breakBefore: 'page' }), + para('body'), + heading(1, 'Appendix', { styleName: 'Appendix 1', breakBefore: 'page' }), + para('more'), + ], + } as any; + + const roundTrip = async () => importDocx(await buildDocx(doc, undefined, undefined, undefined, undefined, undefined, sheet())).content as N; + + it('keeps a numbered heading a heading instead of a list item', async () => { + const back = await roundTrip(); + expect(walk(back, 'orderedList')).toHaveLength(0); + expect(walk(back, 'listItem')).toHaveLength(0); + expect(walk(back, 'heading').map((h) => h.attrs.level)).toEqual([1, 1]); + }); + + it('keeps the page break in front of a numbered heading', async () => { + const back = await roundTrip(); + expect(walk(back, 'heading').map((h) => h.attrs.breakBefore)).toEqual(['page', 'page']); + }); + + it('keeps a heading whose style is not named "Heading n"', async () => { + const back = await roundTrip(); + const named = walk(back, 'heading').find((h) => h.attrs.styleName === 'Appendix 1'); + expect(named, 'the custom heading style survives as a heading').toBeTruthy(); + expect(named!.attrs.level).toBe(1); + }); +});