diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e7f9ff..bbca328 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@
- Word's Table Style Options as six checkboxes beside the gallery (header row, total row, first/last column, banded rows/columns): an area is painted only when the style defines it *and* the table opts in, and turning the header row off shifts the banding by one row. The toolbar's header row / first column buttons drive the same flags, so both surfaces always agree
- Table styles are edited in the same style manager (third tab: area picker for fill and font, outer / row line / column line controls, new / rename / remove), also reachable from the insert-table dropdown
- Round-trip: the style name travels as an ODF `style:family="table"` (`style:parent-style-name`) and DOCX `w:tblStyle`, the options as ODF's `table:use-*-styles` and Word's `w:tblLook`. Fills and borders are materialized into the cells and the region's font is baked onto the runs, so the table looks right in Word/LibreOffice even though ODF has no banding concept — the style definitions themselves live in the app, as LibreOffice's AutoFormats do
+- A Word table's indent follows the file's compatibility mode: up to Word 2010 `w:tblInd` is measured to the cell's text, so the table hangs its left cell margin into the page margin and the first column's text lines up with the body — from Word 2013 it is the table's edge
- A Word file's own table style is read the other way round: its conditional areas (`w:tblStylePr` — header row, banded rows, first/last column) are baked into the cells on import, shading, borders and bold/colour alike, so a document styled with Word's Light Grid or Medium Shading opens looking like it does in Word and LibreOffice
- A new document starts from the built-in styles, like Word's/LibreOffice's default template
diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md
index bd56585..556d2c2 100644
--- a/docs/architecture/tables.md
+++ b/docs/architecture/tables.md
@@ -7,7 +7,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table-
- **`tableView.ts`** (`TableView`) — custom table node view. Unlike TipTap's built-in view it renders the `
` with **percentage** widths and keeps the table at `width:100%`, so the table fills its wrapper (by default the full text width, matching the ODT export) and stays responsive to margin/orientation changes. The per-column `colwidth` cell attrs are treated as proportional *weights* (only ratios matter; `null` = equal share). The table's own `marginLeft`/`marginRight` attrs (cm, from the outer-edge drag) go on the `.tableWrapper` — that's what narrows the table.
- **`tableRow.ts`** (`ResizableTableRow`) — `TableRow` extended with a `rowHeight` attr (unscaled doc px @96dpi), rendered as a CSS min-height (`height` on ``), exported as `style:min-row-height`. `Table` (unlike `TableKit`) does NOT auto-add child extensions, so `ResizableTableRow` must be listed explicitly.
-- **`tableColumnResize.ts`** (`TableColumnResize`) — Word-style column drag: dragging an *inner* border trades width between two columns only (their sum stays constant, table width fixed). Live feedback pokes the `` elements; release writes new weights into `colwidth`. The table's **outer left/right edges** are draggable too (Word/LibreOffice): they move that edge alone — the pure `edgeResize` helper shifts the global `marginLeft`/`marginRight` attrs it adds on `table` (cm, `>= 0`, so the table never leaves the text area) and resizes only the adjacent column, so every other gridline stays put; the minimum is `CELL_MIN_PX`. `borderAt` classifies a hovered border as `inner`/`left`/`right`, the handle decoration mirrors to the cell's left side (`.column-resize-handle.left`), and the commit writes `colwidth` + the table attrs in one transaction. `TableView` renders the margins on the wrapper; the attrs' own `renderHTML` emits them (plus a matching `width: calc(...)`) for the static HTML paths (PDF export, clipboard). They round-trip as ODF `fo:margin-left`/`-right` + `style:width` (`applyTableMargins`) and DOCX `w:tblInd` + the narrower `w:tblGrid`.
+- **`tableColumnResize.ts`** (`TableColumnResize`) — Word-style column drag: dragging an *inner* border trades width between two columns only (their sum stays constant, table width fixed). Live feedback pokes the `` elements; release writes new weights into `colwidth`. The table's **outer left/right edges** are draggable too (Word/LibreOffice): they move that edge alone — the pure `edgeResize` helper shifts the global `marginLeft`/`marginRight` attrs it adds on `table` (cm; the drag stops at the text area, but an imported value may be **negative** — see below) and resizes only the adjacent column, so every other gridline stays put; the minimum is `CELL_MIN_PX`. `borderAt` classifies a hovered border as `inner`/`left`/`right`, the handle decoration mirrors to the cell's left side (`.column-resize-handle.left`), and the commit writes `colwidth` + the table attrs in one transaction. `TableView` renders the margins on the wrapper; the attrs' own `renderHTML` emits them (plus a matching `width: calc(...)`) for the static HTML paths (PDF export, clipboard). They round-trip as ODF `fo:margin-left`/`-right` + `style:width` (`applyTableMargins`) and DOCX `w:tblInd` + the narrower `w:tblGrid`. In **Word 2010 and earlier** (`w:compatSetting` `compatibilityMode` ≤ 14, or none at all) **`w:tblInd` is measured to the cell's text**, not to the table's edge, so import subtracts the left cell margin from it (`tblIndIsToText`, `import/docx.ts`): a `w:tblInd="0"` table there has `marginLeft` **−0.19cm** and hangs that much into the page margin, which is where LibreOffice draws it and what puts the first column's text on the body's left margin. Word 2013 (mode 15) redefined it as the table's own edge — probed both ways against `soffice` — and that is what our own export writes, so `w:tblInd` goes out as `marginLeft` unchanged. ODF's `fo:margin-left` is the table edge too, and both may be negative.
- **`tableRowResize.ts`** (`TableRowResize`) — Word-style row-height drag (changes the row *above* the grid line; min-height semantics). Yields to column resize at a corner.
- **`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).
diff --git a/src/lib/editor/extensions/tableColumnResize.ts b/src/lib/editor/extensions/tableColumnResize.ts
index 3afd455..e03a0b7 100644
--- a/src/lib/editor/extensions/tableColumnResize.ts
+++ b/src/lib/editor/extensions/tableColumnResize.ts
@@ -33,9 +33,10 @@ type ResizeAction =
const round3 = (v: number) => Math.round(v * 1000) / 1000;
// A CSS margin (our own output is cm, pasted HTML may be px) as cm; 0 when absent.
+// Negative is real: a Word table hangs its left cell margin into the page margin.
function marginCm(value: string): number {
const n = parseFloat(value);
- if (!Number.isFinite(n) || n <= 0) return 0;
+ if (!Number.isFinite(n)) return 0;
return value.trim().endsWith('px') ? round3(n / PX_PER_CM) : round3(n);
}
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index 8febb68..e4bf378 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -940,8 +940,8 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
const tableStyle = styleName ? exportSheet.table?.[styleName] : undefined;
const look = parseTableLook(node.attrs?.tableLook);
// A dragged table edge (tableColumnResize.ts) → w:tblInd + the narrower grid.
- let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0);
- let mr = Math.max(0, Number(node.attrs?.marginRight) || 0);
+ let ml = Number(node.attrs?.marginLeft) || 0;
+ let mr = 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);
@@ -1001,6 +1001,8 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
rows: tableRows,
columnWidths: colsTwip,
width: { size: totalTwip, type: WidthType.DXA },
+ // The table's own edge, negative where it hangs into the page margin: what we write
+ // declares Word 2013 compatibility, where w:tblInd means exactly that.
indent: ml ? { size: cmToTwip(ml), type: WidthType.DXA } : undefined,
layout: TableLayoutType.FIXED,
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(pad[0]), right: cmToTwip(pad[1]), bottom: cmToTwip(pad[2]), left: cmToTwip(pad[3]) },
diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 7b7012e..9272a85 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -46,6 +46,12 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
drawn**: Word's Normal Table has no border, and the gridlines it shows on screen are not
printed. ODF says the same for an undeclared `fo:border`, so both importers agree.
+**`w:tblInd` changes meaning with the file's compatibility mode.** Word 2010 and earlier
+(`compatibilityMode` ≤ 14, or no `word/settings.xml`) measure it to the cell's **text**, so
+the table hangs its left cell margin into the page margin; Word 2013 (15) measures it to the
+table's edge. LibreOffice follows the setting — probed both ways on the same file — so
+`tblIndIsToText` gates the subtraction and a table's `marginLeft` may come out negative.
+
**A table style's conditional areas** (`w:tblStylePr`: header row, banded rows, first
column, …) are **baked into the cells** — fill and borders as attrs, the area's `w:rPr` as
real marks on its runs — because the file's own style is not in the editor's registry,
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 168d0b7..efdd0d3 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -56,6 +56,8 @@ type Ctx = {
leftMarginCm: number;
// The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs.
cellSpacing: ParaSpacing;
+ // Whether w:tblInd is measured to the cell's text rather than the table's edge.
+ tblIndToText: boolean;
// theme1.xml's accent1..6, the colours a chart series names instead of an sRGB.
accents: string[];
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
@@ -142,7 +144,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
- const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc), openBookmarks: new Map() };
+ const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map() };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
@@ -1854,7 +1856,7 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
}
if (rows.length === 0) return null;
const named = ctx.styles.tableStyleName(styleId);
- const attrs: Record = { ...(tableMargins(tbl, useWeights, ctx) ?? {}) };
+ const attrs: Record = { ...(tableMargins(tbl, useWeights, ctx, padBase[3]) ?? {}) };
if (pad) attrs.cellPadding = pad;
if (named) {
attrs.tableStyle = named;
@@ -1876,19 +1878,21 @@ function docxTableLook(tblPr: Element | null): string | null {
}
// A table narrower than the text width: w:tblInd is its left indent, the grid (or
-// w:tblW) its width — the rest becomes the editor's right margin.
-function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx): { marginLeft: number; marginRight: number } | null {
+// w:tblW) its width — the rest becomes the editor's right margin. Under the older
+// compatibility mode the indent is measured to the cell's *text*, so the table hangs its
+// left cell margin into the page margin (tblIndIsToText).
+function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx, leftPadCm: number): { marginLeft: number; marginRight: number } | null {
const tblPr = fc(tbl, 'tblPr');
const content = ctx.contentWidthCm;
const dxa = (el: Element | null) => (el?.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(el, W, 'w') : null;
- const left = Math.max(0, twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 0));
+ const left = twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 0) - (ctx.tblIndToText ? leftPadCm : 0);
const declared = dxa(fc(tblPr, 'tblW'));
const width = declared && declared > 0
? twipToCm(declared)
: weights ? twipToCm(weights.reduce((a, b) => a + b, 0)) : null;
- const right = width != null ? Math.max(0, content - left - width) : 0;
+ const right = width != null ? content - left - width : 0;
- if (left < 0.05 && right < 0.05) return null;
+ if (Math.abs(left) < 0.05 && Math.abs(right) < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
@@ -1985,6 +1989,23 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o
// Word's mirror margins are a document setting too: w:pgMar's left/right then read as
// the inner/outer pair and an even page swaps them.
+// Word 2013 (compatibilityMode 15) made w:tblInd the table's own edge; before that it was
+// measured to the cell's text, so the table hangs its left cell margin into the page
+// margin. Probed against `soffice`, which follows the setting: the same table sits at the
+// margin under 15 and 2.1mm left of it under 14. No setting at all is an old file.
+function tblIndIsToText(files: Record): boolean {
+ const bytes = files['word/settings.xml'];
+ if (!bytes) return true;
+ try {
+ for (const el of Array.from(parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'compatSetting'))) {
+ if (el.getAttributeNS(W, 'name') !== 'compatibilityMode') continue;
+ const n = parseInt(el.getAttributeNS(W, 'val') ?? '', 10);
+ return !Number.isFinite(n) || n < 15;
+ }
+ } catch { /* an unreadable settings.xml is no setting */ }
+ return true;
+}
+
function docHasMirrorMargins(files: Record): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 5cfc62a..a2b1319 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -2164,12 +2164,11 @@ function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight:
const align = props['table:align'];
left = align === 'center' ? (content - width) / 2 : align === 'right' ? content - width : 0;
}
- left = Math.max(0, left ?? 0);
+ left = left ?? 0; // negative is real: a table may hang into the page margin
if (right == null) right = width != null ? content - left - width : 0;
- right = Math.max(0, right);
// Rounding noise from the producer, or a table that would be left with no room.
- if (left < 0.05 && right < 0.05) return null;
+ if (Math.abs(left) < 0.05 && Math.abs(right) < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index 4d14761..2eb59f9 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -1109,4 +1109,40 @@ describe('DOCX table margins (dragged outer edges)', () => {
const weights = (table.content?.[0].content ?? []).map((c: N) => c.attrs.colwidth[0]);
expect(weights[0] / weights[1]).toBeCloseTo(2, 2);
});
+
+ // Word 2010 and earlier measure w:tblInd to the cell's text, so LibreOffice draws such
+ // a table a left cell margin outside the body text; Word 2013 (mode 15) does not.
+ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
+ const indentedTable = (mode: number | null) => {
+ const full = Math.round((16.76 / 2.54) * 1440);
+ return zipSync({
+ 'word/document.xml': strToU8(
+ `` +
+ `` +
+ `` +
+ `A` +
+ `` +
+ ``,
+ ),
+ ...(mode == null ? {} : {
+ 'word/settings.xml': strToU8(
+ `` +
+ ``,
+ ),
+ }),
+ });
+ };
+
+ it('hangs an older file’s table its left cell margin into the page margin', () => {
+ for (const mode of [null, 14]) {
+ const table = walk(importDocx(indentedTable(mode)).content as N, 'table')[0];
+ expect(table.attrs.marginLeft).toBeCloseTo(-0.19, 2);
+ expect(table.attrs.marginRight).toBeCloseTo(0.19, 2);
+ }
+ });
+
+ it('leaves a Word 2013 file’s table flush with the text area', () => {
+ const table = walk(importDocx(indentedTable(15)).content as N, 'table')[0];
+ expect(table.attrs?.marginLeft ?? 0).toBe(0);
+ });
});