feat: images in header/footer

This commit is contained in:
Steffen Becker committed 2026-07-18 15:50:26 +02:00
1 parent dce22ab3f4
commit da1bcd8794
9 files changed
+145 -18

No files matched your search

+2 -3
View File
@@ -823,7 +823,7 @@
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // allow re-selecting the same file
if (!file || !editor || hfActive) return;
if (!file || !editor) return; // editor = activeEditor (body or the active HF zone)
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
@@ -1348,8 +1348,7 @@
/>
<button
onclick={() => imageInput?.click()}
disabled={!!hfActive}
title={hfActive ? t().toolbarExpanded.imageNotInHf : t().toolbarExpanded.insertImage}
title={t().toolbarExpanded.insertImage}
aria-label={t().toolbarExpanded.insertImage}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+6 -2
View File
@@ -16,10 +16,11 @@ import TextAlign from '@tiptap/extension-text-align';
import { FontWeight } from './fontWeight';
import { FontColor } from './fontColor';
import { PageNumber, PageCount } from './pageField';
import { Image } from './image';
// Schema for the header/footer mini editors: exactly one paragraph of formatted
// runs, line breaks, and page fields — what odf-kit's HeaderFooterBuilder (and
// thus the ODT export) can express. Marks mirror the main editor's.
// runs, line breaks, page fields, and inline (as-character) images. Marks mirror
// the main editor's; the ODT export emits images via a draw:frame post-process.
const HfDocument = Document.extend({ content: 'paragraph' });
@@ -53,6 +54,9 @@ export function hfExtensions(placeholder = '') {
FontColor,
Highlight.configure({ multicolor: true }),
HfHardBreak,
// Inline (as-character) images only — the single-paragraph zone has no text flow
// for floating/wrapped frames; imports and inserts force wrap:'inline'.
Image,
PageNumber,
PageCount,
History,
+80 -5
View File
@@ -95,6 +95,11 @@ const COL = '';
// rewrites it to <text:date>/<text:time> + a minted number style. U+E00A.
const DTF = '';
// Sentinel wrapping a header/footer image's index (HFIMG{i}HFIMG), emitted as plain
// run text by replaceHfImages so it rides odf-kit's header/footer path (styles.xml);
// applyHfPostProcess rewrites it to an as-char <draw:frame>. U+E00B.
const HFIMG = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -225,7 +230,7 @@ function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
// Decode an `image` node's data-URI src into bytes + geometry. width/height are
// px @96dpi → cm via round3 (sub-pixel, matches table column widths), so an
// integer-px image round-trips exactly. Returns null for a non-data/empty src.
function imageDescriptor(node: TiptapNode, index: number): ImageExport | null {
function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'): ImageExport | null {
const src = node.attrs?.src;
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
const m = /^data:([^;,]+)(;[^,]*)?,([\s\S]*)$/.exec(src);
@@ -246,7 +251,7 @@ function imageDescriptor(node: TiptapNode, index: number): ImageExport | null {
const wrapAttr = node.attrs?.wrap;
const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline';
return {
path: `Pictures/image${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`,
path: `Pictures/${namePrefix}${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`,
bytes,
mimeType,
widthCm: w > 0 ? pxToCm(w) : 0,
@@ -277,6 +282,26 @@ function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
return { ...node, content };
}
// A header/footer paragraph's inline images → HFIMG-sentinel text runs (forced
// as-character), collected into `images` under a distinct Pictures/hfImage* name so
// applyHfRuns/hfFirstZoneXml carry the sentinel and applyHfPostProcess resolves it.
function replaceHfImages(para: TiptapNode | null, images: ImageExport[]): TiptapNode | null {
if (!para?.content?.length) return para;
const content: TiptapNode[] = [];
for (const child of para.content) {
if (child.type === 'image') {
const desc = imageDescriptor(child, images.length, 'hfImage');
if (desc) {
images.push(desc);
content.push({ type: 'text', text: `${HFIMG}${images.length - 1}${HFIMG}` });
}
continue; // invalid image → dropped
}
content.push(child);
}
return { ...para, content };
}
// One inserted date/time field, collected by replaceDateTimeFields and emitted by
// applyDateTimeFields. `value` is the field's stored moment (ISO local datetime).
type DateTimeFieldExport = { kind: 'date' | 'time'; format: string; fixed: boolean; value: string };
@@ -1717,6 +1742,19 @@ function imageFrameXml(img: ImageExport, index: number): string {
);
}
// As-character <draw:frame> for a header/footer image (always inline; no wrap style or
// rotation). Distinct draw:name so it never collides with a body image's frame.
function hfImageFrameXml(img: ImageExport, index: number): string {
const dims =
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
return (
`<draw:frame draw:name="HfImage${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}>` +
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
);
}
// Inject automatic styles, tolerating an empty/self-closed or absent section.
function injectAutomaticStyles(content: string, styles: string): string {
if (!styles) return content;
@@ -2064,6 +2102,13 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const differentFirstPage = !!hf?.differentFirstPage;
let firstHeaderPara = differentFirstPage && hf && !hfIsEmpty(hf.headerFirst ?? null) ? (hf.headerFirst!.content![0] as TiptapNode) : null;
let firstFooterPara = differentFirstPage && hf && !hfIsEmpty(hf.footerFirst ?? null) ? (hf.footerFirst!.content![0] as TiptapNode) : null;
// Hoist header/footer inline images out to HFIMG sentinels before odf-kit serializes
// the zones; applyHfPostProcess rewrites them to <draw:frame> in styles.xml.
const hfImages: ImageExport[] = [];
headerPara = replaceHfImages(headerPara, hfImages);
footerPara = replaceHfImages(footerPara, hfImages);
firstHeaderPara = replaceHfImages(firstHeaderPara, hfImages);
firstFooterPara = replaceHfImages(firstFooterPara, hfImages);
// With the flag on, page 1 is independent: whenever a side has a zone on either
// variant, emit both (an empty one blanks its side, matching the editor and Word).
if (differentFirstPage) {
@@ -2203,7 +2248,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withStyles = rewriteStylesXml(withEmptyFontSizes, language ?? null, pageFormat, orientation);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages);
}
function hfAlign(para: TiptapNode): AlignValue | null {
@@ -2214,8 +2259,8 @@ function hfAlign(para: TiptapNode): AlignValue | null {
// Header/footer post-processing on styles.xml: resolve LBR/PGC sentinels, apply the
// paragraph alignment to the Header/Footer styles, and rewrite the geometry to the
// Word-style mapping (page margin = HF distance, min-height fills up to the body margin).
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1): Uint8Array {
if (!headerPara && !footerPara) return odtBytes;
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1, hfImages: ImageExport[] = []): Uint8Array {
if (!headerPara && !footerPara && !firstHeaderPara && !firstFooterPara) return odtBytes;
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
@@ -2276,10 +2321,40 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
}
}
// Resolve HFIMG sentinels (default + first-page zones) to as-char <draw:frame>s, then
// add the picture binaries and their manifest entries (mirrors the body applyImages).
if (hfImages.length) {
styles = ensureDrawNamespaces(styles);
styles = styles.replace(new RegExp(`${HFIMG}(\\d+)${HFIMG}`, 'g'), (_m, idx: string) => {
const img = hfImages[Number(idx)];
return img ? hfImageFrameXml(img, Number(idx)) : '';
});
for (const img of hfImages) files[img.path] = img.bytes;
const manifestBytes = files['META-INF/manifest.xml'];
if (manifestBytes) {
const entries = hfImages
.map((img) => `<manifest:file-entry manifest:full-path="${img.path}" manifest:media-type="${img.mimeType}"/>`)
.join('');
files['META-INF/manifest.xml'] = strToU8(strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`));
}
}
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
}
// styles.xml needs draw/svg/xlink namespaces for an image frame; odf-kit may not declare
// all three (svg is used by page geometry, but draw/xlink can be absent).
function ensureDrawNamespaces(styles: string): string {
const ns: [string, string][] = [
['draw', 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0'],
['svg', 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0'],
['xlink', 'http://www.w3.org/1999/xlink'],
];
const missing = ns.filter(([p]) => !styles.includes(`xmlns:${p}=`)).map(([p, uri]) => `xmlns:${p}="${uri}"`);
return missing.length ? styles.replace(/<office:document-styles\b/, `<office:document-styles ${missing.join(' ')}`) : styles;
}
// Serialize a first-page header/footer paragraph to <style:header-first>/<style:footer-first>
// XML. Runs and page fields become <text:span> referencing minted automatic text styles
// (pushed via `mint`), so both keep the run's font; hardBreak → line-break.
-1
View File
@@ -126,7 +126,6 @@ const de: Messages = {
decrease: (label) => `${label} verkleinern`,
formattingMarks: 'Formatierungszeichen',
insertImage: 'Bild einfügen',
imageNotInHf: 'Bilder sind in Kopf-/Fußzeilen nicht verfügbar',
insertTextBox: 'Textfeld einfügen',
textBoxNotInHf: 'Textfelder sind in Kopf-/Fußzeilen nicht verfügbar',
columns: 'Spalten',
-1
View File
@@ -129,7 +129,6 @@ const en = {
decrease: (label: string) => `Decrease ${label.toLowerCase()}`,
formattingMarks: 'Formatting marks',
insertImage: 'Insert image',
imageNotInHf: 'Images are not available in headers/footers',
insertTextBox: 'Insert text box',
textBoxNotInHf: 'Text boxes are not available in headers/footers',
columns: 'Columns',
+7 -3
View File
@@ -627,11 +627,15 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime);
// Route a drawing/pict result: an image is inline; a text box is a block node
// riding ctx.pendingBlocks. The one-paragraph header/footer schema holds neither,
// so drawings there are dropped with a warning.
// riding ctx.pendingBlocks. The one-paragraph header/footer schema holds inline
// images (forced as-character) but not boxes — those are dropped with a warning.
const pushDrawn = (n: Node | null) => {
if (!n) return;
if (hfFields) { ctx.warnings.add('Drawings were removed'); return; }
if (hfFields) {
if (n.type === 'image') { out.push({ ...n, attrs: { ...n.attrs, wrap: 'inline' } }); return; }
ctx.warnings.add('Drawings were removed');
return;
}
if (n.type !== 'textBox') { out.push(n); return; }
ctx.pendingBlocks.push(n);
};
+5 -3
View File
@@ -847,12 +847,14 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
}
}
if (e.namespaceURI === NS.draw) {
// The one-paragraph header/footer schema can't hold images or boxes.
const conv = convertDrawElement(e, ctx);
// The one-paragraph header/footer schema holds inline images (forced
// as-character) but not boxes — those are dropped with a warning.
if (hfFields) {
ctx.warnings.add('Drawings were removed');
if (conv?.inline?.type === 'image') out.push({ ...conv.inline, attrs: { ...conv.inline.attrs, wrap: 'inline' } });
else if (conv) ctx.warnings.add('Drawings were removed');
continue;
}
const conv = convertDrawElement(e, ctx);
if (conv?.inline) out.push(conv.inline);
else if (conv?.block) ctx.pendingBlocks.push(conv.block);
continue;
+18
View File
@@ -312,6 +312,24 @@ describe('DOCX different first page (w:titlePg)', () => {
expect(inline[inline.length - 1].type).toBe('hardBreak');
});
it('round-trips an inline image in the default footer and the first-page header', async () => {
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const image = (w: number, h: number): N => ({ type: 'image', attrs: { src: PNG, alt: 'Logo', width: w, height: h, wrap: 'inline' } });
const withImgs = {
...hf,
footer: { type: 'doc', content: [para([text('Logo '), image(120, 48)])] },
headerFirst: { type: 'doc', content: [para([image(200, 60)])] },
} as any;
const res = importDocx(await buildDocx(fixture, undefined, 'portrait', withImgs, { language: 'de', country: 'DE' }));
const fi = walk(res.footer, 'image');
const hi = walk(res.headerFirst, 'image');
expect(fi.length).toBe(1);
expect(hi.length).toBe(1);
expect(String(fi[0].attrs.src)).toMatch(/^data:image\//);
expect(Math.abs(fi[0].attrs.width - 120)).toBeLessThanOrEqual(2);
expect(Math.abs(fi[0].attrs.height - 48)).toBeLessThanOrEqual(2);
});
it('omits titlePg and first-page zones when the flag is off', async () => {
const bytes = await buildDocx(fixture, undefined, 'portrait', { ...hf, differentFirstPage: false }, { language: 'de', country: 'DE' });
const documentXml = strFromU8(unzipSync(bytes)['word/document.xml']);
+27
View File
@@ -814,6 +814,33 @@ describe('Leg 3a: different first page header/footer → buildOdt → importOdt'
});
});
describe('Leg 3b: inline images in header/footer → buildOdt → importOdt', () => {
it('round-trips an as-char image in a default zone and a first-page zone', async () => {
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const img = (w: number, h: number): N => ({ type: 'image', attrs: { src: PNG, alt: 'Logo', width: w, height: h, wrap: 'inline' } });
const footer: N = { type: 'doc', content: [P({ textAlign: 'center' }, T('Logo '), img(120, 48))] };
const headerFirst: N = { type: 'doc', content: [P(null, img(200, 60))] };
const header: N = { type: 'doc', content: [P(null, T('Default header'))] };
const res = importOdt(await buildOdt(fixture, margins, 'portrait',
{ header, footer, headerFirst, footerFirst: null, differentFirstPage: true, pageCount: 3 }));
const imgs = (doc: N): N[] => { const o: N[] = []; const w = (n: N) => { if (!n) return; if (n.type === 'image') o.push(n); (n.content ?? []).forEach(w); }; w(doc); return o; };
check('hf image: no warnings', res.warnings.length === 0, res.warnings);
const fi = imgs(res.footer);
check('hf image: default footer keeps one image', fi.length === 1, res.footer);
check('hf image: src is a data-URI', /^data:image\//.test(fi[0]?.attrs?.src ?? ''), fi[0]?.attrs?.src?.slice(0, 24));
check('hf image: size preserved (px→cm→px)', Math.abs(fi[0]?.attrs?.width - 120) <= 2 && Math.abs(fi[0]?.attrs?.height - 48) <= 2, fi[0]?.attrs);
check('hf image: stays inline (as-char)', (fi[0]?.attrs?.wrap ?? 'inline') === 'inline', fi[0]?.attrs?.wrap);
check('hf image: first-page header keeps its image', imgs(res.headerFirst).length === 1, res.headerFirst);
// Both zones must remain valid in the header/footer editor schema.
const hfSchema = getSchema(hfExtensions());
let ok = true;
for (const z of [res.footer, res.headerFirst]) { if (!z) continue; try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; } }
check('hf image: zones valid in hf schema', ok);
});
});
describe('Leg 4: foreign header/footer → importOdt', () => {
it('parses page-number/count fields, reconstructs body margins, imports the first-page variant', () => {
const fStyles = `<?xml version="1.0" encoding="UTF-8"?>