fix: the running head keeps the chapter name its field cached

A STYLEREF field's result runs are the chapter a reader shows before it
repaginates. The DOCX import dropped them and set the field's text empty, so a
header saved as .docx came back with a blank running head. ODF kept it.

Found by comparing the header and footer zones on a round trip, which nothing
did — they are documents of their own and hold what only they can. The corpus
gains two legs besides: a second save, and Word's dialect on its way out as ODT.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:11 +02:00
1 parent a66ee78ba2
commit 1efe981872
5 files changed
+44 -7

No files matched your search

+4
View File
@@ -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
`<table:table-header-rows>` 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.
+7 -5
View File
@@ -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 } : {}) });
}
}
+20
View File
@@ -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
+9
View File
@@ -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();
}
});
},
);
+4 -2
View File
@@ -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 <table:table-header-rows>
// / 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 {