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:
1 parent
a8dcd9b897
commit
3153c2f7cc
12 files changed
+181
-12
No files matched your search
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 || ' '];
|
||||
},
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user