From db0fe71cdb006ea883ad72131cb7a8646ee71c2f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 8 Aug 2026 12:18:16 +0000 Subject: [PATCH] feat: a table keeps the cell margins its file gave it The cell padding was a constant in three places that disagreed with each other, so a narrow column lost 0.38cm it did not have: a bibliography marker column of 0.71cm wrapped "[2] " onto three lines and every entry grew with it, costing a fixture a whole page. The table now carries its own margins (w:tblCellMar, direct or from the table style; fo:padding on the ODF cell style) and both exports write them back. A side within a twentieth of a millimetre of the default is the default, or Word's 108 twips would tag every table. --- src/lib/editor/extensions.ts | 3 + src/lib/editor/extensions/tableCellPadding.ts | 58 +++++++++++++++++++ src/lib/editor/extensions/tableView.ts | 10 ++++ src/lib/export/docx.ts | 4 +- src/lib/export/odt.ts | 25 ++++---- src/lib/import/docx.ts | 26 ++++++++- src/lib/import/docxStyles.ts | 11 ++++ src/lib/import/odt.ts | 6 ++ src/lib/import/styleResolver.ts | 10 ++++ src/styles/editor.css | 6 +- tests/unit/cell-padding.test.ts | 31 ++++++++++ 11 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 src/lib/editor/extensions/tableCellPadding.ts diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 69f88b3..40501ca 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -41,6 +41,7 @@ import { TableRowResize } from './extensions/tableRowResize'; import { TableSplit } from './extensions/tableSplit'; import { TableCellBackground } from './extensions/tableCellBackground'; import { TableCellBorders } from './extensions/tableCellBorders'; +import { TableCellPadding } from './extensions/tableCellPadding'; import { TableHeaderRow } from './extensions/tableHeaderRow'; import { TableStyle } from './extensions/tableStyle'; import { TrailingNode } from './extensions/trailingNode'; @@ -134,6 +135,8 @@ export const extensions = [ TableCellBackground, // Border presets: per-side border attrs on cells (→ fo:border-*). TableCellBorders, + // The table's cell margins (→ w:tblCellMar / fo:padding on the cell style). + TableCellPadding, // "Header row" styling preset: bold + light shading on the first row (toggle). TableHeaderRow, // Named table style (Word table style / LibreOffice AutoFormat): paints the table's diff --git a/src/lib/editor/extensions/tableCellPadding.ts b/src/lib/editor/extensions/tableCellPadding.ts new file mode 100644 index 0000000..a4f1619 --- /dev/null +++ b/src/lib/editor/extensions/tableCellPadding.ts @@ -0,0 +1,58 @@ +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. +export type CellPadding = [number, number, number, number]; +export const DEFAULT_CELL_PADDING: CellPadding = [0, 0.19, 0, 0.19]; + +const round3 = (n: number) => Math.round(n * 1000) / 1000; +// Word's 108 twips are 0.1905cm and LibreOffice writes 0.19cm back as 0.191, so a +// 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) => { + 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; +} + +export function parseCellPadding(value: unknown): CellPadding | null { + if (Array.isArray(value) && value.length === 4) return cellPaddingAttr(value as number[]); + if (typeof value !== 'string') return null; + const nums = value.trim().split(/\s+/).map((s) => parseFloat(s)); + return nums.length === 4 && nums.every((n) => Number.isFinite(n)) ? cellPaddingAttr(nums) : null; +} + +// One custom property carrying the whole shorthand; editor.css reads it with the +// default as its fallback, so nothing to write for an ordinary table. +export function cellPaddingStyle(value: unknown): string | null { + const p = parseCellPadding(value); + return p ? `--cell-pad:${p.map((n) => `${n}cm`).join(' ')}` : null; +} + +export const TableCellPadding = Extension.create({ + name: 'tableCellPadding', + + addGlobalAttributes() { + return [ + { + types: ['table'], + attributes: { + cellPadding: { + default: null, + parseHTML: (element: HTMLElement) => parseCellPadding(element.style.getPropertyValue('--cell-pad')), + renderHTML: (attributes: Record) => { + const style = cellPaddingStyle(attributes.cellPadding); + return style ? { style } : {}; + }, + }, + }, + }, + ]; + }, +}); diff --git a/src/lib/editor/extensions/tableView.ts b/src/lib/editor/extensions/tableView.ts index 4cbdaeb..0522c62 100644 --- a/src/lib/editor/extensions/tableView.ts +++ b/src/lib/editor/extensions/tableView.ts @@ -1,4 +1,5 @@ import type { Node as PMNode } from '@tiptap/pm/model'; +import { parseCellPadding } from './tableCellPadding'; import type { ViewMutationRecord } from '@tiptap/pm/view'; // Custom table node view: uses percentage widths and the table stays at @@ -56,6 +57,13 @@ function applyTableStyleAttr(node: PMNode, table: HTMLElement): void { else delete table.dataset.tableLook; } +// Also bypassed by the node view: the cell margins editor.css reads off the table. +function applyCellPadding(node: PMNode, table: HTMLElement): void { + const p = parseCellPadding(node.attrs.cellPadding); + if (p) table.style.setProperty('--cell-pad', p.map((n) => `${n}cm`).join(' ')); + else table.style.removeProperty('--cell-pad'); +} + function buildColgroup(node: PMNode, colgroup: HTMLElement): void { while (colgroup.firstChild) colgroup.removeChild(colgroup.firstChild); const percents = columnPercents(columnWeightsFromRow(node.firstChild)); @@ -86,6 +94,7 @@ export class TableView { // table the full text width regardless of the stored weights. this.table.style.width = '100%'; applyTableStyleAttr(node, this.table); + applyCellPadding(node, this.table); this.colgroup = this.table.appendChild(document.createElement('colgroup')); buildColgroup(node, this.colgroup); this.contentDOM = this.table.appendChild(document.createElement('tbody')); @@ -96,6 +105,7 @@ export class TableView { this.node = node; applyMargins(node, this.dom); applyTableStyleAttr(node, this.table); + applyCellPadding(node, this.table); buildColgroup(node, this.colgroup); return true; } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index da6e55e..5cdb0df 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -20,6 +20,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders'; +import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding'; import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker'; import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; @@ -805,6 +806,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); let mr = Math.max(0, Number(node.attrs?.marginRight) || 0); if (ml + mr > contentWidthCm - 1) { ml = 0; mr = 0; } + const pad = parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING; const colsCm = columnWidthsCm(node, contentWidthCm - ml - mr); const colsTwip = colsCm?.map(cmToTwip); const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm - ml - mr); @@ -856,7 +858,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): width: { size: totalTwip, type: WidthType.DXA }, indent: ml ? { size: cmToTwip(ml), type: WidthType.DXA } : undefined, layout: TableLayoutType.FIXED, - margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) }, + margins: { marginUnitType: WidthType.DXA, top: cmToTwip(pad[0]), right: cmToTwip(pad[1]), bottom: cmToTwip(pad[2]), left: cmToTwip(pad[3]) }, borders: { top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder, insideHorizontal: cellBorder, insideVertical: cellBorder, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6e9feb9..072fbd2 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -14,6 +14,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 { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { parseTabStops } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker'; @@ -40,10 +41,6 @@ const CUST_HF = '__cust_hf__'; // Table export styling. Values mirror the editor's table CSS (src/styles/editor.css) // so the .odt matches the on-screen preview. const TABLE_BORDER = '0.5pt solid #000000'; -// Word's/LibreOffice's cell margins, asymmetric. Must match td/th padding in editor.css. -const CELL_PADDING_X = '0.19cm'; -const CELL_PADDING_Y = '0cm'; -const CELL_PADDING = CELL_PADDING_X; // odf-kit emits this as the document's default font (Standard style). The editor // renders the bundled, metric-identical Liberation Serif on screen. @@ -764,18 +761,21 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): } // odf-kit only writes the fo:padding shorthand, which ODF defines as one length — -// LibreOffice drops a two-value form. Cells need Word's asymmetric margins, so the -// shorthand is expanded per side (scoped to cell properties; paragraphs use it too). +// LibreOffice drops a multi-value form. The cells carry their table's margins as a +// CSS-style TRBL shorthand, which this expands per side (scoped to cell properties). function expandCellPadding(odtBytes: Uint8Array): Uint8Array { const files = unzipSync(odtBytes); const contentBytes = files['content.xml']; if (!contentBytes) return odtBytes; - const perSide = `fo:padding-left="${CELL_PADDING_X}" fo:padding-right="${CELL_PADDING_X}"` - + ` fo:padding-top="${CELL_PADDING_Y}" fo:padding-bottom="${CELL_PADDING_Y}"`; const content = strFromU8(contentBytes).replace( - /(]*?)fo:padding="[^"]*"/g, - (_m, head: string) => head + perSide, + /(]*?)fo:padding="([^"]*)"/g, + (_m, head: string, value: string) => { + const [top, right, bottom, left] = value.trim().split(/\s+/); + if (!left) return head + `fo:padding="${value}"`; + return head + `fo:padding-top="${top}" fo:padding-right="${right}"` + + ` fo:padding-bottom="${bottom}" fo:padding-left="${left}"`; + }, ); files['content.xml'] = strToU8(content); @@ -2489,6 +2489,9 @@ 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(' '); doc.addTable((t: TableBuilder) => { for (const row of rows) { t.addRow((r: RowBuilder) => { @@ -2497,7 +2500,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: CELL_PADDING }; + const opts: CellOptions = { padding: cellPad }; 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 613d91a..d8a0b43 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -19,6 +19,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/ import type { HfDoc } from '../storage/headerFooter'; import { applyUniformRunFont, type OdtImportResult } from './odt'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; +import { cellPaddingAttr, 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 @@ -1439,6 +1440,26 @@ 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]) { + for (const name of names) { + const s = el && fc(el, name); + if (!s) continue; + if (s.getAttributeNS(W, 'type') === 'nil') return 0; + const w = intAttr(s, W, 'w'); + if (w != null) return twipToCm(w); + } + } + return null; + }; + return cellPaddingAttr([side('top'), side('right', 'end'), side('bottom'), side('left', 'start')]); +} + function buildTable(tbl: Element, ctx: Ctx): Node | null { const grid = fc(tbl, 'tblGrid'); const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null; @@ -1507,8 +1528,11 @@ function buildTable(tbl: Element, ctx: Ctx): Node | 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 named = ctx.styles.tableStyleName(styleEl ? wVal(styleEl) : null); + 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; const look = docxTableLook(fc(tbl, 'tblPr')); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index d7cb052..185b305 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -172,6 +172,7 @@ export class DocxStyles { private ownKeepNext = new Map(); // style's own w:pPr/w:keepNext private ownKeepLines = new Map(); // style's own w:pPr/w:keepLines private ownTabs = new Map(); // style's own w:pPr/w:tabs + private ownCellMar = new Map(); // table style's own w:tblPr/w:tblCellMar private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); @@ -246,6 +247,8 @@ export class DocxStyles { const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(left)) this.ownIndentTwip.set(id, left); } + const cellMar = firstChild(style, 'tblPr') && firstChild(firstChild(style, 'tblPr')!, 'tblCellMar'); + if (cellMar) this.ownCellMar.set(id, cellMar); const kind = style.getAttributeNS(W, 'type'); if (kind === 'paragraph' || kind === 'character' || kind === 'table') { const nameEl = firstChild(style, 'name'); @@ -447,6 +450,14 @@ export class DocxStyles { return { ...this.defaultsSpacing, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) }; } + // The table style's own w:tblCellMar along the w:basedOn chain (leaf wins). The + // element itself, so the caller reads whichever sides it declares. + tableCellMar(styleId: string | null | undefined, seen = new Set()): Element | null { + if (!styleId || seen.has(styleId)) return null; + seen.add(styleId); + return this.ownCellMar.get(styleId) ?? this.tableCellMar(this.basedOn.get(styleId) ?? null, seen); + } + level(numId: number, ilvl: number): LevelDef { const abs = this.numToAbstract.get(String(numId)); if (!abs) return {}; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index fd88bb5..3df86ca 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -17,6 +17,7 @@ import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc } from '../storage/headerFooter'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; +import { cellPaddingAttr, 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 @@ -1536,6 +1537,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const weights = columnWeights(el, ctx.resolver); const rows: Node[] = []; + let cellPad: CellPadding | null | undefined; const addRow = (rowEl: Element, header: boolean) => { const cells: Node[] = []; let colIndex = 0; @@ -1551,6 +1553,9 @@ 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)); 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'. @@ -1603,6 +1608,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null { // the regions from the registry (refreshTableStyles). const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table'); const attrs: Record = { ...(tableMargins(el, ctx) ?? {}) }; + if (cellPad) attrs.cellPadding = cellPad; if (named) { attrs.tableStyle = displayStyleName(named); // Which conditional areas the table opts into (ODF's table template attributes). diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 5c46e83..b4ec32e 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -371,6 +371,16 @@ export class StyleResolver { return out; } + // A cell style's padding (the cell margin) as [top, right, bottom, left] cm, per-side + // overriding the fo:padding shorthand. A side nobody declares comes back null. + cellPadding(styleName: string | null): (number | null)[] { + if (!styleName) return [null, null, null, null]; + const misc = this.merged('table-cell', styleName).misc; + const all = misc['fo:padding']; + return (['top', 'right', 'bottom', 'left'] as const) + .map((side) => lengthToCm(misc[`fo:padding-${side}`] ?? all)); + } + listStyle(name: string | null): Element | null { return name ? this.listStyles.get(name) ?? null : null; } diff --git a/src/styles/editor.css b/src/styles/editor.css index 9f418cd..1028e29 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -481,8 +481,8 @@ and the exported .odt look identical (see export/odt.ts CUST_TABLE handler): • border → export emits 0.5pt solid #000000; 1px solid renders crisply on screen and is the visual equivalent of a thin 0.5pt line. - • padding → Word's/LibreOffice's cell margins; export emits them per side - (CELL_PADDING_X/_Y, expanded by expandCellPadding). + • padding → Word's/LibreOffice's cell margins. The fallback below is Word's + own default; a file's own value rides --cell-pad (tableCellPadding.ts). Tables are wrapped in
by TipTap's node view. */ .paper .tiptap .tableWrapper { /* Default is flush with the text area; a dragged outer edge sets an inline @@ -504,7 +504,7 @@ .paper .tiptap td, .paper .tiptap th { border: 1px solid #000; - padding: 0 0.19cm; + padding: var(--cell-pad, 0 0.19cm); vertical-align: top; /* Positioning context for the column-resize handle widget (a child div the resize plugin places at the cell's right edge). */ diff --git a/tests/unit/cell-padding.test.ts b/tests/unit/cell-padding.test.ts index 6dfb514..e4a585c 100644 --- a/tests/unit/cell-padding.test.ts +++ b/tests/unit/cell-padding.test.ts @@ -4,6 +4,10 @@ import { describe, it, expect } from 'vitest'; import { unzipSync, strFromU8 } from 'fflate'; 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 { cellPaddingAttr } from '../../src/lib/editor/extensions/tableCellPadding'; type N = any; @@ -37,4 +41,31 @@ describe('table cell padding', () => { expect(props).toMatch(/fo:padding-bottom="0cm"/); } }); + + it('collapses a producer-rounded default, keeps a real value', () => { + // 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]); + }); + + it('round-trips a table’s own cell margins', async () => { + const tight: N = structuredClone(doc); + tight.content[1].attrs = { cellPadding: [0.03, 0.05, 0.03, 0.05] }; + const bytes = await buildOdt(tight, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + expect(xml).toMatch(/fo:padding-left="0\.05cm"/); + expect(xml).toMatch(/fo:padding-top="0\.03cm"/); + + const back = await importOdt(bytes); + const table = back.content.content!.find((n: N) => n.type === 'table') as N; + expect(table.attrs?.cellPadding).toEqual([0.03, 0.05, 0.03, 0.05]); + + const docx = await buildDocx(tight, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait'); + const backDocx = importDocx(docx); + const docxTable = backDocx.content.content!.find((n: N) => n.type === 'table') as N; + // Word measures in twips, so 0.05cm comes back a twentieth of a point off. + for (const [i, cm] of [0.03, 0.05, 0.03, 0.05].entries()) { + expect(docxTable.attrs?.cellPadding[i]).toBeCloseTo(cm, 2); + } + }); });