feat: a top-and-bottom frame is drawn where the file places it
Both anchor offsets now render: the x moves the rotor inside the full-width float, the y the float's top. Chromium puts every line after such a float below it, so a frame set below the paragraph top is sunk behind the text at import and the node view measures what those lines already cover.
This commit is contained in:
1 parent
dc5cbe7225
commit
2830e8a9d4
9 files changed
+116
-22
No files matched your search
@@ -3,5 +3,5 @@
|
||||
Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, resize/rotate
|
||||
handles, wrap modes, frame offsets — and their ODF/DOCX legs.
|
||||
|
||||
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. `wrapOffsetY` (`positionV`, `svg:y`) round-trips but is **not rendered**: a line box avoids a float's whole *margin* box, so a top margin would make dead space where Word flows text (measured on the thesis fixture: 131mm off became 187mm, and the document grew a page). Browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented), so a frame's vertical place stays the anchor paragraph's. Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota.
|
||||
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota.
|
||||
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (`'textbox'|'roundRect'|'ellipse'` — ellipse renders via `border-radius:50%`), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically.
|
||||
@@ -67,10 +67,13 @@ export function fitInlineImage(attrs: Record<string, unknown>, maxWidthPx: numbe
|
||||
|
||||
// A floating frame's margins from its wrapOffset: its left edge in the text column,
|
||||
// measured against the live column vars an indented anchor cannot skew (a right float
|
||||
// is placed from the far side). wrapOffsetY is data only — see the note on the attr.
|
||||
export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number): string {
|
||||
if (wrap === 'topBottom') return '6px 0';
|
||||
// is placed from the far side). wrapOffsetY becomes a top margin — see the attr's note.
|
||||
export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: number, offsetYCm?: unknown): string {
|
||||
const near = typeof offsetCm === 'number' ? `${Math.round(cmToPx(offsetCm))}px` : null;
|
||||
if (wrap === 'topBottom') {
|
||||
const top = typeof offsetYCm === 'number' && offsetYCm > 0 ? `${Math.round(cmToPx(offsetYCm))}px` : '6px';
|
||||
return `${top} 0 6px ${near ?? '0'}`;
|
||||
}
|
||||
if (wrap === 'left') return `0 14px 6px ${near ?? '0'}`;
|
||||
const far = near == null ? '0' : `calc(${COLUMN_WIDTH_CSS} - ${near} - ${boxWidthPx}px)`;
|
||||
return `0 ${far} 6px 14px`;
|
||||
@@ -129,8 +132,8 @@ export const Image = Node.create({
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// How far below its anchor paragraph the frame sits, in cm (Word's positionV
|
||||
// posOffset, ODF svg:y). Kept for the file only: a line box avoids a float's whole
|
||||
// margin box, so rendering it as a top margin makes dead space Word fills with text.
|
||||
// posOffset, ODF svg:y). Drawn as the float's top margin for `topBottom`, where no
|
||||
// text sits beside the frame; a side float would push away lines Word keeps.
|
||||
wrapOffsetY: {
|
||||
default: null,
|
||||
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset-y')),
|
||||
@@ -250,6 +253,8 @@ class ImageView {
|
||||
|
||||
this.applyLayout(this.attrW(), this.attrH(), this.attrRot());
|
||||
this.applyWrap();
|
||||
// The frame is not in the document yet, so a sunk one has nothing to measure against.
|
||||
requestAnimationFrame(() => this.sinkToOffset());
|
||||
}
|
||||
|
||||
private showBadge(w: number, h: number): void {
|
||||
@@ -306,8 +311,29 @@ class ImageView {
|
||||
d.style.float = 'left';
|
||||
d.style.clear = 'both';
|
||||
d.style.width = '100%';
|
||||
d.style.margin = frameMargins(wrap, a.wrapOffset, this.boxWidth());
|
||||
d.style.margin = frameMargins(wrap, null, 0, a.wrapOffsetY);
|
||||
this.sinkToOffset();
|
||||
}
|
||||
// The wrapper spans the column, so the picture's own x is the rotor's place in it
|
||||
// (the rotor is centred by CSS); every other mode positions the wrapper itself.
|
||||
const x = wrap === 'topBottom' ? a.wrapOffset : null;
|
||||
this.rotor.style.left = typeof x === 'number'
|
||||
? `${Math.round(cmToPx(x)) + (parseFloat(this.rotor.style.width) || 0) / 2}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.
|
||||
private sinkToOffset(): void {
|
||||
const y = this.node.attrs.wrapOffsetY;
|
||||
const p = this.dom.parentElement;
|
||||
if (typeof y !== 'number' || y <= 0 || !p || !this.dom.previousSibling) return;
|
||||
this.dom.style.marginTop = '0px';
|
||||
const box = p.getBoundingClientRect();
|
||||
const scale = box.width / p.offsetWidth || 1;
|
||||
const above = (this.dom.getBoundingClientRect().top - box.top) / scale;
|
||||
const gap = clamp(Math.round(cmToPx(y) - above), 6, pageContentHeightPx());
|
||||
this.dom.style.marginTop = `${gap}px`;
|
||||
}
|
||||
|
||||
// Drag an image to re-anchor it live to the text position under the cursor (text
|
||||
|
||||
@@ -359,7 +359,7 @@ class TextBoxView {
|
||||
const a = this.attrs();
|
||||
d.style.float = '';
|
||||
d.style.clear = '';
|
||||
d.style.margin = frameMargins('topBottom', null, 0);
|
||||
d.style.margin = frameMargins('topBottom', a.wrap === 'topBottom' ? a.wrapOffset : null, 0);
|
||||
if (a.wrap === 'left' || a.wrap === 'right') {
|
||||
d.style.float = a.wrap;
|
||||
d.style.margin = frameMargins(a.wrap, a.wrapOffset, parseFloat(d.style.width) || 0);
|
||||
|
||||
@@ -366,7 +366,9 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
|
||||
};
|
||||
if (wrap === 'topBottom') {
|
||||
return {
|
||||
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
|
||||
horizontalPosition: offsetCm != null
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) }
|
||||
: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
|
||||
verticalPosition,
|
||||
wrap: { type: TextWrappingType.TOP_AND_BOTTOM },
|
||||
allowOverlap: false,
|
||||
|
||||
@@ -2689,10 +2689,10 @@ function imageTransform(img: ImageExport): string {
|
||||
// places the frame on that side. topBottom ⇒ no wrap, centred.
|
||||
// An offset frame is placed by coordinate instead (svg:x on the frame).
|
||||
function imageWrapProps(wrap: WrapMode, offset: number | null): string {
|
||||
const pos = offset != null && wrap !== 'topBottom' ? 'from-left' : null;
|
||||
const pos = offset != null ? 'from-left' : null;
|
||||
if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"`;
|
||||
if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"`;
|
||||
return 'style:wrap="none" style:horizontal-pos="center"';
|
||||
return `style:wrap="none" style:horizontal-pos="${pos ?? 'center'}"`;
|
||||
}
|
||||
|
||||
// Graphic style for a floating frame (wrap + side, anchored to the paragraph top).
|
||||
@@ -2720,8 +2720,7 @@ function imageFrameXml(img: ImageExport, index: number): string {
|
||||
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.wrap !== 'topBottom'
|
||||
? ` svg:x="${img.wrapOffsetCm}cm"` : '';
|
||||
const x = img.wrapOffsetCm != null && img.wrap !== 'inline' ? ` svg:x="${img.wrapOffsetCm}cm"` : '';
|
||||
const y = img.wrapOffsetYCm != null && img.wrap !== 'inline' ? ` 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)}>` +
|
||||
@@ -2945,7 +2944,7 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
|
||||
const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph';
|
||||
const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm);
|
||||
const at = box.wrap === 'inline' ? ''
|
||||
: (box.wrapOffsetCm != null && box.wrap !== 'topBottom' ? ` svg:x="${box.wrapOffsetCm}cm"` : '') +
|
||||
: (box.wrapOffsetCm != null ? ` svg:x="${box.wrapOffsetCm}cm"` : '') +
|
||||
(box.wrapOffsetYCm != null ? ` svg:y="${box.wrapOffsetYCm}cm"` : '');
|
||||
const common =
|
||||
` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` +
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval';
|
||||
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
|
||||
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { applyUniformRunFont, type OdtImportResult } from './odt';
|
||||
import { applyUniformRunFont, sinkOffsetFrames, type OdtImportResult } from './odt';
|
||||
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
|
||||
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||||
import { astToLatex } from '../math/latex';
|
||||
@@ -747,6 +747,7 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
|
||||
const ff = paragraphMarkFont(ppr, ctx, baseRun, defaults.fonts);
|
||||
if (ff) attrs.fontFamily = ff;
|
||||
applyUniformRunFont(attrs, content);
|
||||
sinkOffsetFrames(content);
|
||||
|
||||
const node: Node = { type: level ? 'heading' : 'paragraph' };
|
||||
if (level) attrs.level = level;
|
||||
@@ -1293,7 +1294,7 @@ function anchorOffsetX(anchor: Element, ctx: Ctx): number | null {
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null } {
|
||||
const offsetYCm = anchorOffsetY(anchor);
|
||||
const offsetCm = anchorOffsetX(anchor, ctx);
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm: wrap === 'topBottom' ? null : offsetCm, offsetYCm });
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm, offsetYCm });
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom');
|
||||
const wt = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]?.getAttribute('wrapText');
|
||||
if (wt === 'right') return at('left'); // text on right ⇒ image on left
|
||||
|
||||
+14
-1
@@ -146,7 +146,7 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
|
||||
// A frame placed by coordinate (style:horizontal-pos="from-left") keeps its x in the
|
||||
// column; the wrap mode alone would snap it to a side.
|
||||
const x = gp['style:horizontal-pos'] === 'from-left' ? lengthToCm(el.getAttributeNS(NS.svg, 'x')) : null;
|
||||
if (x != null && attrs.wrap && attrs.wrap !== 'topBottom') attrs.wrapOffset = Math.round(x * 100) / 100;
|
||||
if (x != null && attrs.wrap) attrs.wrapOffset = Math.round(x * 100) / 100;
|
||||
// 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.
|
||||
const rel = gp['style:vertical-rel'];
|
||||
@@ -177,6 +177,18 @@ export function applyUniformRunFont(attrs: Record<string, unknown>, content: { t
|
||||
if (attrs.fontSize == null && size) attrs.fontSize = size;
|
||||
}
|
||||
|
||||
// A top-and-bottom frame set below its paragraph's top sinks behind the paragraph's
|
||||
// text: a full-width float pushes every following line under itself, so where there is
|
||||
// text the offset can only be drawn as the lines standing above the frame.
|
||||
export function sinkOffsetFrames(content: { type: string; text?: string; attrs?: Record<string, unknown> }[]): void {
|
||||
const sinks = (n: { type: string; attrs?: Record<string, unknown> }) =>
|
||||
n.type === 'image' && n.attrs?.wrap === 'topBottom' && (n.attrs.wrapOffsetY as number) > 0;
|
||||
if (!content.some(sinks) || !content.some(n => n.type === 'text' && n.text?.trim())) return;
|
||||
const frames = content.filter(sinks);
|
||||
for (const f of frames) content.splice(content.indexOf(f), 1);
|
||||
content.push(...frames);
|
||||
}
|
||||
|
||||
// A <draw:frame><draw:image> → an image node. Size comes from the frame's svg
|
||||
// geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames
|
||||
// with a wrap become floating (wrap mode + svg:x/svg:y position).
|
||||
@@ -1016,6 +1028,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
|
||||
const markFont = resolver.fontFamilyOf(baseTextProps);
|
||||
if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont;
|
||||
applyUniformRunFont(attrs, content);
|
||||
sinkOffsetFrames(content);
|
||||
|
||||
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
|
||||
if (isHeading) attrs.level = level;
|
||||
|
||||
@@ -90,11 +90,11 @@ wrapped pictures, one table) and the thesis (a 48-page thesis with charts,
|
||||
metafile figures and a German TOC) — still report. The thesis is at 48 pages against
|
||||
LibreOffice's 48, with 62 line-level differences, most of them the justification rule above:
|
||||
|
||||
- **A frame's vertical anchor offset is kept but not drawn.** Both offsets round-trip
|
||||
now (`wrapOffset`/`wrapOffsetY`, images and text boxes alike), but only the horizontal
|
||||
one is rendered: a line box avoids a float's whole *margin* box, so applying the
|
||||
vertical one as a top margin makes dead space where Word flows text — measured, the
|
||||
thesis' figure page went from 131mm off to 187mm and the document grew a page.
|
||||
- **A hoisted text box loses the vertical offset it was anchored by.** A frame's own
|
||||
offsets are drawn now (`docs/architecture/frames.md`), but a text box anchored inside a
|
||||
paragraph is a block node here, so the importer lifts it out and it simply follows that
|
||||
paragraph. On the thesis' figure page that leaves its caption 4.7mm high — LibreOffice
|
||||
places the box by its own 19.52cm, we place it after the picture.
|
||||
- **Page geometry is document-wide.** Headers and footers are per section (each is
|
||||
editable in place), but margins, orientation and format are not — a file whose sections
|
||||
disagree on them keeps the last one's.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// A top-and-bottom frame's own place: Word's positionH/positionV posOffset, ODF's
|
||||
// svg:x/svg:y. Both offsets round-trip, and one set below its paragraph's top sinks
|
||||
// behind that paragraph's text (a full-width float pushes following lines under it).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importOdt, sinkOffsetFrames } from '../../src/lib/import/odt';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
|
||||
type N = any;
|
||||
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const IMG = (attrs: N): N => ({ type: 'image', attrs: { src: PNG, width: 200, height: 120, ...attrs } });
|
||||
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'anchor text' }, IMG({ wrap: 'topBottom', wrapOffset: 3.5, wrapOffsetY: 5.75 })],
|
||||
}],
|
||||
};
|
||||
|
||||
const frame = (d: N) => d.content[0].content.find((n: N) => n.type === 'image');
|
||||
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
|
||||
|
||||
describe('a top-and-bottom frame’s offsets', () => {
|
||||
it('round-trips both offsets through ODF', async () => {
|
||||
const img = frame((await importOdt(await buildOdt(doc, margins, 'portrait'))).content);
|
||||
expect(img.attrs.wrapOffset).toBeCloseTo(3.5, 2);
|
||||
expect(img.attrs.wrapOffsetY).toBeCloseTo(5.75, 2);
|
||||
});
|
||||
|
||||
it('round-trips both offsets through DOCX', async () => {
|
||||
const img = frame(importDocx(await buildDocx(doc, margins, 'portrait')).content);
|
||||
expect(img.attrs.wrapOffset).toBeCloseTo(3.5, 2);
|
||||
expect(img.attrs.wrapOffsetY).toBeCloseTo(5.75, 2);
|
||||
});
|
||||
|
||||
it('sinks a frame set below the paragraph top behind its text', () => {
|
||||
const content = [IMG({ wrap: 'topBottom', wrapOffsetY: 5.75 }), { type: 'text', text: 'hi' }];
|
||||
sinkOffsetFrames(content);
|
||||
expect(content.map((n: N) => n.type)).toEqual(['text', 'image']);
|
||||
});
|
||||
|
||||
it('leaves a frame at the paragraph top, and one with no text to stand behind', () => {
|
||||
const flush = [IMG({ wrap: 'topBottom' }), { type: 'text', text: 'hi' }];
|
||||
sinkOffsetFrames(flush);
|
||||
expect(flush.map((n: N) => n.type)).toEqual(['image', 'text']);
|
||||
const alone = [IMG({ wrap: 'topBottom', wrapOffsetY: 5.75 })];
|
||||
sinkOffsetFrames(alone);
|
||||
expect(alone.map((n: N) => n.type)).toEqual(['image']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user