From 759759a9972a4175ec9653fc32691ddb68905806 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 11:24:34 +0200 Subject: [PATCH] test: the cross-format leg compares the look, not only the words The corpus leg out of one format and into the other compared block types and text, so anything the trip dropped below that passed. It now compares every attribute each node carries, with the values a format states in its own units excluded: proportional vs absolute column widths, and a formula's inline flag, which ODF has no place for. Two the stricter leg caught: a note lost the paragraph style it names and fell to the stock 10pt, and a link stored an attribute equal to its default. --- docs/architecture/notes.md | 4 +++- src/lib/export/CLAUDE.md | 4 ++++ src/lib/export/docx.ts | 11 ++++++++++- src/lib/import/docx.ts | 2 +- tests/corpus.test.ts | 27 +++++++++++++++++++++++++-- tests/unit/link-plain.test.ts | 3 ++- 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/architecture/notes.md b/docs/architecture/notes.md index 13b8199..3434f18 100644 --- a/docs/architecture/notes.md +++ b/docs/architecture/notes.md @@ -37,7 +37,9 @@ LibreOffice's 10pt only for a note that names none. Its **indent** is the except note style declares a hanging pair (`fo:margin-left` with a negative `fo:text-indent`) and `ParaProps` has no first-line half, so applying the margin alone would move every note right by the indent. The hanging pair therefore stays the editor's — see -`tests/render-parity/FINDINGS.md`. +`tests/render-parity/FINDINGS.md`. The **DOCX** export writes that style as the note +paragraph's `w:pStyle` and defines it; only a note naming none falls to the stock +`FootnoteText`/`EndnoteText`, whose 10pt the importer suppresses again. **The sync plugin** (`appendTransaction`) keeps one note per anchor, in anchor order, and renumbers each class. It runs in two rounds by design — structure first, numbering on the diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 6fb419d..07004e7 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -67,6 +67,10 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow text alike; only a side wrap has its side dictated by the wrap itself. - **An index row's tab stop is written even with no page number running to it** — it is where the row's leader is kept, and a stop no tab reaches draws nothing. +- **A note writes the paragraph style it names** (`noteStyleName`, and `usedStyleNames` + defines it): the stock `FootnoteText` is 10pt, so a note whose file gives it the body + size would shrink. See `docs/architecture/notes.md`. + - **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. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index d527cd0..672478e 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2539,6 +2539,12 @@ function styleOf(node: TiptapNode): string { return node.type === 'heading' ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE; } +// The paragraph style a note names, if it is not the stock note look. +function noteStyleName(node: TiptapNode): string | null { + const own = node.attrs?.styleName; + return typeof own === 'string' && own ? own : null; +} + // Styles to define in the file: the built-ins (Word defines its standard styles too) // plus the user styles the document references, each with its parent chain. function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set { @@ -2553,6 +2559,7 @@ function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set { for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name); const walk = (node: TiptapNode) => { if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node)); + if (node.type === 'note') { const n = noteStyleName(node); if (n) addChain(n); } // An index's rows are generated, but the styles their levels name are the file's own. if (node.type === 'tableOfContents' && Array.isArray(node.attrs?.levelStyles)) { for (const name of node.attrs.levelStyles as unknown[]) if (typeof name === 'string') addChain(name); @@ -2768,7 +2775,9 @@ export async function buildDocx( // The tab after the marker jumps to the note style's hanging indent (the gap the // editor draws itself); the importer strips it back off. children: [new Paragraph({ - style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', + // The note keeps the paragraph style it names; without one it falls to the + // stock note style, which is the 10pt look a styleName-less note renders. + style: noteStyleName(note) ? docxStyleId(noteStyleName(note)!) : kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', children: [new TextRun({ children: [new Tab()] }), ...inlineToRuns(note.content ?? [])], })], }; diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 180657d..ec0ddc4 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1487,7 +1487,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD // style, which arrives as an ordinary run mark, so the editor must add nothing of // its own. Only our exporter's blue is an editor link — it is stripped above. const ours = hexColor(props.color)?.toUpperCase() === LINK_BLUE; - marks.push({ type: 'link', attrs: { href: linkHref, plain: !ours } }); + marks.push({ type: 'link', attrs: { href: linkHref, ...(ours ? {} : { plain: true }) } }); } return marks; }; diff --git a/tests/corpus.test.ts b/tests/corpus.test.ts index 729a929..23d4339 100644 --- a/tests/corpus.test.ts +++ b/tests/corpus.test.ts @@ -39,6 +39,29 @@ function outline(node: N, out: string[] = []): string[] { return out; } +// Every attribute a node carries, key order made stable. Dropped: note ids, minted per +// export; a table's widths, proportional in ODF and absolute in OOXML; a formula's +// `display`, which ODF has no flag for and reads back off the paragraph. +function attrs(o: Record, skip: (k: string) => boolean = () => false): string { + const keep = Object.keys(o).filter((k) => k !== 'colwidth' && k !== 'display' && !skip(k)).sort(); + return JSON.stringify(Object.fromEntries(keep.map((k) => + [k, /^(ftn|edn|footnote|endnote)\d+$/.test(String(o[k])) ? 'ID' : o[k]]))); +} + +// What outline() cannot see: the look each block carries. A round trip that keeps every +// word can still drop a header row, a box's ring or a note's own style. A run value the +// paragraph already states is redundant, and each importer is free to suppress it. +function look(node: N, out: string[] = [], d = 0, from: Record = {}): string[] { + for (const c of node.content ?? []) { + const same = (k: string) => from[k] !== undefined && from[k] === c.attrs?.[k]; + const marks = (c.marks ?? []).map((m: N) => `${m.type}${attrs(m.attrs ?? {}, (k) => from[k] === m.attrs?.[k])}`) + .filter((m: string) => !m.endsWith(':{}') && !m.endsWith('{}')).join(','); + out.push(`${' '.repeat(d)}${c.type}:${c.type === 'text' ? marks : attrs(c.attrs ?? {}, same)}`); + look(c, out, d + 1, c.type === 'text' ? from : { ...from, ...(c.attrs ?? {}) }); + } + return out; +} + function textOf(node: N): string { if (node.type === 'text') return node.text ?? ''; return (node.content ?? []).map(textOf).join(''); @@ -111,9 +134,9 @@ describe.skipIf(!files.length)('the authored corpus', () => { expect(outline(odt.content)).toEqual(outline(docx.content)); const margins = { top: 2, bottom: 2, left: 2, right: 2 }; const asOdt = await buildOdt(docx.content, margins, 'portrait', undefined, undefined, undefined, docx.styles); - expect(outline(importOdt(asOdt).content)).toEqual(outline(docx.content)); + expect(look(importOdt(asOdt).content)).toEqual(look(docx.content)); const asDocx = await buildDocx(odt.content, margins, 'portrait', undefined, undefined, undefined, odt.styles); - expect(outline(importDocx(asDocx).content)).toEqual(outline(odt.content)); + expect(look(importDocx(asDocx).content)).toEqual(look(odt.content)); }); } }); diff --git a/tests/unit/link-plain.test.ts b/tests/unit/link-plain.test.ts index a57bace..dd383b9 100644 --- a/tests/unit/link-plain.test.ts +++ b/tests/unit/link-plain.test.ts @@ -22,6 +22,7 @@ describe('an editor link round-trips as an editor link', () => { const back = importDocx(bytes).content as unknown as N; const link = texts(back)[0]?.marks?.find((m: any) => m.type === 'link'); expect(link?.attrs.href).toBe('https://example.com'); - expect(link?.attrs.plain).toBe(false); + // Absent is the attribute's default: not plain, the same as an explicit false. + expect(link?.attrs.plain).toBeFalsy(); }); });