diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 7416792..1fd8083 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -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)`; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index df1b0c9..297c705 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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; 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; 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 { 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 ( `` + - `` ); diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index e083f5b..1b53bb9 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -26,7 +26,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` 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`). diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index f1e786c..09991c6 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -202,6 +202,9 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, 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; } } diff --git a/src/styles/editor.css b/src/styles/editor.css index f617e37..553af79 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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 diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8d31349..3a3d09d 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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 = `