From d6bd53d97117a37a5d902cd17e5231e5a5db3dca Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 08:54:53 +0000 Subject: [PATCH] fix: a header or footer the zone schema can't hold renders anyway A hyperlink in a fixture's copyright footer threw generateHTML and blanked the whole zone. Drop what the one-paragraph schema lacks, keep its text. A master handing over to a successor is also the different-first- page flag, so a title master with no zones leaves page one blank. --- src/lib/import/odt.ts | 31 +++++++++++++++++++++++++++---- src/lib/import/styleResolver.ts | 4 ++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index c5cecdc..a41568d 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -22,6 +22,9 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/ import type { HfDoc, HfSet } from '../storage/headerFooter'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; +import { getSchema } from '@tiptap/core'; +import type { Schema } from '@tiptap/pm/model'; +import { hfExtensions } from '../editor/extensions/headerFooter'; // .odt → TipTap JSON, inverting export/odt.ts. Editor-expressible content becomes its // native node/mark/attr; values matching the editor's defaults are suppressed so round @@ -597,7 +600,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx) : null; // The presence of a first-page element is the flag, even when it's empty (an empty // first-page zone deliberately blanks page 1 while the default fills later pages). - const differentFirstPage = !!(hf.headerFirst || hf.footerFirst); + const differentFirstPage = !!(hf.headerFirst || hf.footerFirst || hf.firstPageOnly); const headerEven = hf.headerLeft ? convertHfZone(hf.headerLeft, ctx) : null; const footerEven = hf.footerLeft ? convertHfZone(hf.footerLeft, ctx) : null; const differentOddEven = !!(hf.headerLeft || hf.footerLeft); @@ -645,7 +648,7 @@ function hfSetOfMasterPage(name: string, ctx: Ctx): HfSet { footer: zone(hf.footer), headerFirst: zone(hf.headerFirst), footerFirst: zone(hf.footerFirst), - differentFirstPage: !!(hf.headerFirst || hf.footerFirst), + differentFirstPage: !!(hf.headerFirst || hf.footerFirst || hf.firstPageOnly), headerEven: zone(hf.headerLeft), footerEven: zone(hf.footerLeft), differentOddEven: !!(hf.headerLeft || hf.footerLeft), @@ -687,9 +690,10 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { // runs and no inter-paragraph break, so inline stays empty. Keep such a zone only when // it carries a background/rule line (a footer that is just a colored line has no text). const box = mergeHfBox(boxMaps); - if (inline.length === 0 && Object.keys(box).length === 0) return null; + const content = fitZoneSchema(inline); + if (content.length === 0 && Object.keys(box).length === 0) return null; - const para: Node = { type: 'paragraph', content: inline }; + const para: Node = { type: 'paragraph', content }; const attrs: Record = {}; if (textAlign) attrs.textAlign = textAlign; if (stops) attrs.tabStops = stops; @@ -698,6 +702,25 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc { return { type: 'doc', content: [para] }; } +// The header/footer schema is a subset of the body's, and it is not forgiving: one node +// or mark it doesn't know (a hyperlink, a date field) makes the whole zone fail to render +// and come out blank. Drop what it can't hold, keeping the text. +let zoneSchema: Schema | null = null; +function fitZoneSchema(nodes: Node[]): Node[] { + zoneSchema ??= getSchema(hfExtensions()); + const out: Node[] = []; + for (const n of nodes) { + if (!zoneSchema.nodes[n.type]) { + const text = typeof n.attrs?.text === 'string' ? n.attrs.text : ''; + if (text) out.push({ type: 'text', text }); + continue; + } + const marks = n.marks?.filter((m) => zoneSchema!.marks[m.type]); + out.push(marks && marks.length !== n.marks?.length ? { ...n, marks } : n); + } + return out; +} + // The zone collapses several source paragraphs into one, so pick the box props that // reproduce the visible band+rule: background/side rules from the first that declares // them, but the bottom rule from the LAST paragraph (it sits under the whole band). diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 66fa57e..01c1dfe 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -541,6 +541,7 @@ export class StyleResolver { footerLeft: Element | null; headerExtraCm: number; footerExtraCm: number; + firstPageOnly: boolean; } { const mp = this.masterPageEl(pageName); const layout = this.pageLayoutEl(); @@ -597,6 +598,9 @@ export class StyleResolver { footerLeft: zoneIn(rest, 'footer-left'), headerExtraCm: extraCm('header-style', 'margin-bottom'), footerExtraCm: extraCm('footer-style', 'margin-top'), + // Handing over to a successor is itself the "different first page" flag: a title + // master with no zones of its own leaves page one deliberately blank. + firstPageOnly: !!successor, }; }