feat: a floating table opens as the frame that holds it
Word's w:tblpPr takes a table out of the flow; the editor kept it in, so whatever followed — and every frame anchored to it — sat as far down as the table is tall. It now arrives as the box whose content a table may be, which is the shape LibreOffice keeps such a table in, and both exporters write it back as the file's own floating one. A drawing in one of its cells is ordinary table text, so the box-in-box guard lets it stand. Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
This commit is contained in:
1 parent
2a40d4383f
commit
fd82080115
11 files changed
+233
-26
No files matched your search
@@ -4,7 +4,18 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res
|
||||
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`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `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. A picture that arrives by **URL** (an `<img>` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) 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), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. 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). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against Line truncated
|
||||
- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection. **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes `<a:noAutofit/>`, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when onLine truncated
|
||||
- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+ | table'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection. **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes `<a:noAutofit/>`, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset`Line truncated
|
||||
|
||||
## A floating table
|
||||
|
||||
A table Word takes out of the flow (`w:tblpPr` — every cover-page layout has one) is a
|
||||
**frame holding nothing but that table**, which is how LibreOffice keeps one too: the
|
||||
blocks after it then start where the table does instead of below it, and text wraps
|
||||
beside it. The box's content expression is an alternation for exactly that reason — a
|
||||
table in a box is the whole content or nothing, since neither format's writer has a
|
||||
place for one standing beside text in a frame. Both exporters write it back as the
|
||||
file's own floating table (`w:tblpPr` via the package's `float`, ODF's
|
||||
`draw:frame`/`draw:text-box` around the table), not as a shape.
|
||||
|
||||
## A box in static HTML
|
||||
|
||||
|
||||
@@ -99,8 +99,12 @@ function nestsBox(node: PMNode, inBox = false): boolean {
|
||||
node.forEach(child => {
|
||||
if (bad) return;
|
||||
const isBox = child.type.name === 'textBox';
|
||||
// A frame holding a table is a floating table, not a frame the file has to nest in:
|
||||
// a drawing in one of its cells is anchored in ordinary table text, and both
|
||||
// exporters write it as one.
|
||||
const floatingTable = isBox && child.firstChild?.type.name === 'table';
|
||||
if (isBox && inBox) bad = true;
|
||||
else if (nestsBox(child, inBox || isBox)) bad = true;
|
||||
else if (nestsBox(child, !floatingTable && (inBox || isBox))) bad = true;
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
@@ -149,7 +153,10 @@ export const TextBox = Node.create({
|
||||
// or a list item through their paragraphs. A box inside a box is barred by a plugin.
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
content: '(paragraph | heading | bulletList | orderedList)+',
|
||||
// Either blocks or one table: a frame holding nothing but a table is how both word
|
||||
// processors keep a floating table (Word's w:tblpPr), and the alternation is what
|
||||
// bars a table beside text in a box, which neither format's writer has a place for.
|
||||
content: '(paragraph | heading | bulletList | orderedList)+ | table',
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
+50
-3
@@ -7,12 +7,14 @@ import {
|
||||
AlignmentType, LevelFormat, LevelSuffix, UnderlineType, BorderStyle, ShadingType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, LineNumberRestartFormat, TableLayoutType, SectionType, NumberFormat,
|
||||
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
|
||||
TableAnchorType, RelativeHorizontalPosition,
|
||||
TextWrappingType, TextWrappingSide, TabStopType, LeaderType,
|
||||
} from 'docx';
|
||||
import type { TiptapNode } from 'odf-kit';
|
||||
import type {
|
||||
IRunStylePropertiesOptions, ISpacingProperties, IIndentAttributesProperties,
|
||||
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions, ICharacterStyleOptions,
|
||||
ITableFloatOptions,
|
||||
} from 'docx';
|
||||
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||||
import { isSvgDataUrl, svgToPngDataUrl } from '../import/imageFormats';
|
||||
@@ -2367,14 +2369,14 @@ function cellBlocksToDocx(content: TiptapNode[] = [], force: TextProps, num: Num
|
||||
} else if (child.type === 'table') {
|
||||
out.push(tableToDocx(child, contentWidthCm, num));
|
||||
} else if (child.type === 'paragraph' || child.type === 'heading') {
|
||||
out.push(paragraphToDocx(child, { force, inCell: true }));
|
||||
out.push(paragraphToDocx(emitFloatingTables(child, contentWidthCm, num, out), { force, inCell: true }));
|
||||
}
|
||||
}
|
||||
if (out.length === 0) out.push(new Paragraph({}));
|
||||
return out;
|
||||
}
|
||||
|
||||
function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): Table {
|
||||
function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering, float?: ITableFloatOptions): Table {
|
||||
const rows = (node.content ?? []).filter((r) => r.type === 'tableRow');
|
||||
// The named table style, if the registry still knows it: Word gets the reference
|
||||
// (w:tblStyle) plus the baked cell formatting, since w:tblStylePr isn't emitted.
|
||||
@@ -2442,6 +2444,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
|
||||
});
|
||||
|
||||
return new Table({
|
||||
...(float ? { float } : {}),
|
||||
...(tableStyle ? {
|
||||
style: docxStyleId(tableStyle.name),
|
||||
// Word's Table Style Options are w:tblLook (its band flags are inverted).
|
||||
@@ -2520,12 +2523,56 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe
|
||||
}));
|
||||
}
|
||||
|
||||
// A text box whose whole content is one table — the editor's floating table.
|
||||
function floatingTableOf(node: TiptapNode): TiptapNode | null {
|
||||
if (node.type !== 'textBox' || node.content?.length !== 1) return null;
|
||||
return node.content[0].type === 'table' ? node.content[0] : null;
|
||||
}
|
||||
|
||||
function frameWidthCm(box: TiptapNode, fallbackCm: number): number {
|
||||
const w = box.attrs?.width;
|
||||
return typeof w === 'number' && w > 0 ? (w * 2.54) / 96 : fallbackCm;
|
||||
}
|
||||
|
||||
// Each of a paragraph's floating-table frames becomes a table of its own ahead of it,
|
||||
// and the paragraph comes back without them.
|
||||
function emitFloatingTables(
|
||||
node: TiptapNode, contentWidthCm: number, num: Numbering, out: (Paragraph | Table | TableOfContents)[],
|
||||
): TiptapNode {
|
||||
const floats = (node.content ?? []).filter(c => floatingTableOf(c));
|
||||
if (!floats.length) return node;
|
||||
for (const box of floats) {
|
||||
out.push(tableToDocx(floatingTableOf(box)!, frameWidthCm(box, contentWidthCm), num, tableFloatOptions(box)));
|
||||
}
|
||||
return { ...node, content: (node.content ?? []).filter(c => !floats.includes(c)) };
|
||||
}
|
||||
|
||||
// The frame's place as w:tblpPr: the x either an offset or the side it takes, the y
|
||||
// counted from the page or from the text it rides, and the wrap gap on both sides.
|
||||
function tableFloatOptions(box: TiptapNode): ITableFloatOptions {
|
||||
const a = box.attrs ?? {};
|
||||
const dist = typeof a.wrapDist === 'number' ? cmToTwip(a.wrapDist) : 0;
|
||||
const x = typeof a.wrapOffset === 'number' ? a.wrapOffset : null;
|
||||
return {
|
||||
horizontalAnchor: TableAnchorType.MARGIN,
|
||||
...(x != null
|
||||
? { absoluteHorizontalPosition: cmToTwip(x) }
|
||||
: { relativeHorizontalPosition: a.wrap === 'right' ? RelativeHorizontalPosition.RIGHT : RelativeHorizontalPosition.LEFT }),
|
||||
verticalAnchor: a.wrapFromPage === true ? TableAnchorType.PAGE : TableAnchorType.TEXT,
|
||||
absoluteVerticalPosition: cmToTwip(typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : 0),
|
||||
leftFromText: dist,
|
||||
rightFromText: dist,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- top-level walk --------------------------------------------------------
|
||||
function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: number): (Paragraph | Table | TableOfContents)[] {
|
||||
const out: (Paragraph | Table | TableOfContents)[] = [];
|
||||
for (const node of content) {
|
||||
if (node.type === 'paragraph' || node.type === 'heading') {
|
||||
out.push(paragraphToDocx(node));
|
||||
// A frame holding nothing but a table is Word's floating table: a table of its own
|
||||
// ahead of the paragraph anchoring it, placed by w:tblpPr, not a shape in its run.
|
||||
out.push(paragraphToDocx(emitFloatingTables(node, contentWidthCm, num, out)));
|
||||
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
||||
listEntryToDocx(node, num, out);
|
||||
} else if (node.type === 'table') {
|
||||
|
||||
@@ -1111,7 +1111,6 @@ const de: Messages = {
|
||||
hfFlattened: 'Listen/Tabellen in Kopf- oder Fußzeilen wurden in Text umgewandelt',
|
||||
textBoxFlattened: 'In Tabellenzellen oder anderen Textfeldern verschachtelte Textfelder wurden aufgelöst',
|
||||
nestedTables: 'Verschachtelte Tabellen wurden in Absätze umgewandelt',
|
||||
floatingTable: 'Eine frei positionierte Tabelle steht jetzt im Textfluss — was ihr folgt, kann tiefer sitzen als in Word',
|
||||
comments: 'Kommentare wurden entfernt',
|
||||
missingFonts: (fonts) => `Diese Schriftarten sind nicht installiert und wurden durch Ersatzschriften dargestellt: ${fonts}`,
|
||||
},
|
||||
|
||||
@@ -1119,7 +1119,6 @@ const en = {
|
||||
hfFlattened: 'Lists/tables in headers or footers were flattened to text',
|
||||
textBoxFlattened: 'Text boxes nested in table cells or other text boxes were flattened',
|
||||
nestedTables: 'Nested tables were flattened to paragraphs',
|
||||
floatingTable: 'A floating table was placed in the text flow — what follows it may sit lower than in Word',
|
||||
comments: 'Comments were removed',
|
||||
missingFonts: (fonts: string) => `These fonts aren't installed and were shown with a substitute: ${fonts}`,
|
||||
},
|
||||
|
||||
@@ -115,12 +115,13 @@ a producer writing EMU there (360000 = 1cm in EMU, 635cm read as twips) would ot
|
||||
leave columns of no width. Under that width LibreOffice lays the declared gap out literally
|
||||
— probed to 10.16cm on a 15.24cm text — so only the unholdable value is dropped.
|
||||
|
||||
**A floating table stays in the flow, with a warning.** Word's `w:tblpPr` takes a table
|
||||
out of it — a cover page's layout table is one, and what follows starts where the table
|
||||
does — which the editor's block table cannot do. So the blocks after it (and any frame
|
||||
anchored to them) sit as far down as the table is tall; LibreOffice models the same table
|
||||
as a text frame wrapping it, which is why its own layout of such a cover differs from
|
||||
ours.
|
||||
**A floating table becomes a frame holding the table.** Word's `w:tblpPr` takes a table
|
||||
out of the flow — a cover page's layout table is one, and what follows starts where the
|
||||
table does. It arrives as the `textBox` its schema takes one table in, riding the block
|
||||
that follows it (the paragraph LibreOffice anchors its own frame to), with the side the
|
||||
`w:tblpX` half puts it on as the wrap and the table's own width as the frame's — which
|
||||
is also the width its margins are then measured against. LibreOffice keeps Word's table
|
||||
in exactly that frame and writes it back as `w:tblpPr`, so both legs round-trip it.
|
||||
|
||||
**A heading in a cell carries its own 0.** No style name reaches a cell, so the editor
|
||||
draws the level's 12pt/6pt margins (`styles/headings.ts`) there — where a Word heading
|
||||
|
||||
+56
-6
@@ -537,6 +537,8 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
// Table-of-contents field tracking (see scanTocField). A TOC node is emitted for the
|
||||
// body only; its cached paragraphs are skipped. The node view regenerates entries live.
|
||||
const tocState: TocFieldState = { fieldDepth: 0, tocDepth: -1, instr: [] };
|
||||
// Floating tables, each with the place in `out` its anchor follows (floatingTableBox).
|
||||
const floatBoxes: { box: Node; at: number }[] = [];
|
||||
|
||||
const closeTop = () => {
|
||||
const top = stack.pop()!;
|
||||
@@ -620,6 +622,8 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
breakPending = false; // a break before a table can't be modeled; drop it
|
||||
flush();
|
||||
if (kind === 'body') {
|
||||
const floated = floatingTableBox(el, ctx);
|
||||
if (floated) { floatBoxes.push({ box: floated, at: out.length }); continue; }
|
||||
const t = convertTable(el, ctx);
|
||||
if (t) out.push(t);
|
||||
} else {
|
||||
@@ -653,6 +657,14 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
}
|
||||
}
|
||||
flush();
|
||||
// A floating table's frame rides the block that follows it, the paragraph LibreOffice
|
||||
// anchors its own frame to; where no block does, it takes a paragraph of its own.
|
||||
for (let i = floatBoxes.length - 1; i >= 0; i--) {
|
||||
const { box, at } = floatBoxes[i];
|
||||
const host = out[at];
|
||||
if (host && (host.type === 'paragraph' || host.type === 'heading')) host.content = [box, ...(host.content ?? [])];
|
||||
else out.splice(at, 0, { type: 'paragraph', content: [box] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -2586,6 +2598,50 @@ function bakeCellRuns(nodes: Node[], props: RunProps): void {
|
||||
}
|
||||
}
|
||||
|
||||
// A floating table (w:tblpPr) is out of Word's flow: what follows it starts where the
|
||||
// table does. It becomes the frame holding a table both formats keep it in — the shape
|
||||
// LibreOffice reads it as and writes it back from — so the blocks after it stay put.
|
||||
function floatingTableBox(tbl: Element, ctx: Ctx): Node | null {
|
||||
const pos = fc(fc(tbl, 'tblPr'), 'tblpPr');
|
||||
if (!pos) return null;
|
||||
const widthCm = floatTableWidthCm(tbl);
|
||||
if (!widthCm) return null;
|
||||
const outerCm = ctx.contentWidthCm;
|
||||
// The table fills its frame, so what margins it declares are measured against that.
|
||||
ctx.contentWidthCm = widthCm;
|
||||
let table: Node | null;
|
||||
try { table = convertTable(tbl, ctx); } finally { ctx.contentWidthCm = outerCm; }
|
||||
if (!table) return null;
|
||||
const xCm = twipToCm(intAttr(pos, W, 'tblpX') ?? 0);
|
||||
const attrs: Record<string, unknown> = {
|
||||
width: framePx(cmToPx(widthCm)),
|
||||
// The wrap names the side the frame sits on; text flows on the open one.
|
||||
wrap: xCm + widthCm / 2 <= outerCm / 2 ? 'left' : 'right',
|
||||
// No ring of its own: the cells' own margins are the whole inset a table has.
|
||||
paddingCm: 0,
|
||||
};
|
||||
if (Math.abs(xCm) > 0.01) attrs.wrapOffset = round2(xCm);
|
||||
const yCm = twipToCm(intAttr(pos, W, 'tblpY') ?? 0);
|
||||
// w:vertAnchor names what the offset counts from: the page, or the text it rides.
|
||||
if (pos.getAttributeNS(W, 'vertAnchor') === 'page') {
|
||||
attrs.wrapFromPage = true;
|
||||
attrs.wrapOffsetY = round2(yCm);
|
||||
} else if (Math.abs(yCm) > 0.01) attrs.wrapOffsetY = round2(yCm);
|
||||
const dist = intAttr(pos, W, 'leftFromText') ?? intAttr(pos, W, 'rightFromText');
|
||||
if (dist) attrs.wrapDist = round2(twipToCm(dist));
|
||||
return { type: 'textBox', attrs, content: [table] };
|
||||
}
|
||||
|
||||
// A floating table's own width: what w:tblW declares, else the sum of its grid.
|
||||
function floatTableWidthCm(tbl: Element): number | null {
|
||||
const tblW = fc(fc(tbl, 'tblPr'), 'tblW');
|
||||
const declared = tblW && (tblW.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(tblW, W, 'w') : null;
|
||||
const grid = fc(tbl, 'tblGrid');
|
||||
const gridTwip = grid ? fcAll(grid, 'gridCol').reduce((a, g) => a + (intAttr(g, W, 'w') ?? 0), 0) : 0;
|
||||
const twip = declared && declared > 0 ? declared : gridTwip;
|
||||
return twip > 0 ? round2(twipToCm(twip)) : null;
|
||||
}
|
||||
|
||||
function convertTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// The table style's own w:pPr/w:spacing reaches its cells' paragraphs (Word's Table Grid
|
||||
// zeroes the space after and the line spacing), under their own style chain. Restored
|
||||
@@ -2746,12 +2802,6 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
rows.push(row);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
// A floating table (w:tblpPr) is out of Word's flow: what follows it starts where the
|
||||
// table does. The editor has no such table, so it stays in the flow — and a frame
|
||||
// anchored to the paragraph after it lands as far down as the table is tall.
|
||||
if (fc(fc(tbl, 'tblPr'), 'tblpPr')) {
|
||||
ctx.warnings.add('A floating table was placed in the text flow — what follows it may sit lower than in Word');
|
||||
}
|
||||
const attrs: Record<string, unknown> = { ...(tableMargins(tbl, useWeights, ctx, padBase[3]) ?? {}) };
|
||||
if (pad) attrs.cellPadding = pad;
|
||||
// w:tblHeader on the first row: Word repeats it at the top of every page the table
|
||||
|
||||
+17
-5
@@ -461,10 +461,22 @@ function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, defaultSol
|
||||
}
|
||||
}
|
||||
|
||||
// The block content of a text box / shape: its text:* children via the cell path
|
||||
// (which flattens whatever the box schema can't hold), at least one paragraph.
|
||||
function textBoxContent(children: Element[], ctx: Ctx): Node[] {
|
||||
// The block content of a text box / shape: one table where the frame holds nothing
|
||||
// else, otherwise its text:* children via the cell path (which flattens whatever the
|
||||
// box schema can't hold), at least one paragraph.
|
||||
function textBoxContent(children: Element[], ctx: Ctx, widthCm: number | null): Node[] {
|
||||
const textChildren = children.filter(c => c.namespaceURI === NS.text);
|
||||
// A frame holding nothing but a table is a floating table — how LibreOffice keeps
|
||||
// Word's own (w:tblpPr) and the one table the box's schema takes. Its margins are
|
||||
// measured against the frame it fills, not the page's text.
|
||||
const tables = children.filter(c => c.namespaceURI === NS.table && c.localName === 'table');
|
||||
if (tables.length === 1 && textChildren.every(c => !c.textContent?.trim())) {
|
||||
const outerCm = ctx.contentWidthCm;
|
||||
if (widthCm) ctx.contentWidthCm = widthCm;
|
||||
let table: Node | null;
|
||||
try { table = convertTable(tables[0], ctx); } finally { ctx.contentWidthCm = outerCm; }
|
||||
if (table) return [table];
|
||||
}
|
||||
const blocks = unnestBoxes(convertBlocks(textChildren, ctx, 'cell'), ctx);
|
||||
return blocks.length ? blocks : [{ type: 'paragraph' }];
|
||||
}
|
||||
@@ -501,7 +513,7 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
|
||||
if (padCm != null && Math.abs(padCm - TEXTBOX_PADDING_CM) > 0.01) attrs.paddingCm = Math.round(padCm * 1000) / 1000;
|
||||
shapeStyleAttrs(gp, attrs, false);
|
||||
boxTextVertical(frame, ctx, attrs);
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx, wCm) };
|
||||
}
|
||||
|
||||
// A <draw:frame> holding a formula object → a formula node. The MathML lives either
|
||||
@@ -609,7 +621,7 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
|
||||
boxWrapAlign(gp, attrs);
|
||||
shapeStyleAttrs(gp, attrs, true);
|
||||
boxTextVertical(el, ctx, attrs);
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx, wCm) };
|
||||
}
|
||||
|
||||
// A `<draw:enhanced-geometry>` no preset matches, read as its own outline: only the
|
||||
|
||||
@@ -11,6 +11,9 @@ import ListItem from '@tiptap/extension-list-item';
|
||||
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
|
||||
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
|
||||
import { TextBox } from '../../src/lib/editor/extensions/textBox';
|
||||
// A box's content takes a table (a floating table rides one), so the schema needs it.
|
||||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||||
import { AutoText } from '../../src/lib/editor/extensions/autoText';
|
||||
import { entryForShortcut, loadAutoText } from '../../src/lib/storage/autoText';
|
||||
import { setAutoTextEntries } from '../../src/lib/storage/autoText.svelte';
|
||||
@@ -81,7 +84,8 @@ describe('an entry holding a text box', () => {
|
||||
document.body.appendChild(el);
|
||||
return new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, Bold, Heading, BulletList, OrderedList, ListItem, TextBox, AutoText],
|
||||
extensions: [Document, Paragraph, Text, Bold, Heading, BulletList, OrderedList, ListItem, TextBox,
|
||||
Table, ResizableTableRow, TableHeader, TableCell, AutoText],
|
||||
content: { type: 'doc', content },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Word's floating table (w:tblpPr) is a frame holding a table — LibreOffice's own model
|
||||
// for it — so what follows the table keeps its place instead of starting below it.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
|
||||
type N = any;
|
||||
|
||||
const cell = (text: string): N => ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [180] },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] });
|
||||
|
||||
const table: N = { type: 'table', content: [
|
||||
{ type: 'tableRow', content: [cell('Report'), cell('2018')] },
|
||||
{ type: 'tableRow', content: [cell('Company'), cell('Name')] },
|
||||
] };
|
||||
|
||||
const doc: N = { type: 'doc', content: [
|
||||
{ type: 'paragraph', content: [
|
||||
{ type: 'textBox', attrs: { width: 372, wrap: 'left', wrapDist: 0.32 }, content: [table] },
|
||||
{ type: 'text', text: 'beside the frame' },
|
||||
] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
|
||||
] };
|
||||
|
||||
const find = (n: N, type: string): N => {
|
||||
if (n.type === type) return n;
|
||||
for (const c of n.content ?? []) { const hit = find(c, type); if (hit) return hit; }
|
||||
return null;
|
||||
};
|
||||
const paraText = (p: N): string => (p.content ?? []).filter((n: N) => n.type === 'text').map((n: N) => n.text).join('');
|
||||
const cellTexts = (t: N): string[] => (t.content ?? []).flatMap((r: N) =>
|
||||
(r.content ?? []).map((c: N) => find(c, 'text')?.text ?? ''));
|
||||
|
||||
describe('a floating table', () => {
|
||||
it('round-trips through ODF as a frame holding the table', async () => {
|
||||
const bytes = await buildOdt(doc);
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
expect(xml).toMatch(/<draw:text-box[^>]*>\s*<table:table/);
|
||||
const back = importOdt(bytes).content as N;
|
||||
const box = find(back, 'textBox');
|
||||
expect(box.attrs).toMatchObject({ wrap: 'left' });
|
||||
expect(cellTexts(box.content[0])).toEqual(['Report', '2018', 'Company', 'Name']);
|
||||
expect(paraText(back.content[0])).toBe('beside the frame');
|
||||
});
|
||||
|
||||
it('round-trips through DOCX as w:tblpPr', async () => {
|
||||
const bytes = await buildDocx(doc);
|
||||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
expect(xml).toMatch(/<w:tblpPr[^>]*w:horzAnchor="margin"/);
|
||||
const back = importDocx(bytes).content as N;
|
||||
const box = find(back, 'textBox');
|
||||
expect(box.attrs).toMatchObject({ wrap: 'left' });
|
||||
expect(cellTexts(box.content[0])).toEqual(['Report', '2018', 'Company', 'Name']);
|
||||
// The table is out of the flow: the text it was anchored beside stays with it.
|
||||
expect(paraText(back.content[0])).toBe('beside the frame');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,9 @@ import { BulletList } from '../../src/lib/editor/extensions/bulletList';
|
||||
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
|
||||
import ListItem from '@tiptap/extension-list-item';
|
||||
import { TextBox, TextAlignInFrames } from '../../src/lib/editor/extensions/textBox';
|
||||
// The box's content takes a table (a floating table rides one), so the schema needs it.
|
||||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||||
|
||||
type N = any;
|
||||
|
||||
@@ -35,6 +38,7 @@ function makeEditor(...content: N[]) {
|
||||
return new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, Heading, BulletList, OrderedList, ListItem, TextBox,
|
||||
Table, ResizableTableRow, TableHeader, TableCell,
|
||||
TextAlignInFrames.configure({ types: ['paragraph', 'heading'] })],
|
||||
content: { type: 'doc', content },
|
||||
});
|
||||
@@ -81,6 +85,19 @@ describe('a text box in the line', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
it('keeps a drawing in a floating table’s cell', () => {
|
||||
// The frame around a table is the file's floating table, so what its cells hold is
|
||||
// anchored in ordinary table text — not a frame inside a frame.
|
||||
const inner = box('IM FELD');
|
||||
const cell = { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [180] },
|
||||
content: [{ type: 'paragraph', content: [inner] }] };
|
||||
const floating = { type: 'textBox', attrs: { width: 200, wrap: 'left' },
|
||||
content: [{ type: 'table', content: [{ type: 'tableRow', content: [cell] }] }] };
|
||||
const ed = makeEditor({ type: 'paragraph', content: [floating] });
|
||||
expect(boxCount(ed)).toBe(2);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
it('grows a selection that reaches into the box to the whole box', () => {
|
||||
const ed = makeEditor(...doc());
|
||||
const pos = boxPos(ed);
|
||||
|
||||
Reference in new issue
Block a user