diff --git a/.gitignore b/.gitignore index fda31a4..d845c42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist debug .DS_Store tests/render-parity/fixtures +docs/others diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index a427b9d..c34c330 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -139,6 +139,14 @@ export const Image = Node.create({ parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset-y')), renderHTML: () => ({}), }, + // The page a page-anchored frame is placed on (ODF text:anchor-page-number): a + // cover graphic or a watermark, out of the text flow. wrapOffset/-Y are then the + // frame's coordinates from that page's top-left corner, not from its column. + anchorPage: { + default: null, + parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-anchor-page')), + renderHTML: () => ({}), + }, // Which end of its band a `topBottom` frame is set against (Word's positionH align, // ODF's style:horizontal-pos). Set only where two frames share the band, so they // sit side by side; a lone one fills the band, which is what the wrap means. @@ -173,6 +181,7 @@ export const Image = Node.create({ ...(offset != null ? { 'data-wrap-offset': String(offset) } : {}), ...(offsetY != null ? { 'data-wrap-offset-y': String(offsetY) } : {}), ...(node.attrs.wrapAlign ? { 'data-wrap-align': String(node.attrs.wrapAlign) } : {}), + ...(node.attrs.anchorPage ? { 'data-anchor-page': String(node.attrs.anchorPage) } : {}), })]; }, @@ -312,7 +321,16 @@ class ImageView { d.style.display = ''; d.style.clear = ''; d.style.margin = ''; + d.style.position = ''; + d.style.zIndex = ''; + d.style.top = ''; + d.style.left = ''; const a = this.node.attrs; + if (typeof a.anchorPage === 'number' && a.anchorPage > 0) { + this.applyPageAnchor(a.anchorPage); + return; + } + delete d.dataset.anchorPage; if (wrap === 'left' || wrap === 'right') { d.style.float = wrap; d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth()); @@ -340,6 +358,19 @@ 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. + 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.left = `${px(this.node.attrs.wrapOffset)}px`; + d.style.top = `calc(${page - 1} * var(--page-cycle) + ${px(this.node.attrs.wrapOffsetY)}px)`; + } + // The offset counts from the anchor paragraph's top. Where text precedes the frame // (the importers sink one behind the text — a full-width float pushes every following // line under itself) the lines already cover part of it, so the rest is measured. diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 9ecb3a2..8366358 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -375,7 +375,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; wrapAlign: 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; wrapAlign: string | null; anchorPage: number | null }; function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); @@ -419,6 +419,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'): wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null, wrapOffsetYCm: typeof node.attrs?.wrapOffsetY === 'number' ? round3(node.attrs.wrapOffsetY) : null, 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, }; } @@ -2768,6 +2769,14 @@ function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | // Graphic style for a floating frame (wrap + side, anchored to the paragraph top). // Inline images need none. Injected into content.xml automatic-styles by applyImages. function imageGraphicStyle(img: ImageExport, index: number): string { + if (img.anchorPage) { + return ( + `` + + `` + ); + } if (img.wrap === 'inline') return ''; return ( `` + @@ -2788,12 +2797,15 @@ function imageFrameXml(img: ImageExport, index: number): string { (img.heightCm ? ` svg:height="${img.heightCm}cm"` : ''); const title = img.alt ? `${escapeXml(img.alt)}` : ''; const inner = `${title}`; - const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph'; - const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`; - const x = img.wrapOffsetCm != null && img.wrap !== 'inline' && !img.wrapAlign ? ` svg:x="${img.wrapOffsetCm}cm"` : ''; - const y = img.wrapOffsetYCm != null && img.wrap !== 'inline' ? ` svg:y="${img.wrapOffsetYCm}cm"` : ''; + const floats = img.anchorPage != null || img.wrap !== 'inline'; + const anchor = img.anchorPage != null + ? ` text:anchor-type="page" text:anchor-page-number="${img.anchorPage}"` + : ` text:anchor-type="${floats ? 'paragraph' : 'as-char'}"`; + const styleName = floats ? ` draw:style-name="ImgFr${index + 1}"` : ''; + const x = img.wrapOffsetCm != null && floats && !img.wrapAlign ? ` svg:x="${img.wrapOffsetCm}cm"` : ''; + const y = img.wrapOffsetYCm != null && floats ? ` svg:y="${img.wrapOffsetYCm}cm"` : ''; return ( - `` + + `` + `${inner}` ); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index c34c49b..8dc87f2 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -164,6 +164,15 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, const y = gp['style:vertical-pos'] === 'from-top' && (!rel || rel.startsWith('paragraph') || rel === 'line') ? lengthToCm(el.getAttributeNS(NS.svg, 'y')) : null; if (y != null && y > 0 && attrs.wrap) attrs.wrapOffsetY = Math.round(y * 100) / 100; + // A page-anchored frame is out of the text flow, placed from its page's corner: the + // cover graphic or watermark of a title page. Its offsets are that corner's, not the + // column's, so they replace whatever the wrap rules above read. + if (anchor === 'page') { + const page = Number(el.getAttributeNS(NS.text, 'anchor-page-number')); + 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); + } } // A paragraph mark that declares nothing keeps the style's font, but the runs may all @@ -756,10 +765,25 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDef } }; + // A page-anchored frame is out of the text flow: it rides a paragraph of its own, + // which collapses to nothing (editor.css). In a cell there is no page corner to place + // it from, so it stays the ordinary floating frame the wrap rules made of it. + const hoistPageFrames = (block: Node | null): Node | null => { + const frames = (block?.content ?? []).filter(n => n.type === 'image' && n.attrs?.anchorPage); + if (!block || !frames.length) return block; + if (kind !== 'body') { + for (const f of frames) delete f.attrs!.anchorPage; + return block; + } + block.content = block.content!.filter(n => !frames.includes(n)); + for (const f of frames) out.push({ type: 'paragraph', content: [f] }); + return block; + }; + for (const el of elements) { if (el.namespaceURI === NS.text) { if (el.localName === 'p' || el.localName === 'h') { - pushWithPending(convertParaLike(el, ctx, kind, boldByDefault)); + pushWithPending(hoistPageFrames(convertParaLike(el, ctx, kind, boldByDefault))); } else if (el.localName === 'list') { // A list wrapping only headings is ODF outline (chapter) numbering, not a real // list — unwrap it to plain headings instead of empty nested list levels. diff --git a/src/styles/editor.css b/src/styles/editor.css index d9e245c..738b9a0 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -161,6 +161,13 @@ margin-bottom: 0; } +/* 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). */ +.paper .tiptap p:has(> [data-anchor-page]) { + height: 0; + margin: 0; +} + /* Paragraph "colored field" + rule line (paragraphBox.ts). Background-color and per-side borders are inline styles from renderHTML; a bordered block gets a little padding so the text doesn't touch the rule (matches LibreOffice's fo:padding on such paragraphs). */