diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 6f64a69..7ae31c2 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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([]); + // 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} /> diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index cb064c6..679973d 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -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} diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 87b3615..d2d3d1a 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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 ``; `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 ``; `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 diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index d0d8964..d1fecd4 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -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({ diff --git a/src/lib/editor/extensions/pageField.ts b/src/lib/editor/extensions/pageField.ts index f7ba797..cb0f54b 100644 --- a/src/lib/editor/extensions/pageField.ts +++ b/src/lib/editor/extensions/pageField.ts @@ -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 . +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 || ' ']; + }, +}); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 42bdf9e..c0659a7 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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') { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ad22365..bebe4ac 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -158,6 +158,10 @@ const BME = '\uE014'; // (XRF{i}XRFXRF); applyBookmarks rewrites it to . U+E015. const XRF = '\uE015'; +// Sentinel wrapping a header/footer chapter field (CHPCHPCHP); +// applyHfPostProcess rewrites it to in styles.xml. U+E016. +const CHP = '\uE016'; + const EXT_BY_MIME: Record = { '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); } } +// CHPCHPCHP — 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 ; // applyCellBlocks splits on SEG and rebuilds real //. @@ -3598,6 +3610,8 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa styles = styles.replace(/]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, '')); styles = styles.split(LBR).join(''); styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '$1'); + styles = styles.replace(new RegExp(`${CHP}(\\d)${CHP}([\\s\\S]*?)${CHP}`, 'g'), + (_m, level: string, name: string) => `${name}`); 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 += ''; else if (node.type === 'pageNumber') inner += styled('1', node.marks); else if (node.type === 'pageCount') inner += styled(`${pageCount}`, node.marks); + else if (node.type === 'chapterField') inner += styled( + `${odfEncodeInline(String(node.attrs?.text ?? ''))}`, node.marks); } const parent = kind === 'header' ? 'Header' : 'Footer'; diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 8050cc7..bad1401 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -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; diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 108677e..7a1b5ac 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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 diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index e0456bb..f6e5856 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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. diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 9dba7b9..a00d8a1 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -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 { diff --git a/tests/unit/chapter-field.test.ts b/tests/unit/chapter-field.test.ts new file mode 100644 index 0000000..1a23a15 --- /dev/null +++ b/tests/unit/chapter-field.test.ts @@ -0,0 +1,44 @@ +// The running head's chapter field: ODF , 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('Cached 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); + }); +});