feat: the running head names the chapter the page is in

A manual's footer carries text:chapter, which we imported as the string the
producer happened to cache — so every page of a fixture claimed the last chapter
of the book. It is now a live field: the layer resolves the last heading at
or above its outline level to have started by that page. Round-trips to ODF
text:chapter and Word STYLEREF; PDF keeps the cached name (Chrome's print
engine has no named strings).
This commit is contained in:
Steffen Becker committed 2026-08-10 13:04:25 +00:00
1 parent a8dcd9b897
commit 3153c2f7cc
12 files changed
+181 -12

No files matched your search

+26 -2
View File
@@ -23,8 +23,8 @@
import { type Orientation } from '../storage/pageOrientation';
import { type SpacingModel } from '../storage/spacingModel';
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, pageOfElement, readVerticalMargins, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
@@ -59,6 +59,28 @@
// Page each section after the first starts on (pageBreaks reports it); the layer
// turns it into a per-page set.
let sectionStartPages = $state<number[]>([]);
// Where each heading starts, for the running head's chapter field. Only collected
// when a zone actually shows one — it costs a layout read per heading.
let chapterStarts = $state<{ page: number; level: number; text: string }[]>([]);
let wantsChapters = $derived(hfUsesChapterField([
{ header: headerDoc, footer: footerDoc, headerFirst: headerFirstDoc, footerFirst: footerFirstDoc,
differentFirstPage, headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven },
...extraHfSections,
]));
function collectChapterStarts(): { page: number; level: number; text: string }[] {
if (!editor || editor.isDestroyed) return [];
const cycle = readVerticalMargins(editor.view.dom as HTMLElement).cycle;
const out: { page: number; level: number; text: string }[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return;
const text = node.textContent.trim();
const el = editor!.view.nodeDOM(pos) as HTMLElement | null;
if (!text || !el || el.nodeType !== 1) return;
out.push({ page: pageOfElement(editor!.view, el, cycle), level: (node.attrs.level as number) ?? 1, text });
});
return out;
}
// Apply the page margins + orientation to the :root CSS vars (visual padding,
// page dimensions, and pagination all read these). DOM-only, safe in effects.
@@ -624,6 +646,7 @@
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
tableBandsDoc = detail.tableBreakBands ?? [];
sectionStartPages = detail.sectionStartPages ?? [];
chapterStarts = wantsChapters ? collectChapterStarts() : [];
// The document height changed → resize the scaled scroll footprint.
recomputeScaledSize();
updateCurrentPage();
@@ -870,6 +893,7 @@
{hfDistances}
bind:extraHfSections
{sectionStartPages}
{chapterStarts}
/>
</div>
</div>
+28 -7
View File
@@ -28,6 +28,7 @@
hfTick = $bindable(),
extraHfSections = $bindable([]),
sectionStartPages = [],
chapterStarts = [],
}: {
headerDoc: HfDoc;
footerDoc: HfDoc;
@@ -48,6 +49,7 @@
hfTick: number;
extraHfSections?: HfSet[];
sectionStartPages?: number[];
chapterStarts?: { page: number; level: number; text: string }[];
} = $props();
const PAGE_GAP = 20;
@@ -193,9 +195,24 @@
return v === 'first' ? b.footerFirst : v === 'even' ? b.footerEven : b.footer;
}
// Page, page count, the zone's HTML and the chapter map: everything the two actions
// below re-run on (the map's identity changes with each pagination pass).
type ZoneParams = [number, number, string, unknown];
// Lay out the zone's tabs: static HTML no ProseMirror plugin reaches. The advances are
// layout px, so only a content change invalidates them — not the zoom transform.
function layOutTabs(node: HTMLElement, _params: [number, number, string]) {
// The chapter a page runs under: the last heading at or above the field's outline
// level that has started by then (ODF text:chapter, Word STYLEREF).
function chapterOn(page: number, level: number): string {
let text = '';
for (const c of chapterStarts) {
if (c.page > page) break;
if (c.level <= level) text = c.text;
}
return text;
}
function layOutTabs(node: HTMLElement, _params: ZoneParams) {
const apply = () => layOutZoneTabs(node);
apply();
return { update: apply };
@@ -203,10 +220,12 @@
// Replace the placeholder text in every page-field span with the real value:
// current page number, or the total page count. Re-runs when its param changes.
function patchFields(node: HTMLElement, params: [number, number, string]) {
const apply = ([page, total]: [number, number, string]) => {
function patchFields(node: HTMLElement, params: ZoneParams) {
const apply = ([page, total]: ZoneParams) => {
for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) {
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? total : page);
const kind = el.getAttribute('data-page-field');
if (kind === 'chapter') el.textContent = chapterOn(page, Number(el.getAttribute('data-level')) || 1);
else el.textContent = String(kind === 'count' ? total : page);
}
};
apply(params);
@@ -312,7 +331,9 @@
void editingPage;
if (!liveMount) return;
for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) {
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? numPages : editingPage);
const kind = el.getAttribute('data-page-field');
if (kind === 'chapter') el.textContent = chapterOn(editingPage, Number(el.getAttribute('data-level')) || 1);
else el.textContent = String(kind === 'count' ? numPages : editingPage);
}
// Content height (unscaled by the zoom transform) drives the active zone's frame.
const tt = liveMount.querySelector('.tiptap') as HTMLElement | null;
@@ -360,8 +381,8 @@
ondblclick={() => startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html]}
use:layOutTabs={[p, numPages, html]}
use:patchFields={[p, numPages, html, chapterStarts]}
use:layOutTabs={[p, numPages, html, chapterStarts]}
>
{@html html}
</div>
+2 -2
View File
@@ -32,8 +32,8 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
- **`tableOfContents.ts`** — a generated TOC: block atom listing every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents".
- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go).
- **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the two `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value.
- **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
- **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`.
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
+3 -1
View File
@@ -16,7 +16,7 @@ import { FontWeight } from './fontWeight';
import { FontColor } from './fontColor';
import { ParagraphBox } from './paragraphBox';
import { CharacterStyle } from './characterStyle';
import { PageNumber, PageCount } from './pageField';
import { PageNumber, PageCount, ChapterField } from './pageField';
import { Image } from './image';
import { Shortcuts } from './shortcuts';
import { TabStops } from './tabStops';
@@ -69,6 +69,8 @@ export function hfExtensions(placeholder = '') {
Image,
PageNumber,
PageCount,
// The running head's chapter name, resolved per page by HeaderFooterLayer.
ChapterField,
History,
Placeholder.configure({ placeholder }),
TextAlign.configure({
+31
View File
@@ -23,3 +23,34 @@ function pageFieldNode(name: string, kind: 'number' | 'count') {
export const PageNumber = pageFieldNode('pageNumber', 'number');
export const PageCount = pageFieldNode('pageCount', 'count');
// The chapter heading in force on the page — the running head every manual has. The
// same per-page patching applies; `text` is the file's cached name, shown until the
// layer resolves the page's own. Round-trips to ODF <text:chapter>.
export const ChapterField = Node.create({
name: 'chapterField',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
level: {
default: 1,
parseHTML: (el: HTMLElement) => Number(el.getAttribute('data-level')) || 1,
},
text: {
default: '',
parseHTML: (el: HTMLElement) => el.textContent ?? '',
},
};
},
parseHTML() {
return [{ tag: 'span[data-page-field="chapter"]' }];
},
renderHTML({ node }) {
return ['span', { 'data-page-field': 'chapter', 'data-level': String(node.attrs.level) }, node.attrs.text || ' '];
},
});
+3
View File
@@ -357,6 +357,9 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
out.push(new TextRun({ children: [PageNumber.CURRENT], ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'pageCount') {
out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES], ...runPropsFromMarks(node.marks) }));
} else if (node.type === 'chapterField') {
// Word's running head: STYLEREF picks the heading of that level in force on the page.
out.push(new SimpleField(`STYLEREF "Heading ${Number(node.attrs?.level) || 1}" \\* MERGEFORMAT`, String(node.attrs?.text ?? '')));
} else if (node.type === 'dateTimeField') {
out.push(dateTimeRun(node));
} else if (node.type === 'formula') {
+16
View File
@@ -158,6 +158,10 @@ const BME = '\uE014';
// (XRF{i}XRF<text>XRF); applyBookmarks rewrites it to <text:bookmark-ref>. U+E015.
const XRF = '\uE015';
// Sentinel wrapping a header/footer chapter field (CHP<level>CHP<name>CHP);
// applyHfPostProcess rewrites it to <text:chapter> in styles.xml. U+E016.
const CHP = '\uE016';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -2533,9 +2537,17 @@ function applyHfRuns(b: HeaderFooterBuilder, para: TiptapNode, pageCount: number
else if (node.type === 'hardBreak') b.addText(LBR);
else if (node.type === 'pageNumber') b.addPageNumber(f);
else if (node.type === 'pageCount') b.addText(`${PGC}${pageCount}${PGC}`, f);
else if (node.type === 'chapterField') b.addText(chapterSentinel(node), f);
}
}
// CHP<level>CHP<cached name>CHP — the cached name is what a reader shows before it
// resolves the field itself, so it rides along like LibreOffice writes it.
function chapterSentinel(node: TiptapNode): string {
const level = Number(node.attrs?.level) || 1;
return `${CHP}${level}${CHP}${String(node.attrs?.text ?? '')}${CHP}`;
}
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in lockstep,
// return a CellBlock[] descriptor. odf-kit serializes the runs into one <text:p>;
// applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
@@ -3598,6 +3610,8 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, ''));
styles = styles.split(LBR).join('<text:line-break/>');
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
styles = styles.replace(new RegExp(`${CHP}(\\d)${CHP}([\\s\\S]*?)${CHP}`, 'g'),
(_m, level: string, name: string) => `<text:chapter text:display="name" text:outline-level="${level}">${name}</text:chapter>`);
const mintedStyles: string[] = [];
const mint = (n: string) => { mintedStyles.push(n); };
@@ -3712,6 +3726,8 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' |
else if (node.type === 'hardBreak') inner += '<text:line-break/>';
else if (node.type === 'pageNumber') inner += styled('<text:page-number text:select-page="current">1</text:page-number>', node.marks);
else if (node.type === 'pageCount') inner += styled(`<text:page-count>${pageCount}</text:page-count>`, node.marks);
else if (node.type === 'chapterField') inner += styled(
`<text:chapter text:display="name" text:outline-level="${Number(node.attrs?.level) || 1}">${odfEncodeInline(String(node.attrs?.text ?? ''))}</text:chapter>`, node.marks);
}
const parent = kind === 'header' ? 'Header' : 'Footer';
+2
View File
@@ -423,6 +423,8 @@ function hfContent(doc: HfDoc): { content: string; align: 'left' | 'center' | 'r
if (n.type === 'text' && n.text) parts.push(cssStr(n.text));
else if (n.type === 'pageNumber') parts.push('counter(page)');
else if (n.type === 'pageCount') parts.push('counter(pages)');
// Chrome's print engine has no named strings, so the chapter stays its cached name.
else if (n.type === 'chapterField') parts.push(cssStr(String(n.attrs?.text ?? '')));
else if (n.type === 'hardBreak') parts.push('"\\A0 "');
}
if (!parts.length) return null;
+6
View File
@@ -1165,6 +1165,12 @@ function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[]
const push = (type: string) => out.push(marks.length ? { type, marks } : { type });
if (/\bNUMPAGES\b/.test(instr)) push('pageCount');
else if (/\bPAGE\b/.test(instr)) push('pageNumber');
// Word's running head: STYLEREF on a heading style → the live chapter field. The
// 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 } : {}) });
}
}
// A REF/PAGEREF field instruction → a cross-reference node; its cached result text is
+10
View File
@@ -1545,6 +1545,16 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
out.push(field);
continue;
}
// The running head's chapter name; text:display="number"/"plain-number" is a
// numbering we don't track, so those keep the file's cached string.
if (hfFields && e.localName === 'chapter' && e.getAttributeNS(NS.text, 'display') === 'name') {
const level = Number(e.getAttributeNS(NS.text, 'outline-level')) || 1;
const field: Node = { type: 'chapterField', attrs: { level, text: e.textContent ?? '' } };
const marks = marksFor(props, ctx.resolver, defaults);
if (marks.length) field.marks = marks;
out.push(field);
continue;
}
// Date/time fields become live dateTimeField nodes in the body (a known
// format; falls through to the shown text otherwise). The one-paragraph
// header/footer schema has no such node, so there they stay text.
+10
View File
@@ -133,6 +133,16 @@ export function saveHfDoc(zone: HfZone, doc: HfDoc, variant: HfVariant = 'defaul
else localStorage.setItem(KEYS[zone][variant], JSON.stringify(doc));
}
// Whether any zone shows a chapter field — only then does the layer need the
// heading→page map, which costs a DOM read per heading.
export function hfUsesChapterField(sets: HfSet[]): boolean {
const inZone = (doc: HfDoc) =>
((doc?.content?.[0] as { content?: { type?: string }[] } | undefined)?.content ?? [])
.some((n) => n.type === 'chapterField');
return sets.some((s) => inZone(s.header) || inZone(s.footer) || inZone(s.headerFirst)
|| inZone(s.footerFirst) || inZone(s.headerEven) || inZone(s.footerEven));
}
// Empty = null or a single paragraph without inline content AND without a visible box
// (a footer that is just a colored rule line has no text but must still render/export).
export function hfIsEmpty(doc: HfDoc): boolean {
+44
View File
@@ -0,0 +1,44 @@
// The running head's chapter field: ODF <text:chapter>, Word STYLEREF. Both keep the
// outline level; the shown name is resolved per page by HeaderFooterLayer.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { importOdt } from '../../src/lib/import/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importDocx } from '../../src/lib/import/docx';
type N = any;
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const footer: N = { type: 'doc', content: [{ type: 'paragraph', content: [
{ type: 'chapterField', attrs: { level: 1, text: 'Cached chapter' } },
{ type: 'text', text: ' — ' },
{ type: 'pageNumber' },
] }] };
const doc: N = { type: 'doc', content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Cached chapter' }] },
] };
const hf = {
header: null, footer, headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false, sections: [], pageCount: 1,
};
const fieldOf = (d: any) => d?.content?.[0]?.content?.find((n: any) => n.type === 'chapterField');
describe('chapter field', () => {
it('round-trips through ODF as text:chapter', async () => {
const bytes = await buildOdt(doc, margins, 'portrait', hf);
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
expect(styles).toContain('<text:chapter text:display="name" text:outline-level="1">Cached chapter</text:chapter>');
const back = await importOdt(bytes);
expect(fieldOf(back.footer)).toEqual({ type: 'chapterField', attrs: { level: 1, text: 'Cached chapter' } });
});
it('round-trips through DOCX as a STYLEREF field', async () => {
const bytes = await buildDocx(doc, margins, 'portrait', hf);
const back = importDocx(bytes);
// Word caches the shown name in the field result, which the import drops — the
// level is what the live field needs.
expect(fieldOf(back.footer)?.attrs?.level).toBe(1);
});
});