feat: images in header/footer
This commit is contained in:
1 parent
dce22ab3f4
commit
da1bcd8794
9 files changed
+145
-18
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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"?>
|
||||
|
||||
Reference in new issue
Block a user