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
4 changes: 4 additions & 0 deletions .claude/skills/weak-agent-test/scenarios/resume/criteria.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ the request in `task.md`. Judge the finished document against these checks.
shows the trailing section's `marginTop`, `marginRight`, `marginBottom`, `marginLeft`
all equal 720. `docx read` shows `docx:page … margins="0.5in …"`.

- **Independent Arabic font** — the contact line contains “مرحبا”; its run has
`w:rFonts w:cs="Amiri"` without `w:cstheme`. Existing Latin font settings on
the English contact details remain unchanged. AST reports `fontComplexScript`.

## How to verify

- `docx read FILE` — confirm candidate text landed, no `[Note: ...]` survives, section
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/weak-agent-test/scenarios/resume/task.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ but **Experience** was left as plain bold centered text. Fix it in two steps:
page. This is a page setup change for the whole document, not a per-paragraph change.
- **Keep the right-aligned date tab stops and the bullet lists** exactly as they are.

## Bilingual contact detail

Add the Arabic greeting “مرحبا” at the end of the contact line. Use Amiri for
that Arabic text while retaining the existing font for the English contact details.

## What "done" looks like

Every placeholder (name, contact, school, degree, dates, position, bullets) is filled
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ These invariants are NOT SUGGESTIONS. These MUST be followed.
- Errors print `{code, error, hint?}` (no `ok`) + a nonzero exit. Dry-run previews drop `ok` too.
- **A mutation that changes NOTHING is an error, not a silent success.** Weak agents key their done/retry decision off the exit code (they react to nonzero, ignore a cheerful `replaced: 0` line), so a zero-effect mutation that exits `0` bakes in a confidently-wrong document. Any mutator with a clean "matched N things" signal must exit nonzero (`MATCH_NOT_FOUND`) when N is 0 — `replace` and `comments add --anchor` both do. **`replace --batch` is the one wrinkle: it exits nonzero if ANY entry matched nothing, but (sed-like) still SAVES the entries that DID match — so a nonzero batch means "some entries missed," NOT "nothing changed," and the applied edits are already on disk** (the error names which entries missed and how many were saved). Locator-addressed mutators (`edit`/`delete`/`tables …`) fail `BLOCK_NOT_FOUND` when the target locator can't be RESOLVED — a narrower guard, not the same one: it catches a bad address, not a resolved target that changed nothing (an `edit` whose flags already match the current state still exits `0`). Keep new mutators consistent: never `respondAck` a no-op.

**Script fonts:** `--font` keeps its legacy ASCII/high-ANSI/complex-script scope.
`--font-east-asia` and `--font-complex-script` target independent slots, remove
only the corresponding theme reference, and apply after `--font`. Both travel
through edit/replace (including batch), styles set/create, AST, `--runs`, and
Markdown span data attributes; preserve them through the write-read loop.

## Testing

```bash
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ docx validate FILE [--json] # ECMA-376 schema check, per WML part (
docx raw get FILE --at LOCATOR [--json] # a block's exact XML — the read half of the raw patch loop
docx styles FILE [--used] [--at STYLEID] [--json] # the style catalog (not in the body) — what --style NAMEs exist
docx styles --catalog [--json] # built-in styles you can apply on demand (Title, Heading1–9, Quote, …), no FILE needed
docx styles set FILE --at STYLEID [--bold --color HEX --size PT --font NAME --space-before PT --indent-left IN …] # restyle every paragraph/run that uses the style
docx styles set FILE --at STYLEID [--bold --color HEX --size PT --font NAME --font-east-asia NAME --font-complex-script NAME --space-before PT --indent-left IN …] # restyle every paragraph/run that uses the style
docx styles create FILE STYLEID [--type paragraph|character] [--name "…"] [--based-on STYLEID] [--next STYLEID] [formatting] # define a new custom style
docx render FILE [--out DIR] [--engine word|libreoffice|auto] [--dpi N] [--pages 1-N] [--format png|jpg]
docx upgrade [--to TAG] [--dry-run] # update the installed binary in place (no FILE)
Expand Down Expand Up @@ -364,6 +364,15 @@ docx delete FILE --batch drop.jsonl # { at } per line — whole blocks (
# --caps --smallcaps --superscript --subscript (edit; SET run formatting on EXISTING text —
# the inverse of --clear. Alone they format a span/paragraph/range in place; with --text they
# fill AND format. Like --clear, applied directly — not recorded as a tracked change.)
# --font-east-asia NAME sets the East-Asian-script font (<w:rFonts w:eastAsia>) — separate from
# --font because Word resolves w:eastAsia independently of w:ascii/w:hAnsi; --font alone has no
# visible effect on Chinese/Japanese/Korean text. Supported by edit, edit --batch,
# replace, replace --batch, styles set, and styles create.
# --font-complex-script NAME sets w:cs independently (e.g. Amiri for Arabic). The legacy
# --font still sets ASCII, high-ANSI, and complex-script slots; this explicit override wins
# for complex-script text when both flags are supplied, regardless of flag order.
# Supports the same commands as --font-east-asia; each clears only its matching theme reference.
# AST/--runs use fontEastAsia and fontComplexScript; Markdown preserves them as span data attributes.
# NOTE: in a single no-content call (or one --batch entry) these run-format SET flags and the
# paragraph properties (--style/--alignment/--space-*/--line-spacing/--indent-*/--first-line/
# --hanging/--tabs) can't ride together — use separate calls/entries, or add --text to set both.
Expand Down
8 changes: 8 additions & 0 deletions src/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,11 @@ importer won't reconstruct it — NO comment is parse-back, not even `docx:base`
OOXML has no `w:hyperlinkChange` / `w:drawingChange` element — Word silently bypasses tracking for hyperlink edits and image swaps. We compromise: when `<w:trackRevisions/>` is on, `hyperlinks add/replace/delete` and `images replace` each auto-emit a `[docx-cli] …` comment anchored to the affected span/run, attributed via the `--author` chain. The mutation itself stays silent (no fake `<w:ins>`/`<w:del>` — OOXML has no honest construct for it). The entry point is `new Comments(document).addAudit(anchor, { body, author, date })` (in [@core/comments](../core/comments/index.tsx)); the lower-level marker helpers `findContainingParagraph`, `findElementOffsetsInParagraph`, `addCommentMarkersAroundRun` live in [@core/comments/markers](../core/comments/markers.tsx). When track-changes is off, no comment is emitted.

`images delete` is the exception — deleting an image is honest content removal, so under tracking it wraps the drawing's run in a real `<w:del>` (accept removes it, reject restores it; the media part is kept until accept). It does **not** emit an audit comment. Replace stays audit-comment because swapping bytes has no tracked-change construct, but removal does.

## Script-specific font overrides

`--font-east-asia` and `--font-complex-script` are shared run-format flags for
edit/replace (including batch) and styles set/create. The latter overrides
`--font` for `w:cs` regardless of argv order. Style readback and AST expose
`fontEastAsia` / `fontComplexScript`; Markdown carries their explicit values
in `data-font-east-asia` / `data-font-complex-script` span attributes.
4 changes: 4 additions & 0 deletions src/cli/edit/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ const SINGLE_SHOT_FLAGS = [
"superscript",
"subscript",
"font",
"font-east-asia",
"font-complex-script",
"size",
"highlight",
"shade",
Expand Down Expand Up @@ -231,6 +233,8 @@ const SET_KEYS = [
"subscript",
"color",
"font",
"font-east-asia",
"font-complex-script",
"size",
"highlight",
"shade",
Expand Down
6 changes: 6 additions & 0 deletions src/cli/edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ parse them instead). To get FORMATTED text there are two paths:
--underline Underline (single) --strike Strikethrough
--color HEX Text color (C00000) --highlight NAME Highlighter
--shade HEX Background fill hex --font NAME Font family
--font-east-asia NAME East Asian font (CJK)
--font-complex-script NAME Complex-script font; overrides --font for Arabic/Hebrew
--size PT Font size (points) --caps All caps
--smallcaps Small caps --superscript / --subscript
e.g. \`--at pN --text "Title" --bold --color C00000\`.
Expand Down Expand Up @@ -242,6 +244,8 @@ span (pN:S-E), a whole paragraph (pN), or a range (pN-pM) — paste a span from
--underline Underline (single) --strike Strikethrough
--color HEX Text color (hex) --highlight NAME Highlighter
--shade HEX Background fill hex --font NAME Font family
--font-east-asia NAME East Asian font (CJK)
--font-complex-script NAME Complex-script font; overrides --font for Arabic/Hebrew
--size PT Font size (points) --caps All caps
--smallcaps Small caps --superscript / --subscript
--clear ATTRS Turn formatting OFF (comma list, or "all")
Expand Down Expand Up @@ -912,6 +916,8 @@ const OPTION_SPEC = {
superscript: { type: "boolean" },
subscript: { type: "boolean" },
font: { type: "string" },
"font-east-asia": { type: "string" },
"font-complex-script": { type: "string" },
size: { type: "string" },
highlight: { type: "string" },
shade: { type: "string" },
Expand Down
2 changes: 2 additions & 0 deletions src/cli/info/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ const JSON_SCHEMA = {
smallCaps: { type: "boolean" },
allCaps: { type: "boolean" },
font: { type: "string" },
fontEastAsia: { type: "string" },
fontComplexScript: { type: "string" },
sizeHalfPoints: { type: "number" },
runStyle: { type: "string" },
comments: { type: "array", items: { type: "string" } },
Expand Down
12 changes: 11 additions & 1 deletion src/cli/parse-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ const RUN_FORMAT_FLAGS = [
"subscript",
"color",
"font",
"font-east-asia",
"font-complex-script",
"size",
"highlight",
"shade",
Expand All @@ -531,12 +533,20 @@ const BOOLEAN_FORMAT_FIELDS: ReadonlyArray<
const STRING_FORMAT_FIELDS: ReadonlyArray<
readonly [
key: string,
field: "color" | "font" | "highlight" | "shade",
field:
| "color"
| "font"
| "fontEastAsia"
| "fontComplexScript"
| "highlight"
| "shade",
normalize: (value: string) => string,
]
> = [
["color", "color", normalizeHexColor],
["font", "font", (value) => value],
["font-east-asia", "fontEastAsia", (value) => value],
["font-complex-script", "fontComplexScript", (value) => value],
["highlight", "highlight", (value) => value],
["shade", "shade", normalizeHexColor],
];
Expand Down
8 changes: 8 additions & 0 deletions src/cli/read/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,8 @@ function sameDecoration(a: TextRun, b: TextRun, view: MarkdownView): boolean {
(a.highlight ?? "") === (b.highlight ?? "") &&
(a.shade ?? "") === (b.shade ?? "") &&
(a.font ?? "") === (b.font ?? "") &&
(a.fontEastAsia ?? "") === (b.fontEastAsia ?? "") &&
(a.fontComplexScript ?? "") === (b.fontComplexScript ?? "") &&
(a.sizeHalfPoints ?? 0) === (b.sizeHalfPoints ?? 0) &&
(a.vertAlign ?? "") === (b.vertAlign ?? "") &&
(a.smallCaps ?? false) === (b.smallCaps ?? false) &&
Expand Down Expand Up @@ -1745,6 +1747,8 @@ function needsHtmlWrap(run: TextRun, baseline: RunFormatBaseline): boolean {
(run.colorTheme && !isDefaultThemeColor(run)) ||
run.shade ||
(run.font && run.font !== baseline.font) ||
run.fontEastAsia ||
run.fontComplexScript ||
(run.sizeHalfPoints !== undefined &&
run.sizeHalfPoints !== baseline.sizeHalfPoints) ||
run.smallCaps ||
Expand Down Expand Up @@ -1819,6 +1823,10 @@ function spanFormattingWrapper(
): HtmlFormattingWrapper | null {
const styles: string[] = [];
const attrs: string[] = [];
if (run.fontEastAsia)
attrs.push(htmlAttr("data-font-east-asia", run.fontEastAsia));
if (run.fontComplexScript)
attrs.push(htmlAttr("data-font-complex-script", run.fontComplexScript));
// Black / "auto" is the universal default — emitting it says nothing.
if (run.color && !isDefaultColor(run.color))
styles.push(`color:#${run.color}`);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/replace/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const BATCH_ENTRY_ONLY_FLAGS = [
"subscript",
"color",
"font",
"font-east-asia",
"font-complex-script",
"size",
"highlight",
"shade",
Expand Down
4 changes: 4 additions & 0 deletions src/cli/replace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ Options:
"all"; repeatable), then apply any set flags below
--bold / --italic / --underline / --strike
--color HEX / --highlight NAME / --shade HEX / --font NAME / --size PT
--font-east-asia NAME East Asian font (CJK)
--font-complex-script NAME Complex-script font; overrides --font for Arabic/Hebrew
--caps / --smallcaps / --superscript / --subscript
format only the newly inserted replacement text; unspecified
properties still inherit from the first matched run
Expand Down Expand Up @@ -159,6 +161,8 @@ export async function run(args: string[]): Promise<number> {
subscript: { type: "boolean" },
color: { type: "string" },
font: { type: "string" },
"font-east-asia": { type: "string" },
"font-complex-script": { type: "string" },
size: { type: "string" },
highlight: { type: "string" },
shade: { type: "string" },
Expand Down
2 changes: 2 additions & 0 deletions src/cli/styles/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Run formatting:
--bold --italic --underline --strike --caps --smallcaps
--superscript | --subscript
--color HEX --font NAME --size PT --highlight NAME --shade HEX
--font-east-asia NAME East Asian font (CJK)
--font-complex-script NAME Complex-script font; overrides --font for Arabic/Hebrew

Paragraph formatting (paragraph styles only):
--alignment left|center|right|justify
Expand Down
12 changes: 11 additions & 1 deletion src/cli/styles/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ type StyleMeta = {
type StyleDetail = StyleMeta & {
next?: string;
font?: string;
fontEastAsia?: string;
/** Explicit complex-script font family (e.g. Arabic/Hebrew). */
fontComplexScript?: string;
sizePt?: number;
color?: string;
bold?: boolean;
Expand Down Expand Up @@ -158,8 +161,13 @@ function describeStyle(node: XmlNode): StyleDetail {
}

function describeRunProperties(rPr: XmlNode, detail: StyleDetail): void {
const font = rPr.findChild("w:rFonts")?.getAttribute("w:ascii");
const rFonts = rPr.findChild("w:rFonts");
const font = rFonts?.getAttribute("w:ascii");
if (font) detail.font = font;
const fontEastAsia = rFonts?.getAttribute("w:eastAsia");
if (fontEastAsia) detail.fontEastAsia = fontEastAsia;
const fontComplexScript = rFonts?.getAttribute("w:cs");
if (fontComplexScript) detail.fontComplexScript = fontComplexScript;
const sizeHalf = rPr.findChild("w:sz")?.getAttribute("w:val");
if (sizeHalf) {
const parsed = Number.parseInt(sizeHalf, 10);
Expand Down Expand Up @@ -280,6 +288,8 @@ function formatDetail(detail: StyleDetail): string {
add("basedOn:", detail.basedOn);
add("next:", detail.next);
add("font:", detail.font);
add("font-east-asia:", detail.fontEastAsia);
add("font-complex-script:", detail.fontComplexScript);
add("size:", detail.sizePt !== undefined ? `${detail.sizePt}pt` : undefined);
add("color:", detail.color);
add("bold:", detail.bold);
Expand Down
2 changes: 2 additions & 0 deletions src/cli/styles/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Run formatting (any style):
--superscript | --subscript
--color HEX Text color (e.g. 1F4E79 or #1F4E79)
--font NAME Font family (e.g. "Arial")
--font-east-asia NAME East Asian font (CJK)
--font-complex-script NAME Complex-script font; overrides --font for Arabic/Hebrew
--size PT Font size in points (e.g. 16)
--highlight NAME Highlight color (yellow, green, cyan, …)
--shade HEX Background fill behind the text
Expand Down
2 changes: 2 additions & 0 deletions src/cli/styles/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const STYLE_FORMAT_FLAGS = {
subscript: { type: "boolean" },
color: { type: "string" },
font: { type: "string" },
"font-east-asia": { type: "string" },
"font-complex-script": { type: "string" },
size: { type: "string" },
highlight: { type: "string" },
shade: { type: "string" },
Expand Down
33 changes: 28 additions & 5 deletions src/core/ast/document/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,11 +371,10 @@ export class StylesView {
}
}

/** Point a `<w:rFonts>` at `fontName` for the Latin/ASCII script: set
* `w:ascii`/`w:hAnsi`/`w:cs` and DROP any `w:asciiTheme`/`w:hAnsiTheme`/
* `w:cstheme` reference (an explicit font must beat the theme). East-Asian /
* complex-script fallbacks (`w:eastAsia`) are left alone. Shared by
* `setDefaultFont`, `overrideStyleFonts`, and the `Fonts` lens's body/note walk. */
/** Set ASCII, high-ANSI, and complex-script fonts for the legacy `--font`
* surface, clearing their theme references. East Asian settings are preserved.
* Apply script-specific overrides afterwards when both are supplied. Shared
* by style defaults, overrides, and the Fonts lens's body/note walk. */
export function applyRunFont(rFonts: XmlNode, fontName: string): void {
rFonts.setAttribute("w:ascii", fontName);
rFonts.setAttribute("w:hAnsi", fontName);
Expand All @@ -385,6 +384,30 @@ export function applyRunFont(rFonts: XmlNode, fontName: string): void {
delete rFonts.attributes["w:cstheme"];
}

/** Point a `<w:rFonts>` at `fontName` for the East-Asian script: set
* `w:eastAsia` and DROP any `w:eastAsiaTheme` reference (an explicit font
* must beat the theme, the same rule `applyRunFont` applies to the Latin
* attrs). This is the ONLY thing that changes what font CJK text renders
* in — `applyRunFont`'s `w:ascii`/`w:hAnsi`/`w:cs` have no effect on it.
* Deliberately separate from `applyRunFont` rather than folded into it:
* a Latin-primary document with incidental CJK text usually wants the
* theme's East-Asian font left alone (forcing it to the Latin font name
* can render CJK in an unrelated/unsupported font), so callers opt in via
* `--font-east-asia` explicitly instead of it riding along with `--font`. */
export function applyRunFontEastAsia(rFonts: XmlNode, fontName: string): void {
rFonts.setAttribute("w:eastAsia", fontName);
delete rFonts.attributes["w:eastAsiaTheme"];
}

/** Override only the complex-script font, preserving Latin and CJK settings. */
export function applyRunFontComplexScript(
rFonts: XmlNode,
fontName: string,
): void {
rFonts.setAttribute("w:cs", fontName);
delete rFonts.attributes["w:cstheme"];
}

/** The metadata + formatting a `styles set`/`create` applies onto a `<w:style>`.
* Each field is optional; only present fields are written. */
export type StyleSpec = {
Expand Down
6 changes: 6 additions & 0 deletions src/core/ast/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,12 @@ function applyRunProperties(run: TextRun, runProperties: XmlNode): void {
const value =
fontNode.getAttribute("w:ascii") ?? fontNode.getAttribute("w:hAnsi");
if (value) run.font = value;
// East-Asian-script font family — independent of ascii/hAnsi (Word
// resolves w:eastAsia separately for CJK glyphs).
const eastAsiaValue = fontNode.getAttribute("w:eastAsia");
if (eastAsiaValue) run.fontEastAsia = eastAsiaValue;
const complexScriptValue = fontNode.getAttribute("w:cs");
if (complexScriptValue) run.fontComplexScript = complexScriptValue;
}

const sizeNode = runProperties.findChild("w:sz");
Expand Down
6 changes: 6 additions & 0 deletions src/core/ast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ export type TextRun = {
smallCaps?: boolean;
allCaps?: boolean;
font?: string;
/** East-Asian-script font family (CJK) — `<w:rFonts w:eastAsia>`, resolved
* independently of `font` (`w:ascii`/`w:hAnsi`). Setting `font` alone has
* no visible effect on Chinese/Japanese/Korean text. */
fontEastAsia?: string;
/** Explicit complex-script font family (e.g. Arabic/Hebrew). */
fontComplexScript?: string;
sizeHalfPoints?: number;
/** Character style applied via <w:rStyle> (e.g., "Code" for inline code,
* "Hyperlink", or any user-defined style). Distinct from direct formatting
Expand Down
13 changes: 12 additions & 1 deletion src/core/blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ type EmittableTextFormatting = Pick<
| "underlineColor"
| "strike"
| "font"
| "fontEastAsia"
| "fontComplexScript"
| "sizeHalfPoints"
>;

Expand Down Expand Up @@ -618,6 +620,8 @@ function TextRunElement({ run }: { run: TextRun }): XmlNode {
const FORMATTING_KEYS = [
"runStyle",
"font",
"fontEastAsia",
"fontComplexScript",
"bold",
"italic",
"allCaps",
Expand Down Expand Up @@ -646,7 +650,14 @@ function RunProperties({ run }: { run: TextRun }): NullableXmlNode {
return (
<w.rPr>
{run.runStyle && <w.rStyle w-val={run.runStyle} />}
{run.font && <w.rFonts w-ascii={run.font} w-hAnsi={run.font} />}
{(run.font || run.fontEastAsia || run.fontComplexScript) && (
<w.rFonts
w-ascii={run.font}
w-hAnsi={run.font}
w-eastAsia={run.fontEastAsia}
w-cs={run.fontComplexScript}
/>
)}
{run.bold && <w.b />}
{run.italic && <w.i />}
{run.allCaps && <w.caps />}
Expand Down
Loading
Loading