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:
1 parent
2e70e47912
commit
a350b8f59e
5 files changed
+82
-7
No files matched your search
@@ -4,3 +4,4 @@ dist
|
||||
debug
|
||||
.DS_Store
|
||||
tests/render-parity/fixtures
|
||||
docs/others
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user