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.
This commit is contained in:
1 parent
aad4e7a3e4
commit
ca3767f3d4
8 files changed
+81
-36
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-15
@@ -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<string, unknown> = { 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<string, unknown> = { ...(tableMargins(tbl, useWeights, ctx) ?? {}) };
|
||||
const pad = docxCellPadding(tbl, styleId, ctx);
|
||||
if (pad) attrs.cellPadding = pad;
|
||||
if (named) {
|
||||
attrs.tableStyle = named;
|
||||
|
||||
@@ -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<string, unknown> = { colspan, rowspan, ...borders };
|
||||
if (ownPad) attrs.cellPadding = ownPad;
|
||||
if (backgroundColor) attrs.backgroundColor = backgroundColor;
|
||||
if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan);
|
||||
cells.push({
|
||||
|
||||
Reference in new issue
Block a user