diff --git a/CLAUDE.md b/CLAUDE.md
index 65add2c..43981f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,7 @@ These invariants are NOT SUGGESTIONS. These MUST be followed.
- **We do not handle all possible OOXML elements: so make in-place XML mutations, not AST/Markdown round-trips.** The AST or Markdown are just views. Mutate `XmlNode` refs, only emit fresh XML for inserted nodes. This way, elements we cannot parse will not be broken. See [src/core](src/core/CLAUDE.md).
- **Most things, even those that are not easily representable, should be in Markdown.** For these, we (1) build comments into the markdown to give folks a better sense of what is going on, (2) make sure that these comment do not break the markdown and survive the roundtrip, and (3) provide intuitive verbs to deal with concepts. If some structure is truly not representable in Markdown, we nudge agents to render the pages and look. We should guarantee that the things that we're not doing the above for have no possible representation.
- **Authoring has two channels: parsed and literal.** Markdown is the DEFAULT: `edit`/`insert` take their content as a positional argument (`docx edit FILE --at p3 "Net **30** days"`), which is exactly `--markdown` (kept as an alias). New markdown content must look like the document it lands in: a `- item` beside a list joins THAT list (`continueList` in `core/lists/continue-list.tsx`), and whatever the replaced paragraph / neighbor doesn't format falls back FIRST to the look that was AT that position (for `edit`, the replaced paragraph's rendered face + explicit size), THEN to the document's DOMINANT face/size — the `docx:base` `read` shows (`matchDocumentLook` in `core/dominant-formatting.ts`) — never the new style chain's default. `--markdown`/`--from` parse GFM (+ math + CriticMarkup + inline HTML); `--text-file` (on `insert` and `create`, `-` = stdin) is the PARSER-FREE channel — every character lands verbatim, each newline starts a new paragraph. It exists because GFM corruption of literal prose isn't always escapable: a bare URL autolinks with NO escape sequence at all, and CriticMarkup eats `{++…++}` regardless of backslashes (`*x*`, `3.`, `[t](u)` ARE backslash-escapable, but the agent — often Haiku — must get the whole dialect right, and the field evidence is they don't). So "put this exact text in, untouched" gets its own flag rather than an escaping burden on the weakest actor. `--text` stays inline single-paragraph; both it and the bulk-literal `--text-file` insert markdown-looking content VERBATIM (`--text "**bold**"` writes literal `**` — use `--markdown` to parse). The only value `--text` still refuses is a shell-gutted currency amount (`rejectShellMangledValue`: `"$300"` → `.00`), which is never intentional. **Every inline `--text`/`--markdown` argv value gets its `\n`/`\t`/`\r`(`\r\n`) decoded at the CLI ingress** (`decodeInlineEscapes` in `cli/parse-helpers.ts`) — a weak agent writes `--text "a\nb"` for a line break, but bash double-quotes hand the CLI the literal backslash-n, which used to land verbatim in the run. It's applied by EVERY inline authoring surface, not just the body: `edit`/`insert`/`create`, `footnotes`/`endnotes add`+`edit` (`--text` and inline `--markdown`), `comments add`+`reply` (`--text`), `headers`/`footers set` (`--text`), and the `find`/`replace` positionals (QUERY/PATTERN/REPLACEMENT) — where a decoded `\n` is ALSO the gate that routes the command to the cross-paragraph matcher (a `\n` in a find/replace pattern matches an in-paragraph line break OR the boundary between consecutive paragraphs — in the body or within one table cell, never across a cell wall; a `\n` in a replace REPLACEMENT means a paragraph mark, editor-style — single-line replacement across a boundary MERGES paragraphs, replacement `\n` SPLITS; untracked only, refuses under tracking). Decoded whitespace flows through the same real-character handling everything uses — the shared **`textToRunElements`** emitter in `core/blocks.tsx` (`textToRuns` + `RunElement`, nulls filtered) turns a real `\n`/`\t` into ``/``, so the note/comment/marginal emitters (which previously emitted a single raw `` that swallowed a newline) now render breaks just like a body paragraph; the markdown block splitter handles the `--markdown` side. Plain single-line text still collapses to one `` run, byte-identical to the old shape. ONLY those whitespace escapes decode: a `\`-before-punctuation (markdown's own `\*`/`\[`/`\.`, and a literal `\\`) passes through UNTOUCHED, so decoding can't corrupt markdown. The decode is INLINE-argv-only — `--text-file`/`--markdown-file`/`--from` read real characters from a file and `--batch` JSONL is already `JSON.parse`-decoded, so none are re-decoded; `--text-file` therefore stays the one channel where a bare `\n` really means backslash-n.
-- **Comments are never anything but hints.** Every `` comment `read` writes into Markdown — locators (``) and the structural visibility annotations (`docx:section` — rendered at the section's START with an `applies-to="pX..pY (below)"` scope on deviating sections so the "which side of the boundary?" off-by-one is explicit, `docx:page`, `docx:table` (widths/borders + the `tables format` properties align/style/repeat-header/row-heights), `docx:cell` merge/shading/vAlign/halign/borders, `docx:track-changes on|off` ALWAYS at the head (the one hint that states its default too — weak agents can't reliably read "no hint" as "off," and a wrong tracking guess is high-cost, so it's stated outright), `docx:header`/`docx:footer` for page headers/footers (content in a `text` attr, fields as `{page}`/`{date}`/… tokens — authored via `docx headers`/`docx footers`; a UNIFORM marginal rides the head, a marginal that DIFFERS by section renders at that section's START, next to the content it governs), `docx:textbox tbxN anchor="pM"` … `docx:textbox-end tbxN` bracketing a text box's story, printed right after the paragraph that anchors it (the story's own paragraphs carry bare `tbxN:pK` locators), `docx:layout` flagging tab-aligned content that wraps in render — inside a multi-column section, OR a line whose trailing content rides a right-edge LEFT tab so a long value overflows the margin (the résumé `San`/`Francisco` split); the latter is also consolidated into ONE top-of-read `fix-all` summary naming the single-call cure `edit --at pN-pM --tabs right` (a render-only break Markdown can't show), and future image/style hints, all via `formatNote` in `cli/read/annotations.ts`) — is a READ-TIME VISIBILITY HINT. **Naming rule: a BARE comment is a locator (an address you pass to `--at`: ``, ``; a plain empty cell uses `` so it can be filled directly); a `docx:TYPE` comment is docx-cli METADATA** (structure/formatting). Metadata never rides a bare locator — it's its own `docx:` annotation, which may carry the relevant locator as a bare leading token (``). So `docx:` is greppable as "everything docx-cli added beyond addressing," and locators stay short. The Markdown importer DROPS them all (`walker.tsx`'s `case "html"` returns `[]`; the inline walker drops inline comments); they NEVER drive reconstruction or carry data through `read → create`. A comment can't silently corrupt the round-trip or smuggle structure back — what an agent sees is a hint, nothing more. The lossless view is `read --ast`; in-place `edit` preserves structure; the authoring verbs (`docx sections`, `docx tables …`) change it. Emit hints **deviation-only** (only what differs from the document default — even columns, default geometry, `single` borders, `align=left` emit nothing), with TWO deliberate exceptions that always emit even at their default: `docx:section` (every section gets a marker so the `sN` is always addressable) and `docx:track-changes` (states `off` too, so a weak agent never infers state from a missing hint). Run FORMATTING is the one thing that's Markdown-representable and DOES round-trip, but it rides HTML ELEMENTS (``/``/``/``, via `gatherHtmlSpans`), NOT comments. Even `` (the dominant font/size note) is a hint: `read` omits the baseline from runs and shows the note so an agent can match it, but the importer drops it — a full `read → create` rebuild falls back to the template docDefaults (Calibri 11pt) for the dominant font/size. No comment, anywhere, is parse-back.
+- **Comments are never anything but hints.** Every `` comment `read` writes into Markdown — locators (``) and the structural visibility annotations (`docx:section` — rendered at the section's START with an `applies-to="pX..pY (below)"` scope on deviating sections so the "which side of the boundary?" off-by-one is explicit, `docx:page`, `docx:table` (widths/borders + the `tables format` properties align/style/cell-margins/repeat-header/row-heights), `docx:cell` merge/shading/vAlign/halign/borders/margins, `docx:track-changes on|off` ALWAYS at the head (the one hint that states its default too — weak agents can't reliably read "no hint" as "off," and a wrong tracking guess is high-cost, so it's stated outright), `docx:header`/`docx:footer` for page headers/footers (content in a `text` attr, fields as `{page}`/`{date}`/… tokens — authored via `docx headers`/`docx footers`; a UNIFORM marginal rides the head, a marginal that DIFFERS by section renders at that section's START, next to the content it governs), `docx:textbox tbxN anchor="pM"` … `docx:textbox-end tbxN` bracketing a text box's story, printed right after the paragraph that anchors it (the story's own paragraphs carry bare `tbxN:pK` locators), `docx:layout` flagging tab-aligned content that wraps in render — inside a multi-column section, OR a line whose trailing content rides a right-edge LEFT tab so a long value overflows the margin (the résumé `San`/`Francisco` split); the latter is also consolidated into ONE top-of-read `fix-all` summary naming the single-call cure `edit --at pN-pM --tabs right` (a render-only break Markdown can't show), and future image/style hints, all via `formatNote` in `cli/read/annotations.ts`) — is a READ-TIME VISIBILITY HINT. **Naming rule: a BARE comment is a locator (an address you pass to `--at`: ``, ``; a plain empty cell uses `` so it can be filled directly); a `docx:TYPE` comment is docx-cli METADATA** (structure/formatting). Metadata never rides a bare locator — it's its own `docx:` annotation, which may carry the relevant locator as a bare leading token (``). So `docx:` is greppable as "everything docx-cli added beyond addressing," and locators stay short. The Markdown importer DROPS them all (`walker.tsx`'s `case "html"` returns `[]`; the inline walker drops inline comments); they NEVER drive reconstruction or carry data through `read → create`. A comment can't silently corrupt the round-trip or smuggle structure back — what an agent sees is a hint, nothing more. The lossless view is `read --ast`; in-place `edit` preserves structure; the authoring verbs (`docx sections`, `docx tables …`) change it. Emit hints **deviation-only** (only what differs from the document default — even columns, default geometry, `single` borders, `align=left` emit nothing), with TWO deliberate exceptions that always emit even at their default: `docx:section` (every section gets a marker so the `sN` is always addressable) and `docx:track-changes` (states `off` too, so a weak agent never infers state from a missing hint). Run FORMATTING is the one thing that's Markdown-representable and DOES round-trip, but it rides HTML ELEMENTS (``/``/``/``, via `gatherHtmlSpans`), NOT comments. Even `` (the dominant font/size note) is a hint: `read` omits the baseline from runs and shows the note so an agent can match it, but the importer drops it — a full `read → create` rebuild falls back to the template docDefaults (Calibri 11pt) for the dominant font/size. No comment, anywhere, is parse-back.
- **Everything we parse MUST be represented in the AST**. Markdown is always either a subset or equal to what is in the AST, never less than it. `RUN_BEARING_WRAPPER_TAGS` in `src/core/parser/run-ops.ts` is the AST↔XML offset bridge; every offset-aware walker reads it. See [src/core](src/core/CLAUDE.md).
- **`` is transparent at EVERY level, and text boxes are first-class.** ECMA-376 Part 3 lets a producer wrap a block, a paragraph's runs, or a run's children in a markup-compatibility wrapper and nest wrappers inside branches; Word writes every modern shape/text box that way (`wps` under ``, VML under ``, SAME story in both). Every walker resolves it through `alternateContentBranch` in `src/core/mc.ts` (first Choice, else Fallback) — the reader at block/paragraph/run level, the offset walkers via `wrapperContent(node)` in `parser/run-ops.ts` (NEVER `node.children` for a wrapper — that is what keeps a multi-Choice wrapper counted exactly once), and the text-box collector while descending a shape. Dropping the wrapper was issue #4: `replace --all` reported 2 and exited 0 while a third "Acme" sat in a text box a human reads first. A `` story is a block container like a cell (`TextBoxRun` on the anchor paragraph, `tbxN:pK` ids, `blockReferences` parents inside the Choice copy), so `find`/`replace`/`edit`/`insert`/`delete`/`comments` reach it by default; `Document.save` (and the raw gate, BEFORE validating) mirrors each Choice story onto its Fallback twin (`syncTextBoxFallbacks`, post-order so nested boxes sync inner-first) — verified against Word for Mac 365: Word regenerates a box's Fallback when IT edits the box and leaves an untouched box's Fallback verbatim (a stale one stays stale forever), so mirroring on our writes is exactly its behavior. `wc` counts what `read` shows — text boxes included, everywhere. **Word discards comments and footnotes/endnotes inside a text box on its next save** (verified: the markers vanish from the story and the comment from comments.xml, regardless of the Fallback), so `comments add` (`--at` AND `--anchor`) and `footnotes`/`endnotes add` REFUSE a `tbxN` target with `UNSUPPORTED` + a pointer to the anchor paragraph; tracked changes and hyperlinks inside a box survive Word and are allowed. The box rides its anchor RUN: EVERY whole-paragraph `edit` lifts object-bearing runs (drawing/pict/object/AlternateContent) out and puts them back — `--text` around the token diff (`extractObjectRuns`), `--markdown`/`--runs` around the node replace (`liftObjectRuns` → `reattachObjectRuns` in `track-changes/replace.tsx`, untracked carriers even under tracking; a `--markdown` that re-states an image via `` drops the lifted DRAWING so `img0` isn't duplicated, while a text box always returns), a `replace` on the anchor run's text keeps a trailing zero-width shape (`sliceRun`), and a tracked delete of the anchor wraps the run in `` WITHOUT rewriting the story's `` (`TextBoxRun.trackedChange` hides it in the accepted view; `iterateBlocks({ view })`). An UNTRACKED removal of the anchor paragraph (`delete --at pN`, `edit --at pN --text ""`) re-anchors the box on the neighboring paragraph — previous, else next — via `relocateObjectRuns` in `track-changes/replace.tsx`; deleting the invoice's Notes line used to take the stamp box with it, silently.
- **Every element must have stable positional ids for locators** (`p0`, `t0`, `c0`, `img0`, `link0`, `tc0`, `hdr0`/`ftr0` for page headers/footers, `tbx0` for a text box — its story chains like a cell: `tbx0:p1`, `tbx0:t0:r0c0:p0`). Block ids shift after structural edits. Re-read between non-trivial mutations. To not force agents to re-read on every modification, we create batch versions of any editing call such that the already made locators can be used.
diff --git a/REFERENCE.md b/REFERENCE.md
index 04ba017..7bf17bb 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -296,7 +296,15 @@ docx tables unmerge FILE --at tN:rRcC
docx tables borders FILE --at tN [--style single|double|none] [--size N] [--color HEX]
docx tables format FILE --at LOCATOR [--shade HEX|NAME] [--valign top|center|bottom]
[--halign left|center|right|justify] [--cell-borders SIDES]
+ [--cell-margins M|T,R,B,L|none]
[--align left|center|right] [--style ID] [--row-height M] [--repeat-header]
+ # --cell-margins with --at tN sets the TABLE default cell margins
+ # () and clears cells' own; a row/column/cell/range
+ # sets those cells' own.
+ # Units: in, cm, mm, pt, tw. `-` leaves a side inheriting
+ # (-,4pt,-,4pt). read shows only padding that differs from
+ # what's inherited; `docx styles FILE --at STYLE` shows a
+ # table style's own cell margins.
docx lists set FILE --at pN [--start N] [--format FMT] [--restart] [--continue]
# renumber a NUMBERED list (--at = any item; applies to the whole list).
diff --git a/src/cli/CLAUDE.md b/src/cli/CLAUDE.md
index 1a3920a..c410e77 100644
--- a/src/cli/CLAUDE.md
+++ b/src/cli/CLAUDE.md
@@ -85,9 +85,10 @@ it), emitted **deviation-only** (only what differs from the document default).
Today: `docx:section` (rendered at the section's START, with an
`applies-to="pX..pY (below)"` scope on deviating sections), `docx:page`,
`docx:table` (`widths`/`borders` plus the formatting `tables format` authors —
-`align`/`style`, and the row-level `repeat-header`/`row-heights` that have no GFM
-row slot so they ride the table note keyed by `rR`), per-cell `docx:cell` hints
-(`gridSpan`/`vMerge`/`shading`/`vAlign`/`borders`, plus a `halign` derived from a
+`align`/`style`/`cell-margins`, and the row-level `repeat-header`/`row-heights` that have no GFM
+row slot so they ride the table note keyed by `rR`; a NESTED table's note leads
+inline inside its outer cell, keyed by the nested id `tN:rRcC:tM`), per-cell `docx:cell` hints
+(`gridSpan`/`vMerge`/`shading`/`vAlign`/`borders`/`margins`, plus a `halign` derived from a
uniform non-default cell-paragraph alignment — cell text alignment is otherwise
invisible in a GFM cell). A PLAIN EMPTY cell emits exactly one bare ``
handle so `edit --at CELL` / `insert --at CELL` can fill it; an empty cell WITH
diff --git a/src/cli/info/schema.ts b/src/cli/info/schema.ts
index e7fc3bd..c7fc9d3 100644
--- a/src/cli/info/schema.ts
+++ b/src/cli/info/schema.ts
@@ -321,6 +321,11 @@ const JSON_SCHEMA = {
borders: { type: "string" },
style: { type: "string" },
align: { enum: ["left", "center", "right"] },
+ cellMargins: { $ref: "#/$defs/CellMargins" },
+ styleCellMargins: {
+ $ref: "#/$defs/CellMargins",
+ description: "Padding inherited from the table style chain",
+ },
rawXml: { const: true },
contentControl: { $ref: "#/$defs/ContentControl" },
rows: {
@@ -343,6 +348,7 @@ const JSON_SCHEMA = {
shading: { type: "string" },
vAlign: { enum: ["top", "center", "bottom"] },
borders: { type: "string" },
+ margins: { $ref: "#/$defs/CellMargins" },
trackedChange: {
$ref: "#/$defs/TableRevision",
description: "cellIns / cellDel (tracked column change)",
@@ -388,6 +394,16 @@ const JSON_SCHEMA = {
unit: { enum: ["dxa", "pct", "auto", "nil"] },
},
},
+ CellMargins: {
+ type: "object",
+ description: "Cell padding per side in twips; an absent side inherits",
+ properties: {
+ top: { type: "number" },
+ right: { type: "number" },
+ bottom: { type: "number" },
+ left: { type: "number" },
+ },
+ },
ContentControl: {
type: "object",
properties: {
diff --git a/src/cli/parse-helpers.ts b/src/cli/parse-helpers.ts
index e6cba0d..78a1b4d 100644
--- a/src/cli/parse-helpers.ts
+++ b/src/cli/parse-helpers.ts
@@ -1222,6 +1222,23 @@ function parseMeasure(
);
}
+/** Parse an unsigned measure whose unit is REQUIRED (`0.4in`, `28pt`, `1cm`,
+ * `5mm`, `108tw`) into twips, or null. A bare number is rejected rather than
+ * read in a default unit — `--row-height 28` must not become a 28-INCH row,
+ * and `--cell-margins 108` (the twips `read --ast` reports) must not become
+ * 108pt of padding. A bare `0` is the one exception: zero in any unit. Same
+ * unit table as the spacing/indent flags (`MEASURE_UNIT_TWIPS`). */
+export function parseUnitMeasure(raw: string): number | null {
+ const trimmed = raw.trim();
+ if (/^0*\.?0+$/.test(trimmed)) return 0;
+ const match = trimmed.match(/^(\d*\.?\d+)\s*("|[a-z]+)$/i);
+ const unit = match?.[2]?.toLowerCase();
+ if (!match || unit === undefined || !Object.hasOwn(MEASURE_UNIT_TWIPS, unit))
+ return null;
+ const value = Number.parseFloat(match[1] as string);
+ return Math.round(value * (MEASURE_UNIT_TWIPS[unit] as number));
+}
+
/** Twips per unit for an explicit suffix on a measure flag. `tw`/`twips`/`dxa`
* are the raw OOXML unit — what `read --ast` reports, so an agent can feed an
* AST value straight back with its unit named. */
diff --git a/src/cli/read/annotations.ts b/src/cli/read/annotations.ts
index f81670f..04d0de4 100644
--- a/src/cli/read/annotations.ts
+++ b/src/cli/read/annotations.ts
@@ -1,3 +1,5 @@
+import { CELL_MARGIN_SIDES, type CellMargins } from "@core";
+
/** Shared HTML-comment annotation convention for `read --markdown`.
*
* `read` surfaces structural document facts the GFM body can't show as HTML
@@ -88,6 +90,40 @@ export function twipsToPoints(twips: number): string {
return `${Number.parseFloat((twips / 20).toFixed(2))}pt`;
}
+/** Cell padding in points, shaped like the `--cell-margins` flag so an agent can
+ * re-apply it verbatim: one value when all four sides agree (`0.75pt`), else
+ * top,right,bottom,left (`0pt,5.4pt,0pt,5.4pt`), with `-` for a side that isn't
+ * set and so inherits (`-,5.4pt,-,5.4pt`). */
+export function formatCellMargins(margins: CellMargins): string {
+ const sides = CELL_MARGIN_SIDES.map((side) => {
+ const twips = margins[side];
+ return twips === undefined ? "-" : twipsToPoints(twips);
+ });
+ return sides.every((side) => side === sides[0])
+ ? (sides[0] as string)
+ : sides.join(",");
+}
+
+/** The sides of `margins` that actually change the padding `inherited` already
+ * gives (the table style's for a table, the table's effective padding for a
+ * cell; an undeclared inherited side is 0, which is what Word renders), or
+ * undefined when none does. `read`'s margin hints are deviation-only through
+ * this: LibreOffice stamps `0,108,0,108` on every table and Word copies the
+ * table default onto cells, and restating the default is noise. */
+export function deviatingCellMargins(
+ margins: CellMargins | undefined,
+ inherited: CellMargins = {},
+): CellMargins | undefined {
+ if (!margins) return undefined;
+ const deviating: CellMargins = {};
+ for (const side of CELL_MARGIN_SIDES) {
+ const twips = margins[side];
+ if (twips !== undefined && twips !== (inherited[side] ?? 0))
+ deviating[side] = twips;
+ }
+ return Object.keys(deviating).length > 0 ? deviating : undefined;
+}
+
/** Format an EMU count (914400 per inch) as inches, trimming trailing zeros.
* Image extents (``) are in EMU; `read --ast` carries the raw EMU. */
export function emuToInches(emu: number): string {
diff --git a/src/cli/read/markdown.ts b/src/cli/read/markdown.ts
index a5b8f80..5d54350 100644
--- a/src/cli/read/markdown.ts
+++ b/src/cli/read/markdown.ts
@@ -1,6 +1,8 @@
import {
type Block,
type Body,
+ CELL_MARGIN_SIDES,
+ type CellMargins,
type Comment,
type ContentControl,
type Footnote,
@@ -38,7 +40,9 @@ import {
type RevisionView,
} from "@core/track-changes/revision-visibility";
import {
+ deviatingCellMargins,
emuToInches,
+ formatCellMargins,
formatNote,
htmlAttr,
type NotePair,
@@ -1998,13 +2002,19 @@ function renderTable(table: Table, ctx: RenderContext): string | null {
const colCount = Math.max(...rows.map((row) => row.cells.length));
if (colCount === 0) return null;
const gridWidth = logicalColumnCount(rows, table.grid);
+ const margins = tableMarginHints(table);
const renderedRows = rows.map((row) => {
const cells: string[] = [];
for (let columnIndex = 0; columnIndex < colCount; columnIndex++) {
const cell = row.cells[columnIndex];
cells.push(
cell
- ? renderCell(cell, ctx, isBareCellTarget(row, cell, gridWidth))
+ ? renderCell(
+ cell,
+ ctx,
+ isBareCellTarget(row, cell, gridWidth),
+ margins.cellBaseline,
+ )
: "",
);
}
@@ -2023,13 +2033,16 @@ function renderTable(table: Table, ctx: RenderContext): string | null {
// A leading own-line note carries the visual structure GFM can't: uneven
// column widths and table borders (deviation-only — even/borderless tables
// stay clean). A DROPPED read-time hint; the importer ignores it.
- const note = formatTableNote(table);
+ const note = formatTableNote(table, margins.table);
return note ? `${note}\n\n${body}` : body;
}
/** The `` note, or "" when the
* table has nothing non-default to declare (even columns, no explicit borders). */
-function formatTableNote(table: Table): string {
+function formatTableNote(
+ table: Table,
+ cellMargins: CellMargins | undefined,
+): string {
const pairs: NotePair[] = [];
const widths = unevenWidths(table.grid);
if (widths) pairs.push(["widths", widths]);
@@ -2041,6 +2054,7 @@ function formatTableNote(table: Table): string {
}
if (table.align && table.align !== "left") pairs.push(["align", table.align]);
if (table.style) pairs.push(["style", table.style]);
+ if (cellMargins) pairs.push(["cell-margins", formatCellMargins(cellMargins)]);
// Rows have no GFM comment slot, so their formatting facts ride the table note,
// keyed by row index (`r0`) so an agent can re-target with `--at tN:rR`.
const repeatHeaderRows = table.rows
@@ -2134,6 +2148,7 @@ function renderCell(
cell: TableCell,
ctx: RenderContext,
bareTargetable: boolean,
+ marginBaseline: CellMargins | undefined,
): string {
const parts: string[] = [];
// A cell renders as ONE line, its paragraphs joined by `
`, so a pair can
@@ -2182,7 +2197,7 @@ function renderCell(
// The note TRAILS the cell, after the content and per-paragraph locators, so
// an agent reads the human-facing value first and then its cell-level metadata.
// It stays SEPARATE from those bare locators, not merged into one comment.
- const note = cellNote(cell);
+ const note = cellNote(cell, marginBaseline);
if (body.length === 0) {
const soleBlock = cell.blocks[0];
const soleParagraph =
@@ -2216,7 +2231,10 @@ function renderCell(
/** The `` annotation for a cell with merge/shading, or "" for a plain cell. Carries
* the cell's address (the first paragraph's locator minus its `:pN`). */
-function cellNote(cell: TableCell): string {
+function cellNote(
+ cell: TableCell,
+ marginBaseline: CellMargins | undefined,
+): string {
const pairs: NotePair[] = [];
if (cell.gridSpan && cell.gridSpan > 1)
pairs.push(["gridSpan", cell.gridSpan]);
@@ -2225,6 +2243,11 @@ function cellNote(cell: TableCell): string {
// vAlign/borders read deviation-only (top + no-borders suppressed at read).
if (cell.vAlign) pairs.push(["vAlign", cell.vAlign]);
if (cell.borders) pairs.push(["borders", cell.borders]);
+ // Deviation-only against the table's effective padding (`tableMarginHints`);
+ // no baseline = the padding folded onto the table note.
+ const margins =
+ marginBaseline && deviatingCellMargins(cell.margins, marginBaseline);
+ if (margins) pairs.push(["margins", formatCellMargins(margins)]);
// Horizontal alignment is a paragraph property, but a GFM cell can't show it
// per-cell, so surface a UNIFORM non-default cell alignment here (the inverse
// of `tables format --halign`). Cell paragraphs otherwise carry only a bare
@@ -2253,6 +2276,55 @@ function uniformCellAlignment(cell: TableCell): string | undefined {
return aligns.every((align) => align === first) ? first : undefined;
}
+/** Where a table's padding reads. When every cell ends up with the SAME padding
+ * (style chain ← `` ← its own ``) — Word stamps a
+ * template's padding onto every cell — it folds into ONE `cell-margins` on the
+ * table note and the cells say nothing (`cellBaseline` undefined); re-applying
+ * it with `--at tN` reproduces that, since a whole-table set clears the cells'
+ * overrides. Otherwise the table note carries the table default and each cell
+ * only what differs from it. Both deviation-only against the style chain. */
+function tableMarginHints(table: Table): {
+ table: CellMargins | undefined;
+ cellBaseline: CellMargins | undefined;
+} {
+ const tableDefault = { ...table.styleCellMargins, ...table.cellMargins };
+ const effective = table.rows.flatMap((row) =>
+ row.cells.map((cell) => ({ ...tableDefault, ...cell.margins })),
+ );
+ const [first] = effective;
+ if (first && effective.every((margins) => sameCellMargins(margins, first)))
+ return { table: foldedTableMargins(table, first), cellBaseline: undefined };
+ return {
+ table: deviatingCellMargins(table.cellMargins, table.styleCellMargins),
+ cellBaseline: tableDefault,
+ };
+}
+
+/** The folded `cell-margins` value: every side that differs from the style,
+ * PLUS any side where the table's own `` disagrees with what the
+ * cells render (their overrides restate the style there). A `-` means "keep",
+ * so without the second rule re-applying via `--at tN` would keep that stale
+ * table side while clearing the cells, and render differently. */
+function foldedTableMargins(
+ table: Table,
+ effective: CellMargins,
+): CellMargins | undefined {
+ const folded = deviatingCellMargins(effective, table.styleCellMargins) ?? {};
+ for (const side of CELL_MARGIN_SIDES) {
+ const own = table.cellMargins?.[side];
+ if (own !== undefined && own !== (effective[side] ?? 0))
+ folded[side] = effective[side] ?? 0;
+ }
+ return Object.keys(folded).length > 0 ? folded : undefined;
+}
+
+/** Same rendered padding on every side (an undeclared side is 0). */
+function sameCellMargins(margins: CellMargins, other: CellMargins): boolean {
+ return CELL_MARGIN_SIDES.every(
+ (side) => (margins[side] ?? 0) === (other[side] ?? 0),
+ );
+}
+
/** A cell's locator address (`t0:r0c0`), derived from a block locator inside it
* by dropping the trailing positional segment. A cell's first block is a
* paragraph (`t0:r0c0:p0` → `t0:r0c0`) or a nested table (`t0:r0c0:t0` →
@@ -2262,16 +2334,29 @@ function cellAddress(blockId: string): string {
return blockId.replace(/:(?:p|t)\d+$/, "");
}
+/** A table inside a cell, flattened onto the cell's one GFM line (cells ` / `,
+ * rows ` // `). Its `docx:table` note leads INLINE — there's no own line to put
+ * it on inside a cell — so widths/borders/align/style/cell-margins/row facts
+ * stay visible and addressable by the nested id (`t0:r0c0:t0`) like a
+ * top-level table's. */
function renderNestedTable(table: Table, ctx: RenderContext): string {
const gridWidth = logicalColumnCount(table.rows, table.grid);
+ const margins = tableMarginHints(table);
const rows = table.rows.map((row) =>
row.cells
.map((cell) =>
- renderCell(cell, ctx, isBareCellTarget(row, cell, gridWidth)),
+ renderCell(
+ cell,
+ ctx,
+ isBareCellTarget(row, cell, gridWidth),
+ margins.cellBaseline,
+ ),
)
.join(" / "),
);
- return rows.join(" // ");
+ const body = rows.join(" // ");
+ const note = formatTableNote(table, margins.table);
+ return note ? `${note} ${body}` : body;
}
function escapeCell(text: string): string {
diff --git a/src/cli/styles/read.ts b/src/cli/styles/read.ts
index 4b3fe54..389468b 100644
--- a/src/cli/styles/read.ts
+++ b/src/cli/styles/read.ts
@@ -1,6 +1,12 @@
-import { type Block, baselineCatalog, iterateBlocks } from "@core";
+import {
+ type Block,
+ baselineCatalog,
+ iterateBlocks,
+ readCellMargins,
+} from "@core";
import { alignmentFromJc } from "@core/blocks";
import type { XmlNode } from "@core/parser";
+import { formatCellMargins } from "../read/annotations";
import {
EXIT,
fail,
@@ -130,6 +136,11 @@ type StyleDetail = StyleMeta & {
indentRightIn?: number;
firstLineIn?: number;
hangingIn?: number;
+ /** A table style's ``, in the `--cell-margins` shape
+ * (`0pt,5.4pt,0pt,5.4pt`). Read-only here — `read` hides a table's padding
+ * that merely restates its style's, so this is where the inherited value is
+ * visible; override it per table with `tables format --cell-margins`. */
+ cellMargins?: string;
};
function styleMeta(node: XmlNode): StyleMeta {
@@ -158,6 +169,10 @@ function describeStyle(node: XmlNode): StyleDetail {
if (rPr) describeRunProperties(rPr, detail);
const pPr = node.findChild("w:pPr");
if (pPr) describeParagraphProperties(pPr, detail);
+ const cellMargins = readCellMargins(
+ node.findChild("w:tblPr")?.findChild("w:tblCellMar"),
+ );
+ if (cellMargins) detail.cellMargins = formatCellMargins(cellMargins);
return detail;
}
@@ -317,6 +332,7 @@ function formatDetail(detail: StyleDetail): string {
detail.spaceAfterPt !== undefined ? `${detail.spaceAfterPt}pt` : undefined,
);
add("line-spacing:", detail.lineSpacing);
+ add("cell-margins:", detail.cellMargins);
add(
"indent-left:",
detail.indentLeftIn !== undefined ? `${detail.indentLeftIn}in` : undefined,
diff --git a/src/cli/tables/CLAUDE.md b/src/cli/tables/CLAUDE.md
index 083486b..5c110db 100644
--- a/src/cli/tables/CLAUDE.md
+++ b/src/cli/tables/CLAUDE.md
@@ -3,11 +3,12 @@
`docx tables ` reshapes or formats an existing table: `insert-row` /
`delete-row` / `insert-column` / `delete-column` / `set-widths` / `merge` /
`unmerge` / `borders` (restructure), plus `format` (cell/row/table-level
-formatting — shading, vertical/horizontal alignment, per-cell borders, row
-height, repeat-header, table alignment, table style). There is no `list` verb —
-`docx read --ast` already returns the full table structure (grid widths,
-`gridSpan`, `vMerge`, `vAlign`, `shading`, cell `borders`, row `height`/
-`repeatHeader`, table `align`/`style`, and tracked-change markers).
+formatting — shading, vertical/horizontal alignment, per-cell borders, cell
+margins, row height, repeat-header, table alignment, table style). There is no
+`list` verb — `docx read --ast` already returns the full table structure (grid
+widths, `gridSpan`, `vMerge`, `vAlign`, `shading`, cell `borders`/`margins`, row
+`height`/`repeatHeader`, table `align`/`style`/`cellMargins`, and tracked-change
+markers).
## `format` is locator-scoped, not flag-scoped
@@ -18,6 +19,28 @@ EVERY cell the locator covers — a cell, a `rRcC-rRcC` range, a `cC` column, a
writes per-cell `` (table-level `` isn't read back; per-cell is, via
`docx:cell shading`). `--align`/`--style` need `--at tN` (whole table);
`--row-height`/`--repeat-header` need a row (`--at tN:rR`) or `--at tN` (all rows).
+`--cell-margins` is the one flag whose XML depends on the scope: `--at tN` writes
+the table DEFAULT `` (Word's "Default cell margins" — rows
+`insert-row` adds later are covered, and it's the slot real templates use), while
+any narrower locator broadcasts a per-cell `` like the other cell props.
+So the table form is untracked + audit comment (like `--align`), the cell form a
+real ``. Unlike Word's "Default cell margins" dialog, the table
+form ALSO clears every cell's own `` (tracked per cell that had one):
+`--at tN` means every cell, and leaving overrides in place made it exit 0 with
+nothing visible. To keep an override, set the cell AFTER the table.
+The read hints are deviation-only against what the padding INHERITS, side by
+side (`deviatingCellMargins` in `cli/read/annotations.ts`): a table's against
+its style chain (`Table.styleCellMargins`, resolved by
+`StylesView.tableCellMargins` — tblStyle → basedOn → the default table style),
+a cell's against the table's effective padding. A restated side prints `-`,
+and an all-restated element prints nothing — LibreOffice stamps `0,108,0,108`
+on every table. When every cell's EFFECTIVE padding agrees (Word stamps a
+template's `` onto every cell — mnda's 42 cells), it folds into the
+one table-note `cell-margins` and no cell repeats it (`tableMarginHints` in
+`cli/read/markdown.ts`); re-applying that via `--at tN` reproduces the render,
+because of the clearing above. `-` is also a valid flag side (leave it unset),
+so every hint re-applies verbatim. The inherited value itself is visible on
+`docx styles FILE --at STYLE`.
The cell set is the merge-aware grid's PHYSICAL cells, deduped by node — a
`gridSpan` cell covered by a range/column is touched once. `--halign` is a
PARAGRAPH property (``), so it fans `Edit.paragraphProperties` over
@@ -56,15 +79,18 @@ through Word unchanged), and is why `insert-row` builds its row via `buildRow`
(consulting the row now below) rather than a flat band of empty cells.
`buildRow` also inherits each new cell's **look** from the reference (sibling)
-row's cell, in three slices — all via `@core/table`: the first ``'s
+row's cell, in four slices — all via `@core/table`: the first ``'s
`` (alignment/spacing/indent — pure properties, cloned wholesale) and the
first run's `` (font/size — without it new text falls back to docDefaults
and renders visibly off-weight from its siblings) via `cellParagraphLike`, and
-`` via `inheritCellBorders` (without it the new row has no rule
-lines while every sibling row does; both run-level gaps were flagged by
-independent judge runs on the invoice "Calibration kit" row). Cell
-``/`` are deliberately NOT inherited — striped/alternating rows
-make shading unsafe to assume; borders are uniform across data rows in practice.
+`` + `` via `inheritCellBordersAndMargins` (without the
+borders the new row has no rule lines while every sibling row does — both
+run-level gaps were flagged by independent judge runs on the invoice
+"Calibration kit" row; without the padding it renders at the table default
+beside siblings that carry their own, and breaks `read`'s folded
+`cell-margins`). Cell ``/`` are deliberately NOT inherited —
+striped/alternating rows make shading unsafe to assume; borders and padding are
+uniform across data rows in practice.
## Track-changes: native where a construct exists, audit comment otherwise
diff --git a/src/cli/tables/format.tsx b/src/cli/tables/format.tsx
index 2889f11..e3f3d67 100644
--- a/src/cli/tables/format.tsx
+++ b/src/cli/tables/format.tsx
@@ -1,4 +1,4 @@
-import { type Document, Edit, TrackChanges } from "@core";
+import { type CellMargins, type Document, Edit, TrackChanges } from "@core";
import type { ParagraphOptions } from "@core/blocks";
import {
parseCellAt,
@@ -19,14 +19,16 @@ import {
type GridRow,
resolveTableNode,
setCellBorders,
+ setCellMargins,
setCellShading,
setCellVAlign,
setRepeatHeader,
setRowHeight,
+ setTableCellMargins,
setTableJustification,
setTableStyle,
} from "@core/table";
-import { normalizeHexColor } from "../parse-helpers";
+import { normalizeHexColor, parseUnitMeasure } from "../parse-helpers";
import {
EXIT,
fail,
@@ -53,6 +55,7 @@ Examples:
docx tables format invoice.docx --at t0:r1c0-r3c2 --cell-borders bottom
docx tables format invoice.docx --at t0 --align center --style LightGrid
docx tables format invoice.docx --at t0:r0 --repeat-header --row-height 0.4in
+ docx tables format invoice.docx --at t0 --cell-margins 0,5.4pt,0,5.4pt
The --at locator picks WHAT to format; its granularity picks which options apply.
--at t0 whole table
@@ -70,9 +73,18 @@ Cell options (any locator — broadcast to every cell it covers):
--border-style single|double|none (default: single)
--border-size EIGHTHS Thickness in eighths of a point (default: 4 = 0.5pt)
--border-color HEX|NAME (default: auto)
+ --cell-margins M|T,R,B,L|none
+ Padding inside the cell, units required (2pt, 0.05in,
+ 1mm, 108tw; 0 is allowed): one value for every side or
+ four for top,right,bottom,left, - to keep a side
+ as it is (-,4pt,-,4pt = left/right only). With --at tN
+ this sets the TABLE's default cell margins (rows added
+ later get it too) and clears any cell's own, so every
+ cell gets it; a row/column/cell/range sets those cells'
+ own margins. none clears it back to the default.
Row options (--at t0:rR, or --at t0 for every row):
- --row-height MEASURE a unit is required: 0.4in or 28pt (a bare number is rejected)
+ --row-height MEASURE a unit is required: 0.4in, 28pt, 1cm (a bare number is rejected)
--height-rule atLeast|exact|auto (default: atLeast)
--repeat-header / --no-repeat-header
Repeat the row as a header atop each page the table spans
@@ -86,9 +98,9 @@ Common:
--track Record as a tracked change even if the doc toggle is off
-o, --output PATH / --dry-run / -v, --verbose / -h, --help
-Tracking: cell shading/valign/borders and --halign record as real tracked
-changes (Word can accept/reject them). Table align/style and row height/
-repeat-header can't be tracked, so under tracking they apply in place with a
+Tracking: cell shading/valign/borders/margins and --halign record as real
+tracked changes (Word can accept/reject them). Table align/style/default cell
+margins and row height/repeat-header can't be tracked, so under tracking they apply in place with a
[docx-cli] audit comment (same policy as tables borders).
Output:
@@ -109,6 +121,7 @@ export async function run(args: string[]): Promise {
"border-style": { type: "string" },
"border-size": { type: "string" },
"border-color": { type: "string" },
+ "cell-margins": { type: "string" },
align: { type: "string" },
style: { type: "string" },
"row-height": { type: "string" },
@@ -142,12 +155,12 @@ export async function run(args: string[]): Promise {
);
}
- const plan = buildPlan(parsed.values);
+ const plan = buildPlan(parsed.values, scope.kind === "table");
if (typeof plan === "string") return fail("USAGE", plan);
if (planIsEmpty(plan)) {
return fail(
"USAGE",
- "Nothing to format — pass at least one of --shade/--valign/--halign/--cell-borders/--align/--style/--row-height/--repeat-header",
+ "Nothing to format — pass at least one of --shade/--valign/--halign/--cell-borders/--cell-margins/--align/--style/--row-height/--repeat-header",
);
}
@@ -258,13 +271,20 @@ type FormatPlan = {
updates: { side: CellBorderSide; edge: BorderEdge | null }[];
clearAll: boolean;
};
+ cellMargins?: CellMargins | null;
align?: "left" | "center" | "right" | null;
style?: string | null;
+ tableCellMargins?: CellMargins | null;
+ /** Set with `tableCellMargins`: drop each cell's own `` override. */
+ clearCellMargins?: boolean;
rowHeight?: { value: number; rule: "atLeast" | "exact" | "auto" } | null;
repeatHeader?: boolean;
};
-function buildPlan(values: Record): FormatPlan | string {
+function buildPlan(
+ values: Record,
+ wholeTable: boolean,
+): FormatPlan | string {
const plan: FormatPlan = {};
const shade = values.shade as string | undefined;
@@ -297,6 +317,20 @@ function buildPlan(values: Record): FormatPlan | string {
plan.cellBorders = built;
}
+ const cellMargins = values["cell-margins"] as string | undefined;
+ if (cellMargins !== undefined) {
+ const margins = parseCellMargins(cellMargins);
+ if (typeof margins === "string") return margins;
+ // The whole table → its default (``), which rows inserted
+ // later inherit, AND every cell's own `` cleared so the value
+ // actually lands everywhere (a cell override would otherwise win, exit 0,
+ // nothing visible). Anything narrower → those cells' own ``.
+ if (wholeTable) {
+ plan.tableCellMargins = margins;
+ plan.clearCellMargins = true;
+ } else plan.cellMargins = margins;
+ }
+
const align = values.align as string | undefined;
if (align !== undefined) {
if (!TABLE_ALIGNS.has(align))
@@ -316,8 +350,8 @@ function buildPlan(values: Record): FormatPlan | string {
if (heightRule !== undefined && rowHeight === undefined)
return "--height-rule has no effect without --row-height";
if (rowHeight !== undefined) {
- const twips = parseMeasureToTwips(rowHeight);
- if (twips === null)
+ const twips = parseUnitMeasure(rowHeight);
+ if (twips === null || twips === 0)
return `--row-height must be a positive measure WITH a unit, e.g. 0.4in or 28pt (got ${rowHeight})`;
plan.rowHeight = {
value: twips,
@@ -413,9 +447,13 @@ function selectRows(grid: Grid, scope: Scope): GridRow[] {
return [];
}
-/** Cell `` properties (shade/vAlign/borders) record natively: snapshot
- * the prior tcPr BEFORE mutating, apply, then one `` per cell —
- * mirroring `set-widths`'s tracked path (the revision Word's reject reverts). */
+/** Cell `` properties (shade/vAlign/borders/margins) record natively:
+ * snapshot the prior tcPr BEFORE mutating, apply, then one `` per
+ * cell — mirroring `set-widths`'s tracked path (the revision Word's reject
+ * reverts). Clearing cells' own margins for a whole-table `--cell-margins` is
+ * part of that UNTRACKED table-default change (audit comment, like `--align`),
+ * so it lands BEFORE the snapshot: otherwise reject would restore the cells'
+ * overrides but keep the new default — a state the user never had. */
function applyCellProps(
document: Document,
cells: GridCell[],
@@ -423,13 +461,18 @@ function applyCellProps(
tracking: boolean,
author: string | undefined,
): void {
- if (
- plan.shade === undefined &&
- plan.valign === undefined &&
- plan.cellBorders === undefined
- )
- return;
+ const setsCellProps =
+ plan.shade !== undefined ||
+ plan.valign !== undefined ||
+ plan.cellBorders !== undefined ||
+ plan.cellMargins !== undefined;
+ if (!setsCellProps && !plan.clearCellMargins) return;
for (const cell of cells) {
+ const clearsMargins =
+ plan.clearCellMargins === true &&
+ cell.node.findChild("w:tcPr")?.findChild("w:tcMar") !== undefined;
+ if (clearsMargins) setCellMargins(cell.node, null);
+ if (!setsCellProps) continue;
const prior = tracking
? (cell.node.findChild("w:tcPr")?.children ?? []).map((child) =>
child.clone(),
@@ -443,6 +486,8 @@ function applyCellProps(
plan.cellBorders.updates,
plan.cellBorders.clearAll,
);
+ if (plan.cellMargins !== undefined)
+ setCellMargins(cell.node, plan.cellMargins);
if (tracking)
appendTcPrChange(
cell.node,
@@ -477,6 +522,8 @@ function applyHalign(
function applyTableProps(tableNode: XmlNode, plan: FormatPlan): void {
if (plan.align !== undefined) setTableJustification(tableNode, plan.align);
if (plan.style !== undefined) setTableStyle(tableNode, plan.style);
+ if (plan.tableCellMargins !== undefined)
+ setTableCellMargins(tableNode, plan.tableCellMargins);
}
function applyRowProps(rows: GridRow[], plan: FormatPlan): void {
@@ -509,7 +556,8 @@ function validateTargets(
plan.shade !== undefined ||
plan.valign !== undefined ||
plan.halign !== undefined ||
- plan.cellBorders !== undefined;
+ plan.cellBorders !== undefined ||
+ plan.cellMargins !== undefined;
if (hasCellFlag && cells.length === 0)
return `No cells matched ${describeScope(scope)}`;
const hasRowFlag =
@@ -535,6 +583,8 @@ function appliedList(plan: FormatPlan): string[] {
if (plan.valign !== undefined) out.push("valign");
if (plan.halign !== undefined) out.push("halign");
if (plan.cellBorders !== undefined) out.push("cell-borders");
+ if (plan.cellMargins !== undefined || plan.tableCellMargins !== undefined)
+ out.push("cell-margins");
if (plan.align !== undefined) out.push("align");
if (plan.style !== undefined) out.push("style");
if (plan.rowHeight !== undefined) out.push("row-height");
@@ -552,6 +602,12 @@ function untrackedSummary(plan: FormatPlan): string {
parts.push(
plan.style ? `table style ${plan.style}` : "table style cleared",
);
+ if (plan.tableCellMargins !== undefined)
+ parts.push(
+ plan.tableCellMargins
+ ? "table default cell margins set, cells' own cleared"
+ : "table default and cells' own cell margins cleared",
+ );
if (plan.rowHeight !== undefined) parts.push("row height set");
if (plan.repeatHeader !== undefined)
parts.push(plan.repeatHeader ? "repeat-header on" : "repeat-header off");
@@ -585,16 +641,32 @@ function colorError(flag: string, value: string): string {
.join(", ")}, …) — got ${value}`;
}
-/** Parse `0.4in` / `28pt` to twips. The unit is REQUIRED — a bare number is
- * rejected rather than guessed, so `--row-height 28` can't silently become a
- * 28-INCH row (the footgun a defaulted unit invites). Positive only. */
-function parseMeasureToTwips(raw: string): number | null {
- const match = raw.trim().match(/^(\d*\.?\d+)\s*(in|pt)$/i);
- if (!match) return null;
- const value = Number.parseFloat(match[1] as string);
- if (!Number.isFinite(value) || value <= 0) return null;
- const unit = (match[2] as string).toLowerCase();
- return Math.round(unit === "pt" ? value * 20 : value * 1440);
+/** Parse `--cell-margins`: `none`/`auto` clears (null); otherwise one measure
+ * for every side or four in CSS order (top,right,bottom,left), the shape the
+ * `docx:table cell-margins` / `docx:cell margins` read notes print. A `-` side
+ * is left exactly as it is — the shape `read` prints for a side that isn't
+ * overridden, so a note's value re-applies verbatim, and `-,4pt,-,4pt` can't
+ * wipe an existing top/bottom. A value that's ALL `-` would change nothing, so
+ * it's a usage error rather than a silent no-op (`none` is the reset). */
+function parseCellMargins(raw: string): CellMargins | null | string {
+ if (isClear(raw)) return null;
+ const parts = raw.split(",").map((part) => part.trim());
+ const twips = parts.map((part) =>
+ part === "-" ? undefined : parseUnitMeasure(part),
+ );
+ if ((twips.length !== 1 && twips.length !== 4) || twips.includes(null))
+ return `--cell-margins must be one measure WITH a unit (e.g. 2pt, 0.05in, 1mm) or four comma-separated top,right,bottom,left with - for a side that inherits (e.g. 0,5.4pt,0,5.4pt or -,4pt,-,4pt), or none — got ${raw}`;
+ const [top, right, bottom, left] = (
+ twips.length === 1 ? [twips[0], twips[0], twips[0], twips[0]] : twips
+ ) as (number | undefined)[];
+ const margins: CellMargins = {};
+ if (top !== undefined) margins.top = top;
+ if (right !== undefined) margins.right = right;
+ if (bottom !== undefined) margins.bottom = bottom;
+ if (left !== undefined) margins.left = left;
+ if (Object.keys(margins).length === 0)
+ return `--cell-margins ${raw} changes nothing — every side is - (keep as is). Give at least one side a measure, or none to clear`;
+ return margins;
}
const VALIGNS = new Set(["top", "center", "bottom"]);
diff --git a/src/cli/tables/index.ts b/src/cli/tables/index.ts
index c73019b..d2894aa 100644
--- a/src/cli/tables/index.ts
+++ b/src/cli/tables/index.ts
@@ -33,7 +33,8 @@ Verbs:
borders Set table borders (--at tN [--style] [--size] [--color])
format Shade/align/border/size cells, rows, or the table
(--at LOCATOR [--shade] [--valign] [--halign] [--cell-borders]
- [--align] [--style] [--row-height] [--repeat-header])
+ [--cell-margins] [--align] [--style] [--row-height]
+ [--repeat-header])
"create" mints a fresh table; the other verbs restructure an existing one. The
rest of the table lifecycle uses the standard verbs:
diff --git a/src/cli/tables/insert-row.tsx b/src/cli/tables/insert-row.tsx
index 8be0996..6e6bdd6 100644
--- a/src/cli/tables/insert-row.tsx
+++ b/src/cli/tables/insert-row.tsx
@@ -9,7 +9,7 @@ import {
cellParagraphLike,
type Grid,
type GridRow,
- inheritCellBorders,
+ inheritCellBordersAndMargins,
markRowTracked,
resolveTableNode,
TableCell,
@@ -59,8 +59,8 @@ Optional:
The new row mirrors the neighbor row's structure AND look: it copies each cell's
gridSpan pattern and inherits the neighbor cell's paragraph properties
-(alignment, spacing, indent), cell borders, and text font/size — so an inserted
-row lines up and matches the rows above. Cell shading is NOT copied (banded
+(alignment, spacing, indent), cell borders and padding, and text font/size — so
+an inserted row lines up and matches the rows above. Cell shading is NOT copied (banded
tables); use \`tables format --shade\` if the new row needs it.
A row inserted inside a vertical merge extends the merge through the new row
@@ -253,7 +253,7 @@ function buildRow(grid: Grid, position: number, cellTexts: string[]): XmlNode {
{cellParagraphLike(refCell?.node, cellTexts[logical] ?? "")}
);
- if (refCell) inheritCellBorders(cell, refCell.node);
+ if (refCell) inheritCellBordersAndMargins(cell, refCell.node);
cells.push(cell);
logical += 1;
col += refSpan;
diff --git a/src/core/ast/cell-margins.ts b/src/core/ast/cell-margins.ts
new file mode 100644
index 0000000..d537472
--- /dev/null
+++ b/src/core/ast/cell-margins.ts
@@ -0,0 +1,41 @@
+import type { XmlNode } from "../parser";
+import type { CellMargins } from "./types";
+
+/** Cell padding from a ``/`` element, in twips. Reads the
+ * transitional `left`/`right` and the strict `start`/`end` alike; `nil` is 0 and
+ * a percentage side (not meaningful for padding) is skipped. Undefined when the
+ * element is absent or declares no readable side. Shared by the AST reader (a
+ * table's/cell's own margins) and `StylesView` (a table style's). */
+export function readCellMargins(
+ node: XmlNode | undefined,
+): CellMargins | undefined {
+ if (!node) return undefined;
+ const margins: CellMargins = {};
+ for (const [side, tag] of CELL_MARGIN_TAGS) {
+ if (margins[side] !== undefined) continue;
+ const edge = node.findChild(tag);
+ if (!edge) continue;
+ const type = edge.getAttribute("w:type") ?? "dxa";
+ if (type === "nil") margins[side] = 0;
+ if (type !== "dxa") continue;
+ const value = Number(edge.getAttribute("w:w"));
+ if (Number.isFinite(value)) margins[side] = value;
+ }
+ return Object.keys(margins).length > 0 ? margins : undefined;
+}
+
+/** The four padded sides in CSS order (top, right, bottom, left) — the order
+ * the `--cell-margins` flag takes and the read hints print. */
+export const CELL_MARGIN_SIDES = ["top", "right", "bottom", "left"] as const;
+
+/** A margins element's children in CT_TcMar / CT_TblCellMar schema order, each
+ * paired with the side it pads: the strict `start`/`end` and the transitional
+ * `left`/`right` both pad left/right. */
+export const CELL_MARGIN_TAGS = [
+ ["top", "w:top"],
+ ["left", "w:start"],
+ ["left", "w:left"],
+ ["bottom", "w:bottom"],
+ ["right", "w:end"],
+ ["right", "w:right"],
+] as const;
diff --git a/src/core/ast/document/styles.tsx b/src/core/ast/document/styles.tsx
index 70386a7..c24b5b0 100644
--- a/src/core/ast/document/styles.tsx
+++ b/src/core/ast/document/styles.tsx
@@ -6,6 +6,8 @@ import {
import { applyRunFormatToRpr, type RunFormat } from "../../edit/set-formatting";
import { w } from "../../jsx";
import { XmlNode } from "../../parser";
+import { CELL_MARGIN_SIDES, readCellMargins } from "../cell-margins";
+import type { CellMargins } from "../types";
import type { ContentTypesView } from "./content-types";
import type { Pkg } from "./package";
import type { RelationshipsView } from "./relationships";
@@ -121,6 +123,50 @@ export class StylesView {
.find((child) => child.getAttribute("w:styleId") === styleId);
}
+ /** The cell padding a table inherits from its style chain: the referenced
+ * table style and its `basedOn` ancestors — or, ONLY when the table names no
+ * style (or one that doesn't exist), the document's default table style
+ * (`w:default="1"`, usually "Normal Table"). Verified in Word: a named style
+ * that isn't based on Normal Table does NOT pick up its padding. The nearest
+ * `` side wins. Undefined when no style declares any. */
+ tableCellMargins(styleId: string | undefined): CellMargins | undefined {
+ const margins: CellMargins = {};
+ const named = styleId ? this.getStyle(styleId) : undefined;
+ for (const style of this.basedOnChain(named ?? this.defaultTableStyle())) {
+ const own = readCellMargins(
+ style.findChild("w:tblPr")?.findChild("w:tblCellMar"),
+ );
+ for (const side of CELL_MARGIN_SIDES) {
+ if (margins[side] === undefined && own?.[side] !== undefined)
+ margins[side] = own[side];
+ }
+ }
+ return Object.keys(margins).length > 0 ? margins : undefined;
+ }
+
+ /** `style` and its `basedOn` ancestors, nearest first; stops on a cycle. */
+ private basedOnChain(style: XmlNode | undefined): XmlNode[] {
+ const chain: XmlNode[] = [];
+ let current = style;
+ while (current && !chain.includes(current)) {
+ chain.push(current);
+ const parent = current.findChild("w:basedOn")?.getAttribute("w:val");
+ current = parent ? this.getStyle(parent) : undefined;
+ }
+ return chain;
+ }
+
+ private defaultTableStyle(): XmlNode | undefined {
+ const root = XmlNode.findRoot(this.tree, "w:styles");
+ return root
+ ?.findChildren("w:style")
+ .find(
+ (child) =>
+ child.getAttribute("w:type") === "table" &&
+ child.getAttribute("w:default") === "1",
+ );
+ }
+
/** Default run font size in half-points, from ``
* (falling back to the Normal style's run properties). `read` uses it as the
* baseline size when no stronger per-run majority emerges, so a Word doc that
diff --git a/src/core/ast/read.ts b/src/core/ast/read.ts
index 33a978e..140a191 100644
--- a/src/core/ast/read.ts
+++ b/src/core/ast/read.ts
@@ -13,6 +13,7 @@ import { XmlNode } from "../parser";
import { RAW_MARKER_ATTRIBUTE } from "../raw/namespaces";
import { readSectionProperties } from "../sections";
import { detectTaskListState } from "../task-list";
+import { readCellMargins } from "./cell-margins";
import type { Document } from "./document";
import { Body } from "./document/body";
import { numFmtToFormat } from "./document/numbering";
@@ -20,6 +21,7 @@ import { decodeSym } from "./sym";
import type {
Block,
BreakRun,
+ CellMargins,
ChartRun,
CommentAnchor,
ContentControl,
@@ -52,6 +54,10 @@ type WalkState = {
currentBlockId: string;
commentAnchors: Map;
openComments: Map;
+ /** `StylesView.tableCellMargins` per table style id (`""` = no style) — it
+ * walks the style chain, and a document's tables share a handful of styles.
+ * Per walk, so a styles.xml edit between reads can never serve a stale value. */
+ styleCellMargins: Map;
};
export function buildBody(document: Document, path: string): Body {
@@ -89,6 +95,7 @@ export function buildBody(document: Document, path: string): Body {
currentBlockId: "",
commentAnchors: new Map(),
openComments: new Map(),
+ styleCellMargins: new Map(),
};
doc.blocks = readBlocks(document, body, state);
@@ -1264,6 +1271,10 @@ function readTable(
const borders = readTableBorders(node);
const style = readTableStyle(node);
const align = readTableAlign(node);
+ const cellMargins = readCellMargins(
+ node.findChild("w:tblPr")?.findChild("w:tblCellMar"),
+ );
+ const styleCellMargins = resolveStyleCellMargins(document, style, state);
// Register table-level revisions first, in tree order (tblPr before tblGrid,
// both before the rows), so tcN ids land in document order.
readTablePropertyRevision(document, node, id, state);
@@ -1312,9 +1323,22 @@ function readTable(
if (borders) table.borders = borders;
if (style) table.style = style;
if (align) table.align = align;
+ if (cellMargins) table.cellMargins = cellMargins;
+ if (styleCellMargins) table.styleCellMargins = styleCellMargins;
return table;
}
+function resolveStyleCellMargins(
+ document: Document,
+ style: string | undefined,
+ state: WalkState,
+): CellMargins | undefined {
+ const key = style ?? "";
+ if (!state.styleCellMargins.has(key))
+ state.styleCellMargins.set(key, document.styles?.tableCellMargins(style));
+ return state.styleCellMargins.get(key);
+}
+
/** The table's on-page justification from ``
* (ST_JcTable). Only `center`/`right` surface — `left`/`start` (or absent) is
* the default, suppressed; the LTR-aware `end` reads as `right`. */
@@ -1610,6 +1634,8 @@ function readTableCell(
}
const cellBorders = readCellBorders(tcPr);
if (cellBorders) cell.borders = cellBorders;
+ const margins = readCellMargins(tcPr.findChild("w:tcMar"));
+ if (margins) cell.margins = margins;
return cell;
}
diff --git a/src/core/ast/types.ts b/src/core/ast/types.ts
index f5a9e4d..881d8ba 100644
--- a/src/core/ast/types.ts
+++ b/src/core/ast/types.ts
@@ -128,6 +128,15 @@ export type Table = {
* (`"center"` / `"right"`). Absent ⇒ the default `"left"`. Authorable via
* `docx tables format --at tN --align`. */
align?: "left" | "center" | "right";
+ /** Default cell padding from `` (Word's "Default cell
+ * margins"). Present only when the table declares it. Authorable via
+ * `docx tables format --at tN --cell-margins`. */
+ cellMargins?: CellMargins;
+ /** The padding the table's style chain supplies (`tblStyle` → `basedOn` →
+ * …, else the default table style), which `cellMargins` and each cell's
+ * `margins` override side by side. Present only when a style declares one.
+ * `read` hides a table/cell margin that merely restates it. */
+ styleCellMargins?: CellMargins;
/** The block was authored by `docx raw insert`/`replace` — see
* `Paragraph.rawXml`. Surfaces as a bare `raw` token on the `docx:table`
* note. */
@@ -187,6 +196,18 @@ export type TableCell = {
* the XML via in-place mutation. Authorable via `docx tables format --at …
* --cell-borders`. Present only when the cell declares explicit borders. */
borders?: string;
+ /** The cell's own padding from ``, overriding the table's
+ * `cellMargins`. Authorable via `docx tables format --at … --cell-margins`. */
+ margins?: CellMargins;
+};
+
+/** Cell padding per side, in twips, from ``/``. A side
+ * the element omits is absent (it inherits); `start`/`end` read as left/right. */
+export type CellMargins = {
+ top?: number;
+ right?: number;
+ bottom?: number;
+ left?: number;
};
export type TableWidth = {
diff --git a/src/core/index.ts b/src/core/index.ts
index 834ccb2..46d8b4b 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -17,6 +17,7 @@ export {
paragraphTextBaseline,
type TrackedChangeReference,
} from "./ast";
+export { CELL_MARGIN_SIDES, readCellMargins } from "./ast/cell-margins";
export {
FORMAT_TO_NUMFMT,
type ListFormat,
@@ -25,6 +26,7 @@ export { PkgError } from "./ast/document/package";
export type {
Block,
BreakRun,
+ CellMargins,
ChartRun,
Comment,
CommentAnchor,
diff --git a/src/core/table/CLAUDE.md b/src/core/table/CLAUDE.md
index e4c4372..b2f1906 100644
--- a/src/core/table/CLAUDE.md
+++ b/src/core/table/CLAUDE.md
@@ -1,6 +1,6 @@
# src/core/table — table emit, grid model, mutation primitives
-Four files behind the `@core/table` barrel ([index.tsx](index.tsx)): the file itself holds the `` / `` / `` / `` emitters; [grid.ts](grid.ts) is the pure merge-aware read-model (`buildGrid`, `cellAt`, `resolveTableNode`); [cell-content.ts](cell-content.ts) owns direct-cell block invariants (sole/empty paragraph detection, empty-paragraph reuse, start/end insertion via `applyCellInsertion`, the multi-insert `CellInsertionCursor`, `cellInsertionAnchor`, mandatory trailing paragraph repair); [mutate.tsx](mutate.tsx) is the `` / `` / `` surgery (`setGridSpan`, `setVMerge`, `setCellWidth`, `setCellShading`, `setCellVAlign`, `setCellBorders`, `setRowHeight`, `setRepeatHeader`, `setTableLayout`, `setTableJustification`, `setTableStyle`, `setTablePropertiesChild`, `emptyCell`, `gridColElement`, `appendTblGridChange`, `appendTcPrChange`, `markRowTracked`, `markCellTracked`, `clearCellContent`). The three `set*Child` splice helpers keep CT_TcPr / CT_TrPr / CT_TblPr child order.
+Four files behind the `@core/table` barrel ([index.tsx](index.tsx)): the file itself holds the `` / `` / `` / `` emitters; [grid.ts](grid.ts) is the pure merge-aware read-model (`buildGrid`, `cellAt`, `resolveTableNode`); [cell-content.ts](cell-content.ts) owns direct-cell block invariants (sole/empty paragraph detection, empty-paragraph reuse, start/end insertion via `applyCellInsertion`, the multi-insert `CellInsertionCursor`, `cellInsertionAnchor`, mandatory trailing paragraph repair); [mutate.tsx](mutate.tsx) is the `` / `` / `` surgery (`setGridSpan`, `setVMerge`, `setCellWidth`, `setCellShading`, `setCellVAlign`, `setCellBorders`, `setCellMargins`, `setRowHeight`, `setRepeatHeader`, `setTableLayout`, `setTableCellMargins`, `setTableJustification`, `setTableStyle`, `setTablePropertiesChild`, `emptyCell`, `gridColElement`, `appendTblGridChange`, `appendTcPrChange`, `markRowTracked`, `markCellTracked`, `clearCellContent`). The three `set*Child` splice helpers keep CT_TcPr / CT_TrPr / CT_TblPr child order.
`@core/table` is what `cli/tables/` (including `tables create`) builds on. The CLI verbs there are thin glue — arg-parse + merge-correctness gates — over this folder's primitives.
diff --git a/src/core/table/index.tsx b/src/core/table/index.tsx
index f25d2bf..71a76c9 100644
--- a/src/core/table/index.tsx
+++ b/src/core/table/index.tsx
@@ -32,16 +32,18 @@ export {
emptyCell,
ensureTblPr,
gridColElement,
- inheritCellBorders,
+ inheritCellBordersAndMargins,
markCellTracked,
markRowTracked,
setCellBorders,
+ setCellMargins,
setCellShading,
setCellVAlign,
setCellWidth,
setGridSpan,
setRepeatHeader,
setRowHeight,
+ setTableCellMargins,
setTableJustification,
setTableLayout,
setTablePropertiesChild,
diff --git a/src/core/table/mutate.tsx b/src/core/table/mutate.tsx
index 5697642..fbfd2ef 100644
--- a/src/core/table/mutate.tsx
+++ b/src/core/table/mutate.tsx
@@ -1,4 +1,5 @@
-import type { TableWidth } from "../ast/types";
+import { CELL_MARGIN_SIDES, CELL_MARGIN_TAGS } from "../ast/cell-margins";
+import type { CellMargins, TableWidth } from "../ast/types";
import { isInheritableRunProperty, Paragraph } from "../blocks";
import { w } from "../jsx";
import { XmlNode } from "../parser";
@@ -97,25 +98,28 @@ export function setCellVAlign(
pruneEmptyTcPr(cell, tcPr);
}
-/** Clone the reference (sibling-row) cell's `` onto a freshly
- * built cell, so an inserted row keeps the table's rule lines — without this
- * the new row renders with no separators while every sibling row has them (the
- * invoice "Calibration kit" defect, flagged by two independent judge runs).
+/** Clone the reference (sibling-row) cell's `` and ``
+ * onto a freshly built cell, so an inserted row keeps the table's rule lines and
+ * cell padding — without the borders the new row renders with no separators
+ * while every sibling row has them (the invoice "Calibration kit" defect,
+ * flagged by two independent judge runs); without the padding it renders at the
+ * table default while its siblings carry the template's own (mnda's 7.2pt rows).
* Shading and vAlign are deliberately NOT inherited: banded/striped rows make
- * those unsafe to assume, and borders are uniform across data rows in
- * practice. No-op when the reference has no borders or the cell already has
- * its own. */
-export function inheritCellBorders(
+ * those unsafe to assume, while borders and padding are uniform across data rows
+ * in practice. Each part is a no-op when the reference lacks it or the cell
+ * already has its own. */
+export function inheritCellBordersAndMargins(
cell: XmlNode,
referenceCell: XmlNode,
): void {
- const referenceBorders = referenceCell
- .findChild("w:tcPr")
- ?.findChild("w:tcBorders");
- if (!referenceBorders) return;
- const tcPr = ensureTcPr(cell);
- if (tcPr.findChild("w:tcBorders")) return;
- setTcPrChild(tcPr, "w:tcBorders", referenceBorders.clone());
+ const referenceTcPr = referenceCell.findChild("w:tcPr");
+ for (const tag of ["w:tcBorders", "w:tcMar"]) {
+ const reference = referenceTcPr?.findChild(tag);
+ if (!reference) continue;
+ const tcPr = ensureTcPr(cell);
+ if (tcPr.findChild(tag)) continue;
+ setTcPrChild(tcPr, tag, reference.clone());
+ }
}
/** Build a cell paragraph that inherits the reference cell's look — the
@@ -128,7 +132,7 @@ export function inheritCellBorders(
* only properties (no content), so cloning it wholesale is safe; ``
* with no options emits no ``, so the clone becomes the paragraph's
* sole (and correctly first) ``. The borders half of the same policy is
- * `inheritCellBorders` above — a caller minting a full cell applies both. */
+ * `inheritCellBordersAndMargins` above — a caller minting a full cell applies both. */
export function cellParagraphLike(
referenceCell: XmlNode | undefined,
text: string,
@@ -239,6 +243,81 @@ export type CellBorderSide =
| "insideH"
| "insideV";
+/** Set or clear a cell's own padding via `` (overrides the
+ * table default). Only the sides `margins` names change — an absent side keeps
+ * whatever the cell already had (so `-,4pt,-,4pt` touches left/right only).
+ * `null` removes the element. */
+export function setCellMargins(
+ cell: XmlNode,
+ margins: CellMargins | null,
+): void {
+ const tcPr = ensureTcPr(cell);
+ if (!margins) setTcPrChild(tcPr, "w:tcMar", null);
+ else {
+ const existing = tcPr.findChild("w:tcMar");
+ if (existing) mergeMarginSides(existing, margins);
+ else setTcPrChild(tcPr, "w:tcMar", marginsElement("w:tcMar", margins));
+ }
+ pruneEmptyTcPr(cell, tcPr);
+}
+
+/** Set or clear the table's default cell padding via ``
+ * (Word's Table Properties → Options → Default cell margins). Like
+ * `setCellMargins`, only the named sides change; `null` removes it. */
+export function setTableCellMargins(
+ table: XmlNode,
+ margins: CellMargins | null,
+): void {
+ const existing = table.findChild("w:tblPr")?.findChild("w:tblCellMar");
+ if (margins && existing) {
+ mergeMarginSides(existing, margins);
+ return;
+ }
+ setTablePropertiesChild(
+ table,
+ "w:tblCellMar",
+ margins ? marginsElement("w:tblCellMar", margins) : null,
+ );
+}
+
+function marginsElement(tag: string, margins: CellMargins): XmlNode {
+ const node = XmlNode.element(tag, {});
+ mergeMarginSides(node, margins);
+ return node;
+}
+
+/** Write each side `margins` names onto a ``/`` in place:
+ * an existing child for that side (`left` OR the strict `start`, …) is updated
+ * under its own tag, a missing one is inserted in schema order as the
+ * transitional `left`/`right` Word itself writes. Sides `margins` omits are
+ * left untouched. */
+function mergeMarginSides(node: XmlNode, margins: CellMargins): void {
+ for (const side of CELL_MARGIN_SIDES) {
+ const twips = margins[side];
+ if (twips === undefined) continue;
+ const attributes = { "w:w": String(twips), "w:type": "dxa" };
+ const existing = CELL_MARGIN_TAGS.filter(([padded]) => padded === side)
+ .map(([, tag]) => node.findChild(tag))
+ .find((child) => child !== undefined);
+ if (existing) {
+ for (const [name, value] of Object.entries(attributes))
+ existing.setAttribute(name, value);
+ continue;
+ }
+ const tag = `w:${side}`;
+ const rank = schemaRank(tag);
+ const before = node.children.findIndex(
+ (child) => schemaRank(child.tag) > rank,
+ );
+ const element = XmlNode.element(tag, attributes);
+ if (before === -1) node.children.push(element);
+ else node.children.splice(before, 0, element);
+ }
+}
+
+const schemaRank = (tag: string): number =>
+ CELL_MARGIN_TAGS.findIndex(([, candidate]) => candidate === tag);
+
/** Mark a whole row as a tracked insertion/deletion via `` or
* `` (ECMA-376 §17.13.5.18 / §17.13.5.13). */
export function markRowTracked(
diff --git a/tests/cli/tables.test.ts b/tests/cli/tables.test.ts
index f8de7a8..409d6b8 100644
--- a/tests/cli/tables.test.ts
+++ b/tests/cli/tables.test.ts
@@ -1286,6 +1286,380 @@ describe("docx tables format", () => {
comments.some((comment) => comment.text.includes("[docx-cli]")),
).toBe(true);
});
+
+ test("--cell-margins on the whole table sets the table default and round-trips", async () => {
+ const docPath = await newTableDoc("fmt-margins-table", 2, 2);
+ const result = await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "0.75pt",
+ );
+ expect(result.exitCode).toBe(0);
+ const xml = await readDocumentXml(docPath);
+ expect(xml).toContain(
+ '',
+ );
+ // A table default, not a per-cell broadcast.
+ expect(xml).not.toContain("");
+ expect(await readMarkdown(docPath)).toContain('cell-margins="0.75pt"');
+ const table = await tableFromDoc(docPath);
+ expect(
+ (table as unknown as { cellMargins: Record }).cellMargins,
+ ).toEqual({ top: 15, right: 15, bottom: 15, left: 15 });
+ expect((await runCli("validate", docPath)).exitCode).toBe(0);
+ });
+
+ test("--cell-margins on a cell takes top,right,bottom,left and 0", async () => {
+ const docPath = await newTableDoc("fmt-margins-cell", 2, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0:r1c1",
+ "--cell-margins",
+ "0,5.4pt,0,0.1in",
+ );
+ const xml = await readDocumentXml(docPath);
+ expect(xml).toContain(
+ '',
+ );
+ expect(xml).not.toContain("");
+ expect(await readMarkdown(docPath)).toContain(
+ // Top/bottom 0 restate the inherited 0, so they read as `-`.
+ 'docx:cell t0:r1c1 margins="-,5.4pt,-,7.2pt"',
+ );
+ expect((await runCli("validate", docPath)).exitCode).toBe(0);
+ });
+
+ test("--cell-margins none clears the table default", async () => {
+ const docPath = await newTableDoc("fmt-margins-clear", 1, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "2pt",
+ );
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "none",
+ );
+ expect(await readDocumentXml(docPath)).not.toContain("tblCellMar");
+ expect(await readMarkdown(docPath)).not.toContain("cell-margins=");
+ });
+
+ test("reads Word's left/right-only and strict start/end margins", async () => {
+ const docPath = await newTableDoc("fmt-margins-word", 1, 2);
+ await runCli(
+ "raw",
+ "edit",
+ docPath,
+ "--at",
+ "t0",
+ "--find",
+ "",
+ "--with",
+ '',
+ );
+ expect(await readMarkdown(docPath)).toContain(
+ 'cell-margins="-,5.4pt,-,5.4pt"',
+ );
+ });
+
+ test("--cell-margins re-applies read's `-` form, leaving those sides unset", async () => {
+ const docPath = await newTableDoc("fmt-margins-dash", 1, 2);
+ const result = await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "-,4pt,-,4pt",
+ );
+ expect(result.exitCode).toBe(0);
+ expect(await readDocumentXml(docPath)).toContain(
+ '',
+ );
+ expect(await readMarkdown(docPath)).toContain('cell-margins="-,4pt,-,4pt"');
+ });
+
+ test("--cell-margins takes every measure unit (cm, mm, tw)", async () => {
+ const docPath = await newTableDoc("fmt-margins-units", 1, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0:r0c0",
+ "--cell-margins",
+ "1cm,5mm,108tw,0.1in",
+ );
+ expect(await readDocumentXml(docPath)).toContain(
+ '',
+ );
+ });
+
+ test("read hides margins that restate the inherited default", async () => {
+ // mnda's Normal Table style declares Word's 0 / 5.4pt / 0 / 5.4pt.
+ const docPath = join(tempWorkspace("fmt-margins-default"), "out.docx");
+ await Bun.write(docPath, Bun.file("tests/fixtures/mnda.docx"));
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t1",
+ "--cell-margins",
+ "0,5.4pt,0,5.4pt",
+ );
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t1:r0c0",
+ "--cell-margins",
+ "0,5.4pt,0,9pt",
+ );
+ const markdown = await readMarkdown(docPath);
+ expect(markdown).not.toMatch(/docx:table t1 [^>]*cell-margins=/);
+ // Only the one cell's one deviating side surfaces.
+ expect(markdown).toContain(
+ 'docx:cell t1:r0c0 vAlign="center" margins="-,-,-,9pt"',
+ );
+ expect(markdown).not.toMatch(/docx:cell t1:r0c1 [^>]*margins=/);
+ // The inherited value is visible on the style, and in the AST.
+ const style = await runCli("styles", docPath, "--at", "TableNormal");
+ expect(style.stdout).toContain("cell-margins: 0pt,5.4pt,0pt,5.4pt");
+ const table = await tableFromDoc(docPath);
+ expect(
+ (table as unknown as { styleCellMargins: Record })
+ .styleCellMargins,
+ ).toEqual({ top: 0, right: 108, bottom: 0, left: 108 });
+ });
+
+ test("a `-` side keeps the existing value; all `-` is a usage error", async () => {
+ const docPath = await newTableDoc("fmt-margins-keep", 1, 2);
+ const format = (value: string) =>
+ runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0:r0c0",
+ "--cell-margins",
+ value,
+ );
+ await format("6pt");
+ expect((await format("-,4pt,-,4pt")).exitCode).toBe(0);
+ expect(await readDocumentXml(docPath)).toContain(
+ '',
+ );
+ for (const value of ["-", "-,-,-,-"]) {
+ const result = await format(value);
+ expect(result.exitCode).toBe(2);
+ expect(result.stdout + result.stderr).toContain("changes nothing");
+ }
+ expect(await readMarkdown(docPath)).toContain(
+ 'docx:cell t0:r0c0 margins="6pt,4pt,6pt,4pt"',
+ );
+ });
+
+ test("a named table style not based on Normal Table doesn't inherit its padding", async () => {
+ const docPath = join(tempWorkspace("fmt-margins-custom"), "out.docx");
+ await Bun.write(docPath, Bun.file("tests/fixtures/mnda.docx"));
+ await runCli(
+ "raw",
+ "part",
+ "edit",
+ docPath,
+ "--name",
+ "word/styles.xml",
+ "--find",
+ "",
+ "--with",
+ '',
+ );
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--style",
+ "Custom",
+ );
+ // Word renders a Custom-styled table flush (verified), so an explicit
+ // 0,5.4pt,0,5.4pt default is a real deviation, not a restated one.
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "0,5.4pt,0,5.4pt",
+ );
+ expect(await readMarkdown(docPath)).toMatch(
+ /docx:table t0 [^>]*cell-margins="-,5.4pt,-,5.4pt"/,
+ );
+ });
+
+ test("whole-table --cell-margins under tracking is one untracked change + audit comment", async () => {
+ const docPath = await newTableDoc("fmt-margins-track-table", 2, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0:r0c0",
+ "--cell-margins",
+ "2pt",
+ );
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "6pt",
+ "--track",
+ );
+ // Clearing the cell's own 2pt rides the untracked table-default change, so
+ // reject can't restore the override while keeping the new default.
+ expect(await trackedKinds(docPath)).not.toContain("tcPrChange");
+ const xml = await readDocumentXml(docPath);
+ expect(xml).not.toContain("");
+ expect(xml).toContain("");
+ const comments = await runCli("comments", "list", docPath);
+ expect(JSON.stringify(comments.parsed)).toContain(
+ "table default cell margins set, cells' own cleared",
+ );
+ });
+
+ test("insert-row copies the neighbor cell's padding, so the fold survives", async () => {
+ const docPath = join(tempWorkspace("fmt-margins-insert-row"), "out.docx");
+ await Bun.write(docPath, Bun.file("tests/fixtures/mnda.docx"));
+ await runCli("tables", "insert-row", docPath, "--at", "t1");
+ const markdown = await readMarkdown(docPath);
+ expect(markdown).toContain('cell-margins="7.2pt,5.75pt,7.2pt,5.75pt"');
+ expect(markdown).not.toMatch(/docx:cell t1:[^>]* margins=/);
+ });
+
+ test("a folded value re-applies even when the table's own default disagrees", async () => {
+ // Table default 1pt left; every cell restates the style's 0 left. The
+ // cells render 0, so the fold must SAY 0 — a `-` would keep the 1pt.
+ const docPath = await newTableDoc("fmt-margins-fold-stale", 1, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "-,-,-,1pt",
+ );
+ for (const cell of ["t0:r0c0", "t0:r0c1"])
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ cell,
+ "--cell-margins",
+ "-,-,-,0",
+ );
+ const before = await readMarkdown(docPath);
+ expect(before).toMatch(/docx:table t0 [^>]*cell-margins="-,-,-,0pt"/);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "-,-,-,0pt",
+ );
+ expect(await readMarkdown(docPath)).not.toContain("cell-margins=");
+ });
+
+ test("uniform per-cell padding folds onto the table note and re-applies there", async () => {
+ // mnda's t1 stamps 7.2pt/5.75pt on every one of its 14 cells.
+ const docPath = join(tempWorkspace("fmt-margins-fold"), "out.docx");
+ await Bun.write(docPath, Bun.file("tests/fixtures/mnda.docx"));
+ const before = await readMarkdown(docPath);
+ expect(before).toContain('cell-margins="7.2pt,5.75pt,7.2pt,5.75pt"');
+ expect(before).not.toMatch(/docx:cell t1:[^>]* margins=/);
+ // A whole-table set clears the cells' overrides so it lands on every
+ // cell — it used to leave them winning (exit 0, nothing visible).
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t1",
+ "--cell-margins",
+ "0",
+ );
+ const after = await readMarkdown(docPath);
+ expect(after).toMatch(/docx:table t1 [^>]*cell-margins="-,0pt,-,0pt"/);
+ const ast = (await runCli("read", docPath, "--ast")).parsed as {
+ blocks: { id: string; rows?: { cells: { margins?: unknown }[] }[] }[];
+ };
+ const table = ast.blocks.find((block) => block.id === "t1");
+ if (!table?.rows) throw new Error("t1 missing");
+ expect(
+ table.rows.flatMap((row) => row.cells).some((cell) => cell.margins),
+ ).toBe(false);
+ });
+
+ test("rejects a unit-less or two-value --cell-margins", async () => {
+ const docPath = await newTableDoc("fmt-margins-bad", 1, 2);
+ for (const value of ["5", "1pt,2pt", "-1pt"]) {
+ const result = await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0",
+ "--cell-margins",
+ value,
+ );
+ expect(result.exitCode).toBe(2);
+ }
+ expect(await readDocumentXml(docPath)).not.toContain("tblCellMar");
+ });
+
+ test("cell --cell-margins tracks as a tcPrChange that reject restores", async () => {
+ const docPath = await newTableDoc("fmt-margins-track", 2, 2);
+ await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ "t0:r0c0",
+ "--cell-margins",
+ "3pt",
+ "--track",
+ );
+ expect(await trackedKinds(docPath)).toContain("tcPrChange");
+ await runCli("track-changes", "reject", docPath, "--at", "tc0");
+ expect(await readDocumentXml(docPath)).not.toContain("");
+ });
});
describe("docx tables — tracked changes", () => {
@@ -1917,6 +2291,41 @@ describe("tables verbs with nested locators", () => {
expect(ack.position).toBe(2);
});
+ test("a nested table's docx:table note rides inline in its cell", async () => {
+ const docPath = await makeDoc();
+ const before = await readMarkdown(docPath);
+ expect(before).not.toContain(`docx:table ${NESTED_TABLE}`);
+ for (const flags of [
+ ["--align", "center", "--cell-margins", "-,4pt,-,4pt"],
+ ["--row-height", "0.3in"],
+ ]) {
+ const result = await runCli(
+ "tables",
+ "format",
+ docPath,
+ "--at",
+ NESTED_TABLE,
+ ...flags,
+ );
+ expect(result.exitCode).toBe(0);
+ }
+ const markdown = await readMarkdown(docPath);
+ expect(markdown).toContain(
+ ``,
+ );
+ // The note lives inside the outer cell, so the outer row keeps its shape.
+ const outerRow = markdown
+ .split("\n")
+ .find((line) => line.includes(`docx:table ${NESTED_TABLE}`));
+ expect(outerRow?.startsWith("| ")).toBe(true);
+ expect(outerRow?.match(/(? line.includes(`${NESTED_TABLE}:r0c0`))
+ ?.match(/(? {
const docPath = await makeDoc();
const result = await runCli(
diff --git a/tests/fixtures/setup/tables-mutations.ts b/tests/fixtures/setup/tables-mutations.ts
index 9df2d20..1a64a2e 100644
--- a/tests/fixtures/setup/tables-mutations.ts
+++ b/tests/fixtures/setup/tables-mutations.ts
@@ -10,10 +10,11 @@ import { $ } from "bun";
* Table t0 (untracked structural edits + formatting): a 3×3 table that gets a
* horizontal merge, a vertical merge, custom percentage widths, double borders,
* and then `tables format` — header-row shading + vertical/horizontal centering
- * + repeat-header, a per-cell double bottom border, a taller last row, and the
- * whole table centered on the page. Dogfoods the format verb's //
- * paragraph ////table- XML
- * through the LibreOffice round-trip.
+ * + repeat-header, a per-cell double bottom border, a taller last row, the
+ * whole table centered on the page, and table-default + per-cell padding.
+ * Dogfoods the format verb's //paragraph //
+ * //table-// XML through
+ * the LibreOffice round-trip.
*
* Table t1 (tracked edits, left UNACCEPTED): a 2×2 table edited with
* track-changes ON so the saved file carries every table revision marker —
@@ -108,6 +109,18 @@ await cli(
);
await cli("tables", "format", out, "--at", "t0:r2", "--row-height", "0.4in");
await cli("tables", "format", out, "--at", "t0", "--align", "center");
+// Cell padding: a table default () plus one cell's own override
+// (), so the round-trip checks both slots' child order.
+await cli(
+ "tables",
+ "format",
+ out,
+ "--at",
+ "t0",
+ "--cell-margins",
+ "0,4pt,0,4pt",
+);
+await cli("tables", "format", out, "--at", "t0:r2c2", "--cell-margins", "2pt");
// t1 — tracked edits, left unaccepted so the markers persist in the fixture.
await cli(
diff --git a/tests/fixtures/tables-mutations.docx b/tests/fixtures/tables-mutations.docx
index 357a09c..466b656 100644
Binary files a/tests/fixtures/tables-mutations.docx and b/tests/fixtures/tables-mutations.docx differ