Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
# (<w:tblCellMar>) 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).
Expand Down
7 changes: 4 additions & 3 deletions src/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- tN:rRcC -->`
handle so `edit --at CELL` / `insert --at CELL` can fill it; an empty cell WITH
Expand Down
16 changes: 16 additions & 0 deletions src/cli/info/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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)",
Expand Down Expand Up @@ -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: {
Expand Down
17 changes: 17 additions & 0 deletions src/cli/parse-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
36 changes: 36 additions & 0 deletions src/cli/read/annotations.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (`<wp:extent>`) are in EMU; `read --ast` carries the raw EMU. */
export function emuToInches(emu: number): string {
Expand Down
99 changes: 92 additions & 7 deletions src/cli/read/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
type Block,
type Body,
CELL_MARGIN_SIDES,
type CellMargins,
type Comment,
type ContentControl,
type Footnote,
Expand Down Expand Up @@ -38,7 +40,9 @@ import {
type RevisionView,
} from "@core/track-changes/revision-visibility";
import {
deviatingCellMargins,
emuToInches,
formatCellMargins,
formatNote,
htmlAttr,
type NotePair,
Expand Down Expand Up @@ -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,
)
: "",
);
}
Expand All @@ -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 `<!-- docx:table tN widths="…" borders="…" -->` 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]);
Expand All @@ -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
Expand Down Expand Up @@ -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 `<br>`, so a pair can
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -2216,7 +2231,10 @@ function renderCell(
/** The `<!-- docx:cell t0:r0c0 gridSpan="2" vMerge="continue" shading="FFE699"
* -->` 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]);
Expand All @@ -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
Expand Down Expand Up @@ -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 ← `<w:tblCellMar>` ← its own `<w:tcMar>`) — 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 `<w:tblCellMar>` 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` →
Expand All @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion src/cli/styles/read.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -130,6 +136,11 @@ type StyleDetail = StyleMeta & {
indentRightIn?: number;
firstLineIn?: number;
hangingIn?: number;
/** A table style's `<w:tblPr><w:tblCellMar>`, 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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading