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(); }); });