fix: a page-anchored cover graphic sits in front, at the page's own corner

Two compounding bugs on a title/back-page background image:
- its host paragraph picks up position:relative from the line-height
  rule, becoming the frame's containing block instead of the page and
  offsetting it by a full page margin — now forced back to static.
- the frame always painted behind text (z-index -1); a graphic that
  declares ODF's style:run-through="foreground" now paints in front,
  as LibreOffice draws it, via a new inFront attr round-tripped through
  ODF export/import.

Confirmed against LibreOffice's own render: the duplicated, ~2cm
displaced cover graphic now matches exactly.
This commit is contained in:
Steffen Becker committed 2026-08-11 23:48:52 +00:00
1 parent 64cc010f01
commit e58e8a180e
6 files changed
+55 -8

No files matched your search

+13 -4
View File
@@ -209,6 +209,13 @@ export const Image = Node.create({
parseHTML: el => (el as HTMLElement).getAttribute('data-v-align') || null,
renderHTML: () => ({}),
},
// A page-anchored frame's stacking against text (ODF style:run-through): default
// "background" sits behind; a title page's own cover graphic sets "foreground".
inFront: {
default: false,
parseHTML: el => (el as HTMLElement).hasAttribute('data-in-front'),
renderHTML: () => ({}),
},
};
},
@@ -240,6 +247,7 @@ export const Image = Node.create({
...(node.attrs.wrapAlign ? { 'data-wrap-align': String(node.attrs.wrapAlign) } : {}),
...(node.attrs.vAlign ? { 'data-v-align': String(node.attrs.vAlign) } : {}),
...(node.attrs.anchorPage ? { 'data-anchor-page': String(node.attrs.anchorPage) } : {}),
...(node.attrs.inFront ? { 'data-in-front': '' } : {}),
})];
},
@@ -467,15 +475,16 @@ class ImageView {
? `${Math.round(cmToPx(x)) + (parseFloat(this.rotor.style.width) || 0) / 2}px` : '';
}
// Placed from its page's top-left corner (--page-cycle is the page plus the gap) and
// behind the text, like the header layer's page background. Its paragraph collapses to
// nothing (editor.css), so the frame takes no flow space.
// Placed from its page's top-left corner (--page-cycle is the page plus the gap), behind
// the text like the header layer's page background — unless the file's own run-through
// says otherwise (inFront). Its paragraph collapses to nothing (editor.css), so it takes
// no flow space either way.
private applyPageAnchor(page: number): void {
const d = this.dom;
d.dataset.anchorPage = String(page);
const px = (cm: unknown) => Math.round(cmToPx(typeof cm === 'number' ? cm : 0));
d.style.position = 'absolute';
d.style.zIndex = '-1';
d.style.zIndex = this.node.attrs.inFront ? '1' : '-1';
d.style.left = `${px(this.node.attrs.wrapOffset)}px`;
d.style.top = `calc(${page - 1} * var(--page-cycle) + ${px(this.node.attrs.wrapOffsetY)}px)`;
}
+3 -2
View File
@@ -379,7 +379,7 @@ function replaceSectionBreaks(doc: TiptapNode): TiptapNode {
// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW;
// wrap floats the frame at its anchor paragraph (left/right/top-bottom).
type WrapMode = 'inline' | 'left' | 'right' | 'topBottom';
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapDistCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null };
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapDistCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null; inFront: boolean };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
@@ -426,6 +426,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
wrapAlign: node.attrs?.wrapAlign === 'left' || node.attrs?.wrapAlign === 'right' ? node.attrs.wrapAlign : null,
anchorPage: typeof node.attrs?.anchorPage === 'number' && node.attrs.anchorPage > 0 ? node.attrs.anchorPage : null,
vAlign: typeof node.attrs?.vAlign === 'string' ? node.attrs.vAlign : null,
inFront: node.attrs?.inFront === true,
};
}
@@ -2824,7 +2825,7 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
if (img.anchorPage) {
return (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties style:wrap="run-through" style:run-through="background"` +
`<style:graphic-properties style:wrap="run-through" style:run-through="${img.inFront ? 'foreground' : 'background'}"` +
` style:horizontal-rel="page" style:horizontal-pos="from-left"` +
` style:vertical-rel="page" style:vertical-pos="from-top"/></style:style>`
);
+1 -1
View File
@@ -26,7 +26,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner behind the text (`anchorPage`, `image.ts`). In a cell it falls back to an ordinary floating frame.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
+3
View File
@@ -202,6 +202,9 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
attrs.anchorPage = Number.isInteger(page) && page > 0 ? page : 1;
attrs.wrapOffset = Math.max(0, lengthToCm(el.getAttributeNS(NS.svg, 'x')) ?? 0);
attrs.wrapOffsetY = Math.max(0, lengthToCm(el.getAttributeNS(NS.svg, 'y')) ?? 0);
// "background" (LibreOffice's default for a new one) sits behind text; a cover
// page's own graphic instead declares "foreground" to sit in front of everything.
if (gp['style:run-through'] === 'foreground') attrs.inFront = true;
}
}
+5 -1
View File
@@ -175,10 +175,14 @@
}
/* A page-anchored frame is placed from its page corner and is out of the flow, so the
paragraph the importer hoists it into reserves no space (image.ts). */
paragraph the importer hoists it into reserves no space (image.ts). !important: several
other paragraph rules below set their own `position: relative` (line-height shift, the
trailing-break spacer) — any of those would make this paragraph the frame's containing
block instead of the page, offsetting it by a page margin. */
.paper .tiptap p:has(> [data-anchor-page]) {
height: 0;
margin: 0;
position: static !important;
}
/* Paragraph "colored field" + rule line (paragraphBox.ts). Background-color and per-side
+30
View File
@@ -648,6 +648,36 @@ describe('Leg 1e: table margins (dragged outer edges)', () => {
});
});
describe('Leg 1f: page-anchored frame (cover graphic) stacking', () => {
// A cover page's own graphic sits in front of text (ODF style:run-through="foreground");
// the default — no attr — is the usual behind-text watermark case.
const doc: N = { type: 'doc', content: [
P(null, { type: 'image', attrs: { src: PNG, width: 60, height: 40, anchorPage: 1, inFront: true } }),
] };
it('exports style:run-through="foreground" and re-imports inFront', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('export writes foreground run-through', xml.includes('style:run-through="foreground"'), xml.match(/style:run-through="[^"]*"/g));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const img = collectImages(res.content).find((i: N) => i.attrs?.anchorPage);
check('page and foreground stacking round-trip', img?.attrs?.anchorPage === 1 && img?.attrs?.inFront === true, img?.attrs);
});
it('defaults to background (no inFront) when the file has none', async () => {
const behind: N = { type: 'doc', content: [
P(null, { type: 'image', attrs: { src: PNG, width: 60, height: 40, anchorPage: 1 } }),
] };
const bytes = await buildOdt(behind, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('export writes background run-through', xml.includes('style:run-through="background"'), xml.match(/style:run-through="[^"]*"/g));
const img = collectImages(importOdt(bytes).content).find((i: N) => i.attrs?.anchorPage);
check('inFront stays unset', !img?.attrs?.inFront, img?.attrs);
});
});
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>