fix: a page-anchored frame sits on its page instead of in the text

ODF text:anchor-type="page" is a cover graphic or a watermark: out of
flow, placed from its page's corner, behind the text. In flow, the two
full-page frames of a fixture's covers cost three pages.
This commit is contained in:
Steffen Becker committed 2026-08-10 07:38:54 +00:00
1 parent 2e70e47912
commit a350b8f59e
5 files changed
+82 -7

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ dist
debug
.DS_Store
tests/render-parity/fixtures
docs/others
+31
View File
@@ -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.
+18 -6
View File
@@ -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<ArrayBuffer>; 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<ArrayBuffer>; 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<ArrayBuffer> {
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 (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties style:wrap="run-through" style:run-through="background"` +
` style:horizontal-rel="page" style:horizontal-pos="from-left"` +
` style:vertical-rel="page" style:vertical-pos="from-top"/></style:style>`
);
}
if (img.wrap === 'inline') return '';
return (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
@@ -2788,12 +2797,15 @@ function imageFrameXml(img: ImageExport, index: number): string {
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
const inner = `<draw:image xlink:href="${img.path}"/>${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 (
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${x}${y}${imageTransform(img)}>` +
`<draw:frame draw:name="Image${index + 1}"${styleName}${anchor} draw:z-index="${index}"${dims}${x}${y}${imageTransform(img)}>` +
`${inner}</draw:frame>`
);
}
+25 -1
View File
@@ -164,6 +164,15 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
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.
+7
View File
@@ -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). */