From f62559707cc6b1cb4203012d1f03673bdd202f00 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 23:46:37 +0000 Subject: [PATCH] fix: a header/footer zone keeps its blank lines and its logo's height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A letterhead header is usually a couple of empty lines, the logo, then one more — the empty leading ones were dropped on import, and the body's content area counted every line as one text line, logo included. Page 1 of a contract form started 19mm too high. --- src/lib/components/Editor.svelte | 13 ++++++++++--- src/lib/import/docx.ts | 14 ++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b01ec18..fc89ec5 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -66,9 +66,16 @@ const HF_LINE_PX = 16 * 1.15; function hfReachPx(doc: HfDoc, distPx: number): number { if (!doc || hfIsEmpty(doc)) return 0; - const inline = ((doc.content?.[0] as { content?: { type?: string }[] } | undefined)?.content ?? []); - const lines = 1 + inline.filter((n) => n.type === 'hardBreak').length; - return distPx + lines * HF_LINE_PX; + const inline = ((doc.content?.[0] as { content?: { type?: string; attrs?: { height?: number } }[] } | undefined)?.content ?? []); + // Per line, since an as-character image (a letterhead logo) makes its own line + // as tall as it is; the others are one text line each. + let total = 0; + let image = 0; + for (const n of inline) { + if (n.type === 'hardBreak') { total += Math.max(HF_LINE_PX, image); image = 0; } + else if (n.type === 'image' && typeof n.attrs?.height === 'number') image = Math.max(image, n.attrs.height); + } + return distPx + total + Math.max(HF_LINE_PX, image); } let footerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).footer)); let headerDistPx = $derived(cmToPx((hfDistances ?? DEFAULT_HF_DISTANCES).header)); diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 3699460..9dda301 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1613,11 +1613,12 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const relsPath = path.replace(/^word\/(.*)$/, 'word/_rels/$1.rels'); const hfCtx: Ctx = { ...ctx, rels: parseRels(ctx.files[relsPath]) }; - const inline: Node[] = []; + // One line per source paragraph, joined by hard breaks below: an empty leading line + // is a line of the zone too, and the body starts under the whole of it. + const lines: Node[][] = []; let textAlign: string | null = null; const boxMaps: Record[] = []; for (const p of hfParagraphs(root)) { - if (inline.length) inline.push({ type: 'hardBreak' }); const ppr = fc(p, 'pPr'); if (textAlign === null) { const ta = (fc(ppr, 'jc') ? wVal(fc(ppr, 'jc')!) : null) ?? ''; @@ -1628,12 +1629,17 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { // The zone carries no styleName and no style CSS reaches it, so the yardstick is the // editor's own defaults — what the Header/Footer style provides has to become marks // (mirrors odt.ts convertHfZone, which passes no style name either). - inline.push(...convertInline(p, hfCtx, baseRun, blockDefaults({}, null, false), true).filter((n) => n.type !== PB_MARKER)); + lines.push(convertInline(p, hfCtx, baseRun, blockDefaults({}, null, false), true).filter((n) => n.type !== PB_MARKER)); } // An all-empty zone is dropped unless it carries a background/rule line (a footer that // is just a colored line has no text). The zone collapses to one paragraph (mergeHfBox). const box = mergeHfBox(boxMaps); - if (inline.length === 0 && Object.keys(box).length === 0) return null; + if (lines.every((l) => l.length === 0) && Object.keys(box).length === 0) return null; + const inline: Node[] = []; + lines.forEach((line, i) => { + if (i) inline.push({ type: 'hardBreak' }); + inline.push(...line); + }); const para: Node = { type: 'paragraph', content: inline }; const attrs: Record = {};