diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 04bf21d..15e24a8 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -108,6 +108,10 @@ Body text with no resolved font falls back to the *document's own theme minor fo on the page layout governing the body (`HfSet.pageNumberFormat`), which is how a roman front matter precedes a decimal body; Word's is `w:pgNumType w:fmt` per section, and the document's own comes from the **first** section, as the paper and the margins already do. +- **The running head keeps the name the field cached.** A `STYLEREF` field's result runs + are the chapter a reader shows before it repaginates, so `chapterField.text` takes them + (`emitField`, both the `fldSimple` and `fldChar` forms). + - **A header row is `w:tblHeader` on the row**, which says what ODF's `` says: its cells become `tableHeader`, and the export writes the flag for a row whose cells are header cells even where the table asks for no repeat. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index ec0ddc4..2334b57 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1579,7 +1579,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD else { const mark = !hfFields && indexEntryFromInstr(fieldInstr); if (mark) out.push(mark); - else emitField(out, fieldInstr, hfFields, marks); + else emitField(out, fieldInstr, hfFields, marks, fieldResultText); } fieldMode = 'none'; fieldDateTime = null; @@ -1594,7 +1594,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD case 'delText': case 't': if (customMark !== null && (child.textContent ?? '') === customMark) { customMark = null; break; } - if ((fieldShown || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? ''; + if ((fieldShown || fieldSeq || hfFields) && fieldMode === 'result') fieldResultText += child.textContent ?? ''; else if (!skipResult()) pushText(child.textContent ?? '', marks); break; case 'ruby': { @@ -1671,7 +1671,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD } case 'fldSimple': { const instr = el.getAttributeNS(W, 'instr') ?? ''; - if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : []); break; } + if (hfFields) { const first = fcAll(el, 'r')[0]; emitField(out, instr, true, first ? runMarks(first) : [], el.textContent ?? ''); break; } const xref = crossRefFromInstr(instr); if (xref) { const first = fcAll(el, 'r')[0]; @@ -1726,7 +1726,7 @@ function formulaNode(el: Element, ctx: Ctx): Node | null { return { type: 'formula', attrs: { latex, display } }; } -function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = []): void { +function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = [], cached = ''): void { if (!hfFields) return; // The atom carries the field run's marks so its digits render in the run's font/size. const push = (type: string) => out.push(marks.length ? { type, marks } : { type }); @@ -1736,7 +1736,9 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] // level rides in the style name ("Heading 2", or a localized "Überschrift 2"). else { const m = /\bSTYLEREF\s+"?[^"\d]*(\d)/i.exec(instr); - if (m) out.push({ type: 'chapterField', attrs: { level: Number(m[1]), text: '' }, ...(marks.length ? { marks } : {}) }); + // The field's cached result is the chapter name a reader shows before it + // repaginates; this editor's own layer overwrites it on the first pass. + if (m) out.push({ type: 'chapterField', attrs: { level: Number(m[1]), text: cached }, ...(marks.length ? { marks } : {}) }); } } diff --git a/tests/corpus.test.ts b/tests/corpus.test.ts index 23d4339..d01507b 100644 --- a/tests/corpus.test.ts +++ b/tests/corpus.test.ts @@ -105,6 +105,19 @@ describe.skipIf(!files.length)('the authored corpus', () => { }); } + // A second save is the one that drifts: an importer storing as direct formatting what + // it read from a style writes that back out, and the file grows a little each pass. + for (const f of files) { + it(`${f} does not drift on a second save`, async () => { + const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + const build = f.endsWith('.odt') ? buildOdt : buildDocx; + const read = f.endsWith('.odt') ? importOdt : importDocx; + const once = read(await build(readAny(f, load(f)).content, margins, 'portrait', undefined, undefined, undefined, readAny(f, load(f)).styles)); + const twice = read(await build(once.content, margins, 'portrait', undefined, undefined, undefined, once.styles)); + expect(look(twice.content)).toEqual(look(once.content)); + }); + } + // Word re-saves of the same documents (tests/corpus/word/): Word rewrites the file // in its own dialect — rsids, proofErr, separator notes, theme docDefaults — which // neither the docx lib nor LibreOffice produce, so this is the third author here. @@ -120,6 +133,13 @@ describe.skipIf(!files.length)('the authored corpus', () => { const again = importDocx(await buildDocx(doc, margins, 'portrait')); expect(outline(again.content)).toEqual(outline(doc)); }); + + it(`word/${f} keeps its look on the way out as ODT`, async () => { + const read = importDocx(new Uint8Array(readFileSync(join(WORD, f)))); + const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + const asOdt = await buildOdt(read.content as N, margins, 'portrait', undefined, undefined, undefined, read.styles); + expect(look(importOdt(asOdt).content)).toEqual(look(read.content as N)); + }); } // The same document in both formats, and each one exported as the other: the four diff --git a/tests/kitchen-sink-roundtrip.test.ts b/tests/kitchen-sink-roundtrip.test.ts index 4160c30..a412e10 100644 --- a/tests/kitchen-sink-roundtrip.test.ts +++ b/tests/kitchen-sink-roundtrip.test.ts @@ -52,5 +52,14 @@ describe.each([['ODT', buildOdt, importOdt], ['DOCX', buildDocx, importDocx]] as expect(res.warnings ?? []).toEqual([]); expect(firstDiff(clean(doc), clean(res.content))).toBeNull(); }); + + // The zones are documents of their own, and hold what only they can: the running + // head, the page number and count. + it('brings its header and footer back as they went out', async () => { + const res = read(await (build as N)(kitchenSinkDoc() as N, ...args)) as N; + for (const zone of ['header', 'footer'] as const) { + expect(firstDiff(clean((o.hf as N)[zone]), clean(res[zone])), zone).toBeNull(); + } + }); }, ); diff --git a/tests/kitchenSink.ts b/tests/kitchenSink.ts index 5d121a8..db46fa5 100644 --- a/tests/kitchenSink.ts +++ b/tests/kitchenSink.ts @@ -37,11 +37,13 @@ function styledTable(): N { if (paint.fill) attrs.backgroundColor = paint.fill; for (const [k, v] of Object.entries(paint.borders)) if (v !== null) attrs[k] = v; if (paint.regions.length) attrs.region = paint.regions.join(' '); - return { type: 'tableCell', attrs, + // The first row is the table's heading, the shape a file's + // / w:tblHeader imports as: header cells that repeat over a page break. + return { type: ri === 0 ? 'tableHeader' : 'tableCell', attrs, content: [p([t(`Zelle ${ri + 1}.${ci + 1}`)])] }; }), })); - return { type: 'table', attrs: { tableStyle: name, tableLook: tableLookAttr(look) }, content: rows }; + return { type: 'table', attrs: { tableStyle: name, tableLook: tableLookAttr(look), repeatHeader: true }, content: rows }; } export function kitchenSinkDoc(): N {