fix: a frame placed against the page lands there
Word's positionV relativeFrom="page" and ODF's style:vertical-rel="page" — how a cover page's own blocks are placed — were dropped by both importers, so every such frame sat at its anchor paragraph instead: a title page's coloured blocks landed wherever the text happened to end. The offset now rides `wrapFromPage` on the frame, the node view resolves the page the anchor lands on from the page grid, and both exports write the relation back. A shape behind the text also goes under a picture behind it (z-index -2, the sheet moving to -3): probed against LibreOffice, which paints a cover picture over a shape whatever stacking order the file gives them. Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
This commit is contained in:
1 parent
90132fd384
commit
9f5ecd8071
8 files changed
+91
-32
No files matched your search
@@ -14,12 +14,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Behind everything the page carries — the header/footer background layer sits at
|
||||
-1, so the sheet has to be under that. */
|
||||
/* Behind everything the page carries — a shape behind the text sits at -2 and the
|
||||
header/footer background layer at -1, so the sheet has to be under both. */
|
||||
.page-sheet-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
z-index: -3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { dropCursor } from '@tiptap/pm/dropcursor';
|
||||
import { cmToPx } from '../../storage/pageMargins';
|
||||
import { readVerticalMargins } from './pageBreaks';
|
||||
import { readVerticalMargins, topInEditor } from './pageBreaks';
|
||||
|
||||
// Inline, as-character image, or a floating text-wrapped frame (wrap = flow mode);
|
||||
// width/height are doc px @96dpi, rotation CW degrees. Export → cm + ODF
|
||||
@@ -110,6 +110,17 @@ export function applyRunThrough(el: HTMLElement, offsetCm: unknown, offsetYCm: u
|
||||
el.style.zIndex = inFront ? '1' : '-1';
|
||||
}
|
||||
|
||||
// Word's positionV relativeFrom="page" / ODF's style:vertical-rel="page": the offset
|
||||
// counts from the top of the page the anchor lands on, not from the anchor itself. The
|
||||
// frame keeps the x its static position gives it and takes its y off the page grid.
|
||||
export function sinkToPageTop(view: EditorView, el: HTMLElement, offsetYCm: unknown): void {
|
||||
el.style.marginTop = '0px';
|
||||
const { grid } = readVerticalMargins(view.dom as HTMLElement);
|
||||
const top = topInEditor(view, el);
|
||||
const y = typeof offsetYCm === 'number' ? cmToPx(offsetYCm) : 0;
|
||||
el.style.marginTop = `${Math.round(grid.topOf(grid.pageAt(top)) + y - top)}px`;
|
||||
}
|
||||
|
||||
// Where an as-char frame sits against the line (ODF style:vertical-pos/-rel, probed
|
||||
// against LibreOffice): its bottom on the baseline by default, `text-*` against the
|
||||
// character area, `offset` its top wrapOffsetY cm below the baseline.
|
||||
@@ -222,6 +233,14 @@ export const Image = Node.create({
|
||||
parseHTML: el => (el as HTMLElement).getAttribute('data-v-align') || null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// Whether wrapOffsetY counts from the top of the page the anchor lands on rather
|
||||
// than from the anchor paragraph (Word's positionV relativeFrom="page", ODF's
|
||||
// style:vertical-rel="page") — how a cover page's own blocks are placed.
|
||||
wrapFromPage: {
|
||||
default: false,
|
||||
parseHTML: el => (el as HTMLElement).hasAttribute('data-wrap-from-page'),
|
||||
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: {
|
||||
@@ -261,6 +280,7 @@ export const Image = Node.create({
|
||||
...(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': '' } : {}),
|
||||
...(node.attrs.wrapFromPage ? { 'data-wrap-from-page': '' } : {}),
|
||||
})];
|
||||
},
|
||||
|
||||
@@ -475,6 +495,9 @@ class ImageView {
|
||||
delete d.dataset.anchorPage;
|
||||
if (wrap === 'through') {
|
||||
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, a.inFront === true);
|
||||
// Deferred like sinkToOffset: the frame has to be laid out before its own page
|
||||
// can be read off the grid.
|
||||
if (a.wrapFromPage) requestAnimationFrame(() => sinkToPageTop(this.view, d, a.wrapOffsetY));
|
||||
return;
|
||||
}
|
||||
if (wrap === 'left' || wrap === 'right') {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { NodeSelection, Selection, TextSelection, Plugin } from '@tiptap/pm/stat
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, type WrapMode } from './image';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, sinkToPageTop, type WrapMode } from './image';
|
||||
import { SHAPES, shapePath, linePaths, arrowHeadPx, isShapeKind, isLineKind, type ShapeKind } from '../../utils/shapes';
|
||||
import { cmToPx } from '../../storage/pageMargins';
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface TextBoxAttrs {
|
||||
wrap: WrapMode;
|
||||
wrapOffset: number | null; // cm from the text column's left edge
|
||||
wrapOffsetY: number | null; // cm below the anchor paragraph
|
||||
wrapFromPage: boolean; // …or below the top of the page the anchor lands on
|
||||
wrapDist: number | null; // cm of gap to the text beside it
|
||||
wrapAlign: string | null; // 'center'/'right' = set against the middle/far end
|
||||
paddingCm: number; // inset ring around the text (ODF fo:padding)
|
||||
@@ -188,6 +189,12 @@ export const TextBox = Node.create({
|
||||
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset-y')),
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// Whether wrapOffsetY counts from the top of the frame's page — as on an image.
|
||||
wrapFromPage: {
|
||||
default: false,
|
||||
parseHTML: el => (el as HTMLElement).hasAttribute('data-wrap-from-page'),
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// The gap to the text beside it, in cm — as on an image.
|
||||
wrapDist: {
|
||||
default: null,
|
||||
@@ -286,6 +293,7 @@ export const TextBox = Node.create({
|
||||
style,
|
||||
...(a.rotation ? { 'data-rotation': String(a.rotation) } : {}),
|
||||
...(a.wrap !== 'inline' ? { 'data-wrap': a.wrap } : {}),
|
||||
...(a.wrapFromPage ? { 'data-wrap-from-page': '' } : {}),
|
||||
...(a.shapeKind !== 'textbox' ? { 'data-shape': a.shapeKind } : {}),
|
||||
...(a.shapePath ? { 'data-shape-path': a.shapePath } : {}),
|
||||
...(a.flipV ? { 'data-flip-v': 'true' } : {}),
|
||||
@@ -698,8 +706,13 @@ class TextBoxView {
|
||||
d.style.float = a.wrap;
|
||||
d.style.margin = frameMargins(a.wrap, a.wrapOffset, this.wrapperWidth(), null, a.wrapDist);
|
||||
} else if (a.wrap === 'through') {
|
||||
// Behind the text, which is what a shape with no run-through of its own exports as.
|
||||
// Behind the text, which is what a shape with no run-through of its own exports as
|
||||
// — and under a picture behind the text too (-1), which is the order LibreOffice
|
||||
// paints a cover page in whatever stacking order the file names.
|
||||
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, false);
|
||||
d.style.zIndex = '-2';
|
||||
// Deferred: the frame has to be laid out before its own page can be read.
|
||||
if (a.wrapFromPage) requestAnimationFrame(() => sinkToPageTop(this.editor.view, d, a.wrapOffsetY));
|
||||
} else if (a.wrap === 'topBottom') {
|
||||
// A full-width float, as on an image: text may only flow above and below it, and
|
||||
// a block box on an inline node view splits the paragraph's inline content into
|
||||
|
||||
+12
-7
@@ -853,14 +853,15 @@ function paraOffsetEmu(cm: number): number {
|
||||
|
||||
// offsetCm places the frame in the text column (Word's posOffset); without one it is
|
||||
// flush to its side. offsetYCm is how far below the anchor paragraph it sits.
|
||||
function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null, inFront?: boolean): IFloating | undefined {
|
||||
function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null, inFront?: boolean, fromPage?: boolean): IFloating | undefined {
|
||||
if (wrap === 'inline') return undefined;
|
||||
// The gap beside the frame, on both sides as Word writes it; none above or below.
|
||||
const margins = distCm ? { left: Math.round(distCm * 360000), right: Math.round(distCm * 360000) } : undefined;
|
||||
const verticalPosition = {
|
||||
relative: VerticalPositionRelativeFrom.PARAGRAPH,
|
||||
offset: paraOffsetEmu(offsetYCm ?? 0),
|
||||
};
|
||||
// A page-relative offset counts from the top of the page the anchor lands on and may
|
||||
// start above it, so it is written as it stands rather than floored at one twip.
|
||||
const verticalPosition = fromPage
|
||||
? { relative: VerticalPositionRelativeFrom.PAGE, offset: Math.round((offsetYCm ?? 0) * 360000) }
|
||||
: { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: paraOffsetEmu(offsetYCm ?? 0) };
|
||||
if (wrap === 'through') {
|
||||
// Word's in-front-of / behind-text: no wrap at all, and behindDoc names which side
|
||||
// of the text the frame lands on. It overlaps by definition, so allowOverlap holds.
|
||||
@@ -944,7 +945,7 @@ function imageRun(node: TiptapNode): ImageRun | null {
|
||||
data: decoded.bytes,
|
||||
altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined,
|
||||
transformation: { width, height, rotation: rotation || undefined },
|
||||
floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null, distCm, node.attrs?.inFront === true),
|
||||
floating: floatingFor(wrap, offsetCm, offsetYCm, node.attrs?.wrapAlign as string | null, distCm, node.attrs?.inFront === true, node.attrs?.wrapFromPage === true),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -960,6 +961,7 @@ type TextBoxDocx = {
|
||||
offsetYCm: number | null;
|
||||
distCm: number | null;
|
||||
alignH: string | null;
|
||||
fromPage: boolean;
|
||||
shapeKind: ShapeKind;
|
||||
shapePath: string | null;
|
||||
flipV: boolean;
|
||||
@@ -983,6 +985,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null,
|
||||
distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null,
|
||||
alignH: a.wrapAlign === 'center' || a.wrapAlign === 'right' || a.wrapAlign === 'left' ? a.wrapAlign : null,
|
||||
fromPage: a.wrapFromPage === true,
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null,
|
||||
flipV: a.flipV === true,
|
||||
@@ -1387,7 +1390,9 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s
|
||||
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` +
|
||||
`<wp:simplePos x="0" y="0"/>` +
|
||||
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
|
||||
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>${paraOffsetEmu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>` +
|
||||
(box.fromPage
|
||||
? `<wp:positionV relativeFrom="page"><wp:posOffset>${Math.round((box.offsetYCm ?? 0) * 360000)}</wp:posOffset></wp:positionV>`
|
||||
: `<wp:positionV relativeFrom="paragraph"><wp:posOffset>${paraOffsetEmu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>`) +
|
||||
`${extent}${wrapEl}${docPr}${graphic}</wp:anchor></w:drawing>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -429,7 +429,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/run-through).
|
||||
type WrapMode = 'inline' | 'left' | 'right' | 'topBottom' | 'through';
|
||||
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 };
|
||||
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; wrapFromPage: boolean };
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||
const bin = atob(b64);
|
||||
@@ -475,6 +475,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
|
||||
wrapDistCm: typeof node.attrs?.wrapDist === 'number' ? round3(node.attrs.wrapDist) : 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,
|
||||
wrapFromPage: node.attrs?.wrapFromPage === true,
|
||||
vAlign: typeof node.attrs?.vAlign === 'string' ? node.attrs.vAlign : null,
|
||||
inFront: node.attrs?.inFront === true,
|
||||
};
|
||||
@@ -984,6 +985,7 @@ type TextBoxExport = {
|
||||
wrapOffsetYCm: number | null;
|
||||
wrapDistCm: number | null;
|
||||
wrapAlign: string | null;
|
||||
wrapFromPage: boolean;
|
||||
paddingCm: number;
|
||||
shapeKind: ShapeKind;
|
||||
shapePath: string | null;
|
||||
@@ -1008,6 +1010,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||||
wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null,
|
||||
wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null,
|
||||
wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null,
|
||||
wrapFromPage: a.wrapFromPage === true,
|
||||
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null,
|
||||
@@ -4192,7 +4195,9 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
|
||||
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
|
||||
`<style:graphic-properties style:wrap="run-through" style:run-through="${img.inFront ? 'foreground' : 'background'}"` +
|
||||
` style:horizontal-rel="paragraph-content" style:horizontal-pos="${img.wrapOffsetCm != null ? 'from-left' : 'left'}"` +
|
||||
` style:vertical-rel="paragraph" style:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}"/></style:style>`
|
||||
// Against the page the anchor lands on, which is what the file the frame came from
|
||||
// said and what places a cover block; the anchor itself stays in the flow.
|
||||
` style:vertical-rel="${img.wrapFromPage ? 'page' : 'paragraph'}" style:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}"/></style:style>`
|
||||
);
|
||||
}
|
||||
if (img.wrap === 'inline') {
|
||||
@@ -4474,7 +4479,8 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||||
? ' style:vertical-pos="top" style:vertical-rel="baseline"'
|
||||
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"` +
|
||||
` style:horizontal-rel="paragraph-content"` +
|
||||
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
|
||||
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}"` +
|
||||
` style:vertical-rel="${box.wrapFromPage ? 'page' : 'paragraph'}"`;
|
||||
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
|
||||
// false, or LibreOffice's shape autofit shrinks it to its text.
|
||||
const grow = box.shapeKind === 'textbox' && !box.shapePath
|
||||
|
||||
+17
-10
@@ -2071,10 +2071,11 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||||
|
||||
if (anchor) {
|
||||
const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
|
||||
const { wrap, offsetCm, offsetYCm, fromPage, alignH, distCm } = anchorWrap(anchor, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
|
||||
if (fromPage) attrs.wrapFromPage = true;
|
||||
if (distCm != null) attrs.wrapDist = distCm;
|
||||
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
|
||||
if (wrap === 'through') attrs.inFront = anchor.getAttribute('behindDoc') !== '1';
|
||||
@@ -2163,10 +2164,11 @@ function chartImage(drawing: Element, box: { w: number; h: number }, ctx: Ctx):
|
||||
function frameNode(src: string, box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
|
||||
const attrs: Record<string, unknown> = { src, width: box.w, height: box.h, alt: label };
|
||||
if (anchor) {
|
||||
const { wrap, offsetCm, offsetYCm, alignH, distCm } = anchorWrap(anchor, ctx);
|
||||
const { wrap, offsetCm, offsetYCm, fromPage, alignH, distCm } = anchorWrap(anchor, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
|
||||
if (fromPage) attrs.wrapFromPage = true;
|
||||
if (distCm != null) attrs.wrapDist = distCm;
|
||||
if (alignH && wrap === 'topBottom') attrs.wrapAlign = alignH;
|
||||
if (wrap === 'through') attrs.inFront = anchor.getAttribute('behindDoc') !== '1';
|
||||
@@ -2179,16 +2181,20 @@ function frameNode(src: string, box: { w: number; h: number }, label: string, an
|
||||
// How far below its anchor paragraph the frame sits. Only the paragraph- and
|
||||
// line-relative forms have a CSS equivalent (the float's top margin); page- or
|
||||
// margin-relative ones are absolute on the sheet, which a frame in flow cannot be.
|
||||
function anchorOffsetY(anchor: Element): number | null {
|
||||
function anchorOffsetY(anchor: Element): { cm: number | null; fromPage: boolean } {
|
||||
const posV = anchor.getElementsByTagNameNS(WP, 'positionV')[0];
|
||||
const from = posV?.getAttribute('relativeFrom');
|
||||
if (from !== 'paragraph' && from !== 'line') return null;
|
||||
// A page-relative offset counts from the top of the page the anchor lands on, which
|
||||
// is where a cover page's own blocks are placed; the node view resolves the page.
|
||||
const fromPage = from === 'page';
|
||||
if (!fromPage && from !== 'paragraph' && from !== 'line') return { cm: null, fromPage: false };
|
||||
const off = parseInt(posV?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
|
||||
if (!Number.isFinite(off)) return null;
|
||||
if (!Number.isFinite(off)) return { cm: null, fromPage: false };
|
||||
// The exporter floors this offset at one twip (LO derails on 0); sub-visible
|
||||
// remainders round back to none, not to a 0 that would accrete as an attribute.
|
||||
// A page-relative one keeps its sign — a cover block may start above the page top.
|
||||
const cm = round2(off / 360000);
|
||||
return cm > 0 ? cm : null;
|
||||
return { cm: fromPage || cm > 0 ? cm : null, fromPage };
|
||||
}
|
||||
|
||||
// The frame's own x in the text column, cm from its left edge. null where the file
|
||||
@@ -2206,8 +2212,8 @@ function anchorOffsetX(anchor: Element, ctx: Ctx): number | null {
|
||||
// Wrap mode and place are independent: the mode is what the file's wrap element says,
|
||||
// the place its position offsets. Only where neither names a side does the frame's own
|
||||
// x decide which half of the column it fills (text flows on one side of a CSS float).
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom' | 'through'; offsetCm: number | null; offsetYCm: number | null; alignH: 'left' | 'right' | null; distCm: number | null } {
|
||||
const offsetYCm = anchorOffsetY(anchor);
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom' | 'through'; offsetCm: number | null; offsetYCm: number | null; fromPage: boolean; alignH: 'left' | 'right' | null; distCm: number | null } {
|
||||
const { cm: offsetYCm, fromPage } = anchorOffsetY(anchor);
|
||||
const offsetCm = anchorOffsetX(anchor, ctx);
|
||||
const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0]
|
||||
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
@@ -2221,7 +2227,7 @@ function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topB
|
||||
const emu = parseInt(anchor.getAttribute(wrap === 'right' ? 'distL' : 'distR') ?? '', 10);
|
||||
return Number.isFinite(emu) && emu > 0 ? round2(emu / 360000) : null;
|
||||
};
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom' | 'through') => ({ wrap, offsetCm, offsetYCm, alignH, distCm: distOf(wrap) });
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom' | 'through') => ({ wrap, offsetCm, offsetYCm, fromPage, alignH, distCm: distOf(wrap) });
|
||||
// wrapNone is Word's in-front-of / behind-text: the text runs through the frame, so it
|
||||
// reserves neither width nor height. behindDoc picks the side of the text it lands on.
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapNone')[0]) return at('through');
|
||||
@@ -2292,10 +2298,11 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
|
||||
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||||
if (isAnchor) {
|
||||
const { wrap, offsetCm, offsetYCm, distCm } = anchorWrap(root, ctx);
|
||||
const { wrap, offsetCm, offsetYCm, fromPage, distCm } = anchorWrap(root, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
|
||||
if (fromPage) attrs.wrapFromPage = true;
|
||||
if (distCm != null) attrs.wrapDist = distCm;
|
||||
// Where the box sits across its band. A box is a block, so it takes the middle and
|
||||
// the far end that an image reads as one half of a side-by-side pair.
|
||||
|
||||
@@ -289,12 +289,17 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
|
||||
}
|
||||
// Set against one end of its band rather than filling it (Word's positionH align).
|
||||
if (attrs.wrap === 'topBottom' && (hpos === 'left' || hpos === 'right')) attrs.wrapAlign = hpos;
|
||||
// Likewise down the page, but only against the anchor paragraph — a page-relative
|
||||
// frame is placed absolutely, which one in the text flow cannot be.
|
||||
// Likewise down the page — against the anchor paragraph, or against the top of the
|
||||
// page it lands on (`page`, which is how LibreOffice writes a Word cover block; the
|
||||
// node view resolves the page, so the frame stays in the flow it is anchored in).
|
||||
const rel = gp['style:vertical-rel'];
|
||||
const y = gp['style:vertical-pos'] === 'from-top' && (!rel || rel.startsWith('paragraph') || rel === 'line')
|
||||
const fromPage = rel === 'page';
|
||||
const y = gp['style:vertical-pos'] === 'from-top' && (!rel || rel.startsWith('paragraph') || rel === 'line' || fromPage)
|
||||
? lengthToCm(el.getAttributeNS(NS.svg, 'y')) : null;
|
||||
if (y != null && y > 0 && attrs.wrap) attrs.wrapOffsetY = Math.round(y * 100) / 100;
|
||||
if (y != null && attrs.wrap && (fromPage || y > 0)) {
|
||||
attrs.wrapOffsetY = Math.round(y * 100) / 100;
|
||||
if (fromPage) attrs.wrapFromPage = true;
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
overflow-x: clip;
|
||||
|
||||
/* The sheets themselves are one box per page (PageSheetLayer), since a section on its
|
||||
own paper makes them differ in size. They sit at z-index -2, under a header's page
|
||||
background (.hf-bg-layer, -1) and so under the body text. */
|
||||
own paper makes them differ in size. They sit at z-index -3, under a shape behind
|
||||
the text (-2), a header's page background (.hf-bg-layer, -1) and the body text. */
|
||||
}
|
||||
|
||||
/* While a freshly opened document is still finding its pagination (Editor.svelte).
|
||||
|
||||
Reference in new issue
Block a user