From ca3767f3d42ddc97cc0ff2c3fa597c6dd26967d2 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 17:06:49 +0000 Subject: [PATCH] feat: a cell keeps its own margins where they differ from the table's w:tcMar and an ODF cell style's own fo:padding were both dropped: the table's value (in ODF the first cell's) stood for every cell. cellPadding is now an attr on tableCell/tableHeader too, measured against the table's rather than the editor default, so only a real disagreement carries one. --cell-pad inherits, so the cell's own declaration wins for its own td and the CSS is unchanged. Checked against LibreOffice: a 1.2cm cell in a 0.3cm table puts its text at 332.2pt where the grid column starts at 297.6. --- docs/architecture/tables.md | 2 +- src/lib/editor/extensions/tableCellPadding.ts | 22 +++++++---- src/lib/export/docx.ts | 5 +++ src/lib/export/odt.ts | 12 +++--- src/lib/import/docx.ts | 37 +++++++++++-------- src/lib/import/odt.ts | 11 ++++-- tests/render-parity/README.md | 3 -- tests/unit/cell-padding.test.ts | 25 +++++++++++++ 8 files changed, 81 insertions(+), 36 deletions(-) diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index d02badb..ebc7aae 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -12,7 +12,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`tableSplit.ts`** (`TableSplit`) — Word/LibreOffice "Split Cells…": adds `splitCellInto(cols, rows)` (prosemirror-tables only ships `splitCell` = un-merge). Reads the grid via `TableMap`, expands it (duplicating the cell region's right-edge column / bottom-edge row so every other cell crossing a new grid line grows its span — the bridging Word does), partitions the region into `cols`×`rows` sub-cells (first keeps the original content), recomputes `colwidth` weights, and rebuilds the table node in one step. **Merge** uses extension-table's built-in `mergeCells` (drag/shift-click `CellSelection` via the always-on `tableEditing` plugin). Both are surfaced by `TableToolbar.svelte` (Merge button + Split button → `TableSplitDialog.svelte`). - **`tableCellBackground.ts`** (`TableCellBackground`) — adds a `backgroundColor` attr to `tableCell`/`tableHeader` (rendered as the cell's CSS background), set via extension-table's built-in `setCellAttribute` (so it applies to a whole `CellSelection` or the cursor's cell). `TableToolbar.svelte` exposes it via a reused `ColorPicker` (cell shading); round-trips to ODF `fo:background-color` (export/import below). Survives split/merge (attrs are carried through). - **`tableCellBorders.ts`** (`TableCellBorders`) — Word/LibreOffice border control: per-side attrs `borderTop/Right/Bottom/Left` on `tableCell`/`tableHeader` (`null` = the table default 0.5pt black, `'none'`, or canonical `'pt solid #RRGGBB'`; rendered as per-side inline CSS — pt→px, min 1px — plus `data-border-*` for HTML parse). `setTableBorders(preset, spec)` applies a preset (`all`/`outer`/`inner`/`innerH`/`innerV`/`top`/`bottom`/`left`/`right`; spec `null` = no border, the default 0.5pt-black spec normalizes to `null`) to the selected cell region (`selectedRect`), writing **both sides of every affected boundary** — including the facing side of neighbour cells *outside* the region — so collapsed borders never disagree between adjacent cells. Surfaced by `TableBorderPicker.svelte` (dropdown in `TableToolbar.svelte`: preset grid + line width + line color; presets apply with the picker's current pen settings). **Word-like button states**: `activeBorderPresets(state, pen)` marks a preset active when every boundary it targets renders exactly the pen border — width *and* color, where a boundary's effective border is the collapse winner of its two facing cell sides and attr `null` counts as the 0.5pt-black default — so a fresh table lights the presets up under the default pen and a thicker pen turns them off; `'none'` is active when the region is borderless. Clicking an **active** preset toggles those borders off; an inactive one applies the pen. The panel stays open on preset clicks so the states visibly update. Round-trips to ODF `fo:border-*` (export → odf-kit `CellOptions.border*`, `null` falls back to the table's default border; import via `StyleResolver.cellBorders` + `borderAttrFromOdf` — an undeclared side ⇒ `'none'`, the 0.5pt-black default ⇒ `null` with unit tolerance, non-solid styles coerced to solid) and DOCX `w:tcBorders` (import also resolves table-level `w:tblBorders` by cell position; sides nothing declares stay `null` = default). -- **`tableCellPadding.ts`** (`TableCellPadding`) — the table's **cell margins**, a global `cellPadding` attr on `table`: `[top, right, bottom, left]` in cm, `null` = the default `[0, 0.19, 0, 0.19]`, which is Word's own and what `editor.css` falls back to — so an ordinary table carries no attr. A side within `SAME_CM` (0.005cm) of the default *is* the default, or Word's 108 twips (0.1905cm) and LibreOffice's 0.191cm would tag every table. Rendered as one `--cell-pad` custom property (the attr's `renderHTML` for the static paths, `TableView` for the node view, since it bypasses `renderHTML`) that `td/th { padding: var(--cell-pad, 0 0.19cm) }` reads. **DOCX**: `w:tblPr/w:tblCellMar` direct, else the table style's (`DocxStyles.tableCellMar` walks `w:basedOn`); export writes `Table.margins`. **ODF** has no table-level cell margin — it sits on each cell's style, so import takes the first cell's `fo:padding*` for the table and export rides the value on every cell as a CSS-style TRBL shorthand that `expandCellPadding` splits per side (odf-kit only writes the `fo:padding` shorthand, and ODF allows it only one length). Getting this wrong is not cosmetic: a 0.71cm bibliography marker column lost 0.38cm to the old constant and wrapped `"[2] "` onto three lines. +- **`tableCellPadding.ts`** (`TableCellPadding`) — **cell margins**, a global `cellPadding` attr `[top, right, bottom, left]` in cm on `table` *and* on `tableCell`/`tableHeader`. `null` = inherit: a table's from the default `[0, 0.19, 0, 0.19]` (Word's own, and `editor.css`'s fallback), a cell's from its table — so `cellPaddingAttr(sides, base)` takes the baseline it is measured against, and only a real disagreement carries an attr. A side within `SAME_CM` (0.005cm) of that baseline *is* the baseline, or Word's 108 twips (0.1905cm) and LibreOffice's 0.191cm would tag every table. Rendered as one `--cell-pad` custom property (the attr's `renderHTML` for the static paths, `TableView` for the node view, since it bypasses `renderHTML`) that `td/th { padding: var(--cell-pad, 0 0.19cm) }` reads — the property inherits, so a cell's own declaration wins for its own `td`. **DOCX**: `w:tblPr/w:tblCellMar` direct, else the table style's (`DocxStyles.tableCellMar` walks `w:basedOn`), and `w:tcPr/w:tcMar` per cell (`cellMarginsCm` reads either, `w:type="nil"` being an explicit zero); export writes `Table.margins` + `TableCell.margins`. **ODF** has no table-level cell margin — it sits on each cell's style, so import takes the first cell's `fo:padding*` for the table and measures the rest against it, and export rides each cell's value on it as a CSS-style TRBL shorthand that `expandCellPadding` splits per side (odf-kit only writes the `fo:padding` shorthand, and ODF allows it only one length). Getting this wrong is not cosmetic: a 0.71cm bibliography marker column lost 0.38cm to the old constant and wrapped `"[2] "` onto three lines. - **`tableHeaderRow.ts`** (`TableHeaderRow`) — "header row" as a **styling preset** (not the structural ODF header that repeats per page, by design): `toggleHeaderRowStyle` sets/clears a **light-grey fill (`HEADER_SHADE` `#F2F2F2`)** on the first row's cells; `isHeaderStyled(state, axis)` drives the toolbar toggle's active state. **A table carrying a table style owns these two areas through its Table Style Options instead**: there `toggleHeaderStyle`/`isHeaderStyled` read and write `headerRow`/`firstColumn` in the table's `tableLook` (delegating to `setTableLook`), so the toolbar button and the gallery's checkbox are one state and can't disagree. Both also read their label from the same key (`styles.regions.*`), so they can't drift apart in wording either — the button used to say "header column" where the checkbox said "first column". The fill is the *marker* and **round-trips natively** as `fo:background-color`, so the toggle state survives reopen with no `table:table-header-rows` (⇒ Word/LibreOffice don't repeat it). **Bold is presentational, exactly like a heading**: `tableCellBackground.ts` tags a header-shaded cell with `class="cell-header"` and `editor.css` renders `.cell-header` bold — so *all* its text is bold (existing, typed, pasted, IME) without storing marks, and it stays **editable** via the `fontWeight:'normal'` override (the `Toolbar.svelte` Bold button treats `isInHeaderCell` like a heading: toggles `setFontWeight('normal')`/`unsetFontWeight`). Export (`export/odt.ts`) **bakes** bold onto header-shaded cells' runs (`applyRuns(..., forceBold)`, skipping `fontWeight:'normal'` runs) so Word/LibreOffice match; import (`import/odt.ts`) converts those cells' runs with `boldByDefault` (bold run → no mark, normal run → `fontWeight:'normal'`), mirroring headings. `TableToolbar.svelte` exposes the toggle button. - **`tableStyle.ts`** (`TableStyle`) — the **table style** family's editor half (model in `styles/tableStyles.ts`, see below): global attrs `tableStyle` on `table` (⇄ `data-table-style`) and `region` on `tableCell`/`tableHeader` (⇄ `data-region`, a space-separated region list). `setTableStyle(name|null)` writes the attr and **materializes** the style into the cells in one transaction — `paintTable` walks the `TableMap`, resolves each cell via `resolveTableCell` (merged cells by their top-left `findCell` box) and writes `backgroundColor`/the four border attrs/`region`, so **export, PDF and clipboard need no new code**. It only writes cells that differ, which makes it idempotent — the condition the re-band plugin depends on. That plugin (`appendTransaction`) repaints a styled table when its row/column **shape** changes (Word's re-banding); deliberately *not* on every transaction, or a manually shaded cell would be repainted away immediately. `refreshTableStyles()` repaints every styled table and is called from `Editor.svelte`'s document-stylesheet `$effect` — the choke point every registry change (edit, rename, reset, import) passes through. `activeTableStyle`/`cellRegionText` feed the gallery and the Bold button (a style's bold region behaves like a heading, see `tableHeaderRow.ts`). `TableView` sets `data-table-style` itself, since the node view bypasses `renderHTML`. diff --git a/src/lib/editor/extensions/tableCellPadding.ts b/src/lib/editor/extensions/tableCellPadding.ts index a4f1619..c279e6d 100644 --- a/src/lib/editor/extensions/tableCellPadding.ts +++ b/src/lib/editor/extensions/tableCellPadding.ts @@ -1,8 +1,8 @@ import { Extension } from '@tiptap/core'; -// A table's cell margins in cm, [top, right, bottom, left] — Word's w:tblCellMar, -// ODF's fo:padding on the cell style. `null` = the default below, which is Word's -// own and what editor.css falls back to, so an ordinary table carries no attr. +// Cell margins in cm, [top, right, bottom, left] — Word's w:tblCellMar (or w:tcMar on +// the cell), ODF's fo:padding on the cell style. `null` = inherit, which for a table is +// the default below (Word's own, and editor.css's fallback) and for a cell its table's. export type CellPadding = [number, number, number, number]; export const DEFAULT_CELL_PADDING: CellPadding = [0, 0.19, 0, 0.19]; @@ -11,14 +11,18 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000; // side this close to the default is the default — otherwise every table carries one. const SAME_CM = 0.005; -// Clamped, rounded, and collapsed to null when every side is the default. -export function cellPaddingAttr(sides: (number | null | undefined)[]): CellPadding | null { - const p = DEFAULT_CELL_PADDING.map((d, i) => { +// Clamped, rounded, and collapsed to null when every side matches the baseline it would +// override: the table's for a cell (which inherits it), the default for a table. +export function cellPaddingAttr( + sides: (number | null | undefined)[], + base: CellPadding = DEFAULT_CELL_PADDING, +): CellPadding | null { + const p = base.map((d, i) => { const v = sides[i]; if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return d; return Math.abs(v - d) <= SAME_CM ? d : round3(Math.min(v, 5)); }) as CellPadding; - return p.every((v, i) => v === DEFAULT_CELL_PADDING[i]) ? null : p; + return p.every((v, i) => v === base[i]) ? null : p; } export function parseCellPadding(value: unknown): CellPadding | null { @@ -41,7 +45,9 @@ export const TableCellPadding = Extension.create({ addGlobalAttributes() { return [ { - types: ['table'], + // On a cell it is the cell's own margin (Word w:tcMar), overriding the table's: + // the custom property inherits, so the cell's own declaration wins for its td. + types: ['table', 'tableCell', 'tableHeader'], attributes: { cellPadding: { default: null, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 12e65b7..aa103d7 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -828,9 +828,14 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): const fill = typeof bg === 'string' ? hexColor(bg) : undefined; const widthTwip = colsTwip ? colsTwip.slice(col, col + colspan).reduce((a, b) => a + b, 0) : undefined; col += colspan; + // The cell's own margins (w:tcMar); absent, Word falls back to the table's. + const ownPad = parseCellPadding(cell.attrs?.cellPadding); cells.push(new TableCell({ columnSpan: colspan > 1 ? colspan : undefined, rowSpan: rowspan > 1 ? rowspan : undefined, + margins: ownPad + ? { marginUnitType: WidthType.DXA, top: cmToTwip(ownPad[0]), right: cmToTwip(ownPad[1]), bottom: cmToTwip(ownPad[2]), left: cmToTwip(ownPad[3]) } + : undefined, shading: fill ? { type: ShadingType.CLEAR, fill } : undefined, width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined, borders: { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index a7a96e9..0c7cdb9 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -15,7 +15,7 @@ import { type TableStyleRef = { name: string; look: TableLook }; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders'; -import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding'; +import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker'; @@ -2523,9 +2523,11 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, const look = parseTableLook(node.attrs?.tableLook); tableStyleNames.push(tableStyle && styleName ? { name: styleName, look } : null); const columnWidths = tableColumnWidthsCm(node, contentWidthCm - (margins ? margins.ml + margins.mr : 0)); - // The table's cell margins ride on every cell as a TRBL shorthand; expandCellPadding - // splits it, since odf-kit only writes fo:padding and ODF only allows one length. - const cellPad = (parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING).map(n => `${n}cm`).join(' '); + // Cell margins ride on every cell as a TRBL shorthand (ODF has no table-level one); + // expandCellPadding splits it, since odf-kit only writes fo:padding and ODF only + // allows one length there. A cell's own attr overrides the table's. + const tablePad = parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING; + const padValue = (p: CellPadding) => p.map(n => `${n}cm`).join(' '); doc.addTable((t: TableBuilder) => { for (const row of rows) { t.addRow((r: RowBuilder) => { @@ -2534,7 +2536,7 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, // Emit the cell's runs (SEG-separated) and record its descriptor; addCell is // synchronous, so the push stays in document order, matching applyCellBlocks' // walk. Spans make odf-kit emit covered cells, which its cell regex never matches. - const opts: CellOptions = { padding: cellPad }; + const opts: CellOptions = { padding: padValue(parseCellPadding(cell.attrs?.cellPadding) ?? tablePad) }; const colspan = (cell.attrs?.colspan as number) ?? 1; const rowspan = (cell.attrs?.rowspan as number) ?? 1; if (colspan > 1) opts.colSpan = colspan; diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 01016a4..74a32c2 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -20,7 +20,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/ import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter'; import { applyUniformRunFont, type OdtImportResult } from './odt'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; -import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding'; +import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; // .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its // native node/mark/attr; values matching the editor's defaults are suppressed so round @@ -1433,13 +1433,11 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null { } } -// w:tblCellMar → [top, right, bottom, left] cm, direct before the table style's. -// A side nobody declares stays the editor default (cellPaddingAttr fills it in). -function docxCellPadding(tbl: Element, styleId: string | null, ctx: Ctx): CellPadding | null { - const direct = fc(fc(tbl, 'tblPr'), 'tblCellMar'); - const fromStyle = ctx.styles.tableCellMar(styleId); - const side = (...names: string[]): number | null => { - for (const el of [direct, fromStyle]) { +// A w:tblCellMar / w:tcMar's four sides in cm, taking the first element that declares +// each. w:type="nil" is an explicit zero; a side nobody declares comes back null. +function cellMarginsCm(els: (Element | null)[]): (number | null)[] { + return [['top'], ['right', 'end'], ['bottom'], ['left', 'start']].map((names) => { + for (const el of els) { for (const name of names) { const s = el && fc(el, name); if (!s) continue; @@ -1449,8 +1447,12 @@ function docxCellPadding(tbl: Element, styleId: string | null, ctx: Ctx): CellPa } } return null; - }; - return cellPaddingAttr([side('top'), side('right', 'end'), side('bottom'), side('left', 'start')]); + }); +} + +// The table's own cell margins: direct before the table style's. +function docxCellPadding(tbl: Element, styleId: string | null, ctx: Ctx): CellPadding | null { + return cellPaddingAttr(cellMarginsCm([fc(fc(tbl, 'tblPr'), 'tblCellMar'), ctx.styles.tableCellMar(styleId)])); } function buildTable(tbl: Element, ctx: Ctx): Node | null { @@ -1460,6 +1462,13 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { // Table-level border defaults; per-cell w:tcBorders override per side. Edge cells // fall back to the outer sides, interior cells to insideH/insideV. + // Word's w:tblStyle: only the name comes back — the look rides on the cell attrs, and + // the editor re-derives the regions from its own registry (refreshTableStyles). + const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle'); + const styleId = styleEl ? wVal(styleEl) : null; + const pad = docxCellPadding(tbl, styleId, ctx); + const padBase = pad ?? DEFAULT_CELL_PADDING; + const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders'); const tblSide = (name: string) => docxBorderAttr(fc(tblBorders, name)); const tblDef = { @@ -1491,6 +1500,9 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE); const attrs: Record = { colspan, rowspan: 1 }; if (bg) attrs.backgroundColor = bg; + // The cell's own margins (w:tcMar), kept only where they differ from the table's. + const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase); + if (ownPad) attrs.cellPadding = ownPad; const tcBorders = fc(tcPr, 'tcBorders'); const isRight = gridWidth != null && col + colspan >= gridWidth; const resolve = (name: string, tblVal: string | null | undefined) => { @@ -1518,13 +1530,8 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { rows.push(row); } if (rows.length === 0) return null; - // Word's w:tblStyle: only the name comes back — the look rides on the cell attrs, and - // the editor re-derives the regions from its own registry (refreshTableStyles). - const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle'); - const styleId = styleEl ? wVal(styleEl) : null; const named = ctx.styles.tableStyleName(styleId); const attrs: Record = { ...(tableMargins(tbl, useWeights, ctx) ?? {}) }; - const pad = docxCellPadding(tbl, styleId, ctx); if (pad) attrs.cellPadding = pad; if (named) { attrs.tableStyle = named; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 1c532ec..37be68a 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -17,7 +17,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; -import { cellPaddingAttr, type CellPadding } from '../editor/extensions/tableCellPadding'; +import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; // .odt → TipTap JSON, inverting export/odt.ts. Editor-expressible content becomes its // native node/mark/attr; values matching the editor's defaults are suppressed so round @@ -1596,9 +1596,11 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1; const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1; const cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name'); - // ODF has no table-level cell margin — it sits on each cell's style, and every - // producer writes it uniformly, so the first cell's stands for the table. - if (cellPad === undefined) cellPad = cellPaddingAttr(ctx.resolver.cellPadding(cellStyleName)); + // ODF has no table-level cell margin — it sits on each cell's style, so the first + // cell's stands for the table and any cell that disagrees keeps its own. + const padSides = ctx.resolver.cellPadding(cellStyleName); + if (cellPad === undefined) cellPad = cellPaddingAttr(padSides); + const ownPad = cellPaddingAttr(padSides, cellPad ?? DEFAULT_CELL_PADDING); const rawBg = ctx.resolver.cellBackgroundColor(cellStyleName); const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null; // Per-side borders; an undeclared side means no border in ODF → 'none'. @@ -1616,6 +1618,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE); for (let r = 0; r < repeated; r++) { const attrs: Record = { colspan, rowspan, ...borders }; + if (ownPad) attrs.cellPadding = ownPad; if (backgroundColor) attrs.backgroundColor = backgroundColor; if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan); cells.push({ diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index a151fc1..48bf020 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -120,9 +120,6 @@ LibreOffice's 48, with 62 line-level differences, most of them the justification where Word moves the text to the next list tab — reading `w:lvl/w:pPr/w:ind w:hanging` would settle it. Deliberate for now: in flow, a wide marker pushed the text and cost the thesis fixture a page. -- **A cell's own margins are not read**, only the table's (`w:tcMar`; ODF puts padding on - every cell style, and we take the first cell's for the whole table). No producer in - the corpus writes per-cell margins. - Lines whose natural width lands within ~0.2mm of the right margin may take one word more or fewer than LibreOffice. Sub-0.1mm engine rounding, not a layout rule. diff --git a/tests/unit/cell-padding.test.ts b/tests/unit/cell-padding.test.ts index e4a585c..7a2f9a0 100644 --- a/tests/unit/cell-padding.test.ts +++ b/tests/unit/cell-padding.test.ts @@ -46,6 +46,31 @@ describe('table cell padding', () => { // Word's 108 twips and LibreOffice's 0.191cm are both our 0.19cm default. expect(cellPaddingAttr([0, 0.1905, 0, 0.191])).toBeNull(); expect(cellPaddingAttr([0.026, 0.026, 0.026, 0.026])).toEqual([0.026, 0.026, 0.026, 0.026]); + // A cell measures against its table's, so the editor default is an override there + // and its own table's value is not. + expect(cellPaddingAttr([0, 0.19, 0, 0.19], [0.3, 0.3, 0.3, 0.3])).toEqual([0, 0.19, 0, 0.19]); + expect(cellPaddingAttr([0.3, 0.3, 0.3, 0.3], [0.3, 0.3, 0.3, 0.3])).toBeNull(); + // An undeclared side falls back to the table's, not the editor default. + expect(cellPaddingAttr([0.1, null, null, null], [0.3, 0.3, 0.3, 0.3])).toEqual([0.1, 0.3, 0.3, 0.3]); + }); + + it('round-trips one cell’s own margins against the table’s', async () => { + const mixed: N = structuredClone(doc); + mixed.content[1].attrs = { cellPadding: [0.3, 0.3, 0.3, 0.3] }; + mixed.content[1].content[0].content[1].attrs.cellPadding = [0.02, 0.6, 0.02, 0.6]; + const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + const cellOf = (d: N, row: number, col: number) => + (d.content!.find((n: N) => n.type === 'table') as N).content[row].content[col]; + + const odt = await importOdt(await buildOdt(mixed, margins, 'portrait')); + expect(cellOf(odt.content, 0, 0).attrs?.cellPadding ?? null).toBeNull(); + expect(cellOf(odt.content, 0, 1).attrs?.cellPadding).toEqual([0.02, 0.6, 0.02, 0.6]); + + const docx = importDocx(await buildDocx(mixed, margins, 'portrait')); + expect(cellOf(docx.content, 0, 0).attrs?.cellPadding ?? null).toBeNull(); + for (const [i, cm] of [0.02, 0.6, 0.02, 0.6].entries()) { + expect(cellOf(docx.content, 0, 1).attrs?.cellPadding[i]).toBeCloseTo(cm, 2); + } }); it('round-trips a table’s own cell margins', async () => {