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.
This commit is contained in:
1 parent
e4b679ad8b
commit
db0fe71cdb
11 files changed
+174
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
const style = cellPaddingStyle(attributes.cellPadding);
|
||||
return style ? { style } : {};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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: <colgroup> 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-11
@@ -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(
|
||||
/(<style:table-cell-properties\b[^>]*?)fo:padding="[^"]*"/g,
|
||||
(_m, head: string) => head + perSide,
|
||||
/(<style:table-cell-properties\b[^>]*?)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;
|
||||
|
||||
+25
-1
@@ -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<string, unknown> = { ...(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'));
|
||||
|
||||
@@ -172,6 +172,7 @@ export class DocxStyles {
|
||||
private ownKeepNext = new Map<string, boolean>(); // style's own w:pPr/w:keepNext
|
||||
private ownKeepLines = new Map<string, boolean>(); // style's own w:pPr/w:keepLines
|
||||
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
|
||||
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
|
||||
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
|
||||
private numToAbstract = new Map<string, string>();
|
||||
private abstractLevels = new Map<string, Map<number, LevelDef>>();
|
||||
@@ -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<string>()): 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 {};
|
||||
|
||||
@@ -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<string, unknown> = { ...(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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <div class="tableWrapper"> 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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user