diff --git a/.claude/skills/weak-agent-test/scenarios/invoice/criteria.md b/.claude/skills/weak-agent-test/scenarios/invoice/criteria.md index 40c4b4d..e140c5e 100644 --- a/.claude/skills/weak-agent-test/scenarios/invoice/criteria.md +++ b/.claude/skills/weak-agent-test/scenarios/invoice/criteria.md @@ -38,6 +38,7 @@ The agent never sees this file. It is the ground-truth definition of "correct" f - The document still contains **exactly two** embedded images (new logo + footer payment mark). - Verify via `docx images list FILE`: two entries, and the logo entry's dimensions / source match `assets/logo.svg`. - Footer payment mark is preserved: the footer image relationship is still present and unreferenced relationships were not dropped. +- **Logo resized + described (`images set`, issue #47)**: the logo's `docx:image` hint reads `size="1.25x…in"` (width within ±0.05in of 1.25in) and its `` and `` agree; the height/width ratio matches `assets/logo.svg`'s intrinsic ratio (±2%) — a stretched logo is a fail (caveat: until #46 makes `images replace` refit the frame to the new image's aspect, `--width` keeps the OLD placeholder's ratio, so a distortion inherited from the replace is a tool gap, not an agent error). `` (shown as `![Northwind Robotics logo](…)` in `docx read`). Expected path: `docx images set FILE --at imgN --width 1.25in --alt "Northwind Robotics logo"` (one call). Under track-changes this applies untracked with a `[docx-cli]` audit comment on the image — correct, not a defect. ### Stamp text box rewritten (issue #4 — text boxes are addressable) - The document has a floating text box (an `` wps shape anchored in the "Thank you for your business." Notes paragraph) whose story was the template warning `TEMPLATE — DO NOT SEND`. It must now read exactly `Thank you for your business!` and the strings `TEMPLATE` / `DO NOT SEND` must appear nowhere — `docx find FILE "DO NOT SEND"` returns no matches, `docx find FILE "Thank you for your business!"` returns a `tbx0:p0:…` locator. diff --git a/.claude/skills/weak-agent-test/scenarios/invoice/task.md b/.claude/skills/weak-agent-test/scenarios/invoice/task.md index 75790b7..877621d 100644 --- a/.claude/skills/weak-agent-test/scenarios/invoice/task.md +++ b/.claude/skills/weak-agent-test/scenarios/invoice/task.md @@ -59,8 +59,8 @@ There's a red-bordered stamp box near the bottom of the page that reads "TEMPLAT ## Logo -Replace the placeholder logo in the **top-left corner** with the new company mark at `assets/logo.svg`. Swap that one image only — don't add a second logo, and **don't touch the small payment mark in the footer** (it must stay). The document should still have two embedded images when you're done (the new logo + the footer mark). +Replace the placeholder logo in the **top-left corner** with the new company mark at `assets/logo.svg`. Swap that one image only — don't add a second logo, and **don't touch the small payment mark in the footer** (it must stay). The document should still have two embedded images when you're done (the new logo + the footer mark). Once the new logo is in, make it about **1.25 inches wide** (keep its proportions — no stretching) and give it the alt text **Northwind Robotics logo** so screen readers announce it. ## What done looks like -All `Item 1/2/3`, `$0.00`, `Customer name`, `Your Company Name`, and similar placeholder text is replaced with the values above. Before finishing, do one consolidated sweep for every placeholder family named here — item labels, description lines, customer/company names, address prompts, and zero-dollar template amounts — rather than checking just one kind and assuming the rest are gone. Leftover placeholder lines (the extra address line, the per-item sub-lines) are removed, not left dangling. The three tables are still present and unbroken. The line-items table has four filled rows, the Description column is the widest, and no dollar value wraps to a second line. Its header row has a light grey fill, the Price and Amount columns are right-aligned, and the header is set to repeat on a second page. The top-left logo is the new mark from `assets/logo.svg`, and the footer's payment mark is untouched. The stamp box near the bottom still exists and now reads "Thank you for your business!" — no "TEMPLATE" or "DO NOT SEND" wording anywhere in the document. +All `Item 1/2/3`, `$0.00`, `Customer name`, `Your Company Name`, and similar placeholder text is replaced with the values above. Before finishing, do one consolidated sweep for every placeholder family named here — item labels, description lines, customer/company names, address prompts, and zero-dollar template amounts — rather than checking just one kind and assuming the rest are gone. Leftover placeholder lines (the extra address line, the per-item sub-lines) are removed, not left dangling. The three tables are still present and unbroken. The line-items table has four filled rows, the Description column is the widest, and no dollar value wraps to a second line. Its header row has a light grey fill, the Price and Amount columns are right-aligned, and the header is set to repeat on a second page. The top-left logo is the new mark from `assets/logo.svg`, about 1.25 inches wide with its proportions intact and the alt text "Northwind Robotics logo", and the footer's payment mark is untouched. The stamp box near the bottom still exists and now reads "Thank you for your business!" — no "TEMPLATE" or "DO NOT SEND" wording anywhere in the document. diff --git a/REFERENCE.md b/REFERENCE.md index 263489d..c0fbf5e 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -294,6 +294,7 @@ docx images add FILE (--after | --before) LOCATOR --image SRC [--alt T] [--w docx images add FILE (--at-start | --at-end) --image SRC [options] # SRC = path, data: URI, or http(s) URL docx images extract FILE --to DIR [--at imgN] # --to = output directory; --at picks one image docx images replace FILE --at imgN --with ./new.png +docx images set FILE --at imgN [--width L | --height L | --size WxH | --fit] [--alt TEXT] # resize in place (one side keeps the aspect; --size = the docx:image hint verbatim; --fit shrinks to the text width, curing overflow="yes"); --batch FILE.jsonl for many docx images delete FILE --at imgN docx hyperlinks add FILE --at pN:S-E --url URL @@ -409,7 +410,7 @@ Every save of a document with comments already runs the sidecar half of this 0), then the post-fix report; it exits 1 only if errors remain. > **One rule to memorize: addressing an existing thing is always `--at`.** -> `comments reply/resolve/delete`, `footnotes/endnotes edit/delete`, `images extract/replace/delete`, `hyperlinks replace/delete`, `tables *`, `track-changes accept/reject`, `edit`, `delete`, and `insert` all take `--at LOCATOR`. For insert, `--at` means after an ordinary block and into a bare cell; use `--before`/`--after` when the side matters, or `--at-start`/`--at-end` for document boundaries. The remaining exceptions are positional by nature: `read` slices with `--from`/`--to LOCATOR`; `wc` takes a positional `[LOCATOR]`; `find`/`replace` take a positional `QUERY`/`PATTERN` (and `replace` accepts an optional `--at pN` to _confine_ the substitution to one paragraph). `images extract --to DIR` is an _output directory_, not a locator. +> `comments reply/resolve/delete`, `footnotes/endnotes edit/delete`, `images extract/replace/set/delete`, `hyperlinks replace/delete`, `tables *`, `track-changes accept/reject`, `edit`, `delete`, and `insert` all take `--at LOCATOR`. For insert, `--at` means after an ordinary block and into a bare cell; use `--before`/`--after` when the side matters, or `--at-start`/`--at-end` for document boundaries. The remaining exceptions are positional by nature: `read` slices with `--from`/`--to LOCATOR`; `wc` takes a positional `[LOCATOR]`; `find`/`replace` take a positional `QUERY`/`PATTERN` (and `replace` accepts an optional `--at pN` to _confine_ the substitution to one paragraph). `images extract --to DIR` is an _output directory_, not a locator. ## Output contract @@ -433,7 +434,7 @@ The CLI is built for non-interactive agents. **Exit code is the success signal** | Command class | Default stdout on success | `--verbose` | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | **Mutator that mints a new handle** — `comments add`→`cN`, `comments reply`→`cN`, `footnotes/endnotes add`→`fnN`/`enN`, `hyperlinks add`→`linkN`, `insert`→the new `pN` | the bare locator(s), **one per line** (a multi-block `--markdown` insert prints several) | full `{ok:true,…}` ack | -| **Mutator with no new handle** — `edit`, `delete`, `replace`, `create`, `comments resolve/delete`, `images replace/delete`, `hyperlinks replace/delete`, `footnotes/endnotes edit/delete`, `headers/footers set/clear`, `tables *`, `track-changes accept/reject` & toggle | **one-line confirmation** — ` ` (e.g. `edit t1:r0c1:p0`, `edit 7 changes`, `replace 3 occurrences replaced`) (exit `0`) | full `{ok:true,…}` ack | +| **Mutator with no new handle** — `edit`, `delete`, `replace`, `create`, `comments resolve/delete`, `images replace/set/delete`, `hyperlinks replace/delete`, `footnotes/endnotes edit/delete`, `headers/footers set/clear`, `tables *`, `track-changes accept/reject` & toggle | **one-line confirmation** — ` ` (e.g. `edit t1:r0c1:p0`, `edit 7 changes`, `replace 3 occurrences replaced`) (exit `0`) | full `{ok:true,…}` ack | | `find` | matched span locators, one per line (no matches → nothing, exit `0`) | `--json` → `{ totalMatches, query, view, matches:[…], normalizedQuery? }` | | `wc` | the bare count (whole-doc adds a tab-separated `sections` column, like `wc`) | `--json` → `{ words, scope, view, sections? }` | | `outline` | indented `LOCATOR⇥TEXT` tree (two spaces per level) | `--json` → nested `[{ id, locator, level, style, text, children }]` | @@ -453,7 +454,7 @@ Locators come in two flavors. **Positional block ids** (`pN`, `tN`, `sN`) are de | `cN` (comment) | `docx comments list FILE` | `comments reply/resolve/delete --at` | | `fnN` / `enN` (foot/endnote) | `docx footnotes list FILE` / `docx endnotes list FILE` | `footnotes/endnotes edit/delete --at` | | `hdrN` / `ftrN` (header/footer) | `docx headers list FILE` / `docx footers list FILE` (or `read --ast` / the `docx:header`/`docx:footer` note) | `raw get --at`, `headers/footers set/clear --at` (or scope by section: `--at sN --type T`) | -| `imgN` (image) | `docx images list FILE` | `images extract/replace/delete --at` | +| `imgN` (image) | `docx images list FILE` | `images extract/replace/set/delete --at` | | `linkN` (hyperlink) | `docx hyperlinks list FILE` | `hyperlinks replace/delete --at` | | `tcN` (tracked change) | `docx track-changes list FILE` | `track-changes accept/reject --at` | | `eqN` (equation) | `docx read FILE --ast` (run `latex` field) | `equations edit --at eqN` | @@ -507,7 +508,7 @@ from before, so existing calls keep working: | Flag(s) | Bare number | Notes | | -------------------------------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------- | -| `images add --width/--height` | inches | also `px` (96 dpi): `--width 300px`, `--width 5cm` | +| `images add`/`set --width/--height` (`set --size WxH`) | inches | also `px` (96 dpi): `--width 300px`, `--width 5cm` | | `tables create --widths`, `--table-width`; `set-widths --widths`; `insert-column --width` | whole twips (1440 = 1in) | a bare 1–9 is refused (2 twips is 0.001in — write `2in` or `2tw`); `--table-width`/`set-widths` also take `%` | | `--space-before/--space-after` | points | | | `--indent-*`/`--first-line`/`--hanging`, page `--margins` | inches | a bare value above 9 is twips (the `read --ast` number); indents/margins may be negative | @@ -566,7 +567,7 @@ docx track-changes apply doc.docx --accept rev0 --reject rev1 # finalize: acce **Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply. When a Word-saved file carries Word's comment sidecars (`commentsExtended.xml`, `commentsIds.xml`, `commentsExtensible.xml`), every `comments add`/`reply`/`delete` adds or removes the matching entry in each one, so Word never offers to repair the file. A file without them stays without them. -**Tracked changes.** With `` set, `insert`/`edit`/`delete`/`replace` emit native ``/`` (attributed via `--author`, `$DOCX_AUTHOR`, or `Reviewer`); pass `--track` to one of those commands (or the `tables` verbs / `images delete`) to track just that invocation even when the doc toggle is off. `edit --at pN --text` runs a word-level diff so unchanged words keep their formatting and only changed words are wrapped — the same shape Word produces mid-tracking. `accept`/`reject` handle run-level ins/del/moveFrom/moveTo, `sectPrChange`, paragraph-mark ins/del, and the table-structural revisions (rowIns/rowDel, cellIns/cellDel, tblGridChange, tcPrChange). OOXML has no tracked-change construct for hyperlink edits or image swaps, so under tracking those emit a `[docx-cli]` audit comment instead of a fake revision (image _deletion_ is honest removal — it wraps a real ``). +**Tracked changes.** With `` set, `insert`/`edit`/`delete`/`replace` emit native ``/`` (attributed via `--author`, `$DOCX_AUTHOR`, or `Reviewer`); pass `--track` to one of those commands (or the `tables` verbs / `images delete`) to track just that invocation even when the doc toggle is off. `edit --at pN --text` runs a word-level diff so unchanged words keep their formatting and only changed words are wrapped — the same shape Word produces mid-tracking. `accept`/`reject` handle run-level ins/del/moveFrom/moveTo, `sectPrChange`, paragraph-mark ins/del, and the table-structural revisions (rowIns/rowDel, cellIns/cellDel, tblGridChange, tcPrChange). OOXML has no tracked-change construct for hyperlink edits or image swaps/resizes (`images replace`/`set`), so under tracking those emit a `[docx-cli]` audit comment instead of a fake revision (image _deletion_ is honest removal — it wraps a real ``). **Text boxes and markup compatibility.** Word writes every modern shape and text box as an `` pair (a `wps` shape under ``, a legacy VML copy under ``), and the markup-compatibility spec lets that wrapper sit at any level — around a block, a paragraph's runs, or a run's children — and nest. The reader resolves the wrapper wherever it appears (first `Choice`, else `Fallback`), so nothing under it is dropped, and every offset walker descends the same branch (a wrapper with several `Choice`s counts exactly the one the reader read); a `` story surfaces as a `textBox` run whose blocks are addressable `tbxN:pK` paragraphs. Edits land in the `Choice` copy (the one Word reads) and a text box's `Fallback` twin is mirrored from it on save, exactly as Word does when it edits the box. A wrapper that is not a text box has no mirrorable story, so an edit inside it changes the `Choice` only — the same two-faced file Word leaves when it preserves a fallback it didn't regenerate. diff --git a/skills/docx-cli/references/commands.md b/skills/docx-cli/references/commands.md index a7cc076..c0bea36 100644 --- a/skills/docx-cli/references/commands.md +++ b/skills/docx-cli/references/commands.md @@ -38,7 +38,7 @@ docx info skill # this skill, regenerated from the binary | `comments` | `add` (`--at` / `--anchor PHRASE` / `--batch`), `reply`, `resolve` (`--unset`), `delete`, `list`. | | `footnotes` / `endnotes` | `add` (`--at` / `--anchor`), `edit`, `delete`, `list`. | | `headers` / `footers` | `set` / `list` / `clear` — text, page numbers, dates, fields; e.g. `footers set FILE --page-number --of-pages`. | -| `images` | `add` (`--caption` for a figure), `extract`, `replace`, `delete`, `list`. | +| `images` | `add` (`--caption` for a figure), `set` (resize / `--fit` / `--alt`), `extract`, `replace`, `delete`, `list`. | | `hyperlinks` | `add` (`--url`), `list`, `replace` (`--with`), `delete`. | | `tables` | Insert/delete rows & columns, merge/unmerge, set widths, borders, formatting. | | `lists` | Renumber a numbered list (`set --at pN --start 5` / `--format upper-roman` / `--restart` / `--continue`). | diff --git a/src/cli/CLAUDE.md b/src/cli/CLAUDE.md index 2eace70..2ed4c20 100644 --- a/src/cli/CLAUDE.md +++ b/src/cli/CLAUDE.md @@ -247,7 +247,9 @@ The read view is Markdown, so the natural write-back is markdown — and it's th ## Hyperlink/image edits emit audit comments under track-changes -OOXML has no `w:hyperlinkChange` / `w:drawingChange` element — Word silently bypasses tracking for hyperlink edits and image swaps. We compromise: when `` 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 ``/`` — 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. +OOXML has no `w:hyperlinkChange` / `w:drawingChange` element — Word silently bypasses tracking for hyperlink edits and image swaps. We compromise: when `` is on, `hyperlinks add/replace/delete`, `images replace`, and `images set` (resize / alt text — unverified against Word's redline oracle, so it takes the audit-comment fallback) 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 ``/`` — 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 set` (in [images/set.ts](images/set.ts)) is the in-place frame editor: `--width`/`--height` (aspect locked to the frame's CURRENT ratio), `--size WxH` (the `docx:image size="…"` hint verbatim, parsed by `parseImageSizeInches` in [images/length.ts](images/length.ts) — shared with `images add`'s `--width`/`--height`), `--fit` (shrink to `getPageContentWidthEmu`, never enlarge), `--alt`, and `--batch` (`{"at":"imgN",…}`; `imgN` never shifts because `set` adds/removes no drawing). A call where nothing changes (`--fit` on an in-bounds image, the current alt) exits `MATCH_NOT_FOUND`. The ack's layout-hint line echoes each image's new `size="…"`/`alt` so the agent sees the result without re-reading. `images delete` is the exception — deleting an image is honest content removal, so under tracking it wraps the drawing's run in a real `` (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. diff --git a/src/cli/help.ts b/src/cli/help.ts index 00da038..2c09bab 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -43,7 +43,7 @@ Commands (each one-liner names capabilities you'd otherwise miss; see endnotes … Add (--at | --anchor PHRASE), edit, delete, list endnotes (--text/--runs/--markdown bodies) headers … Set/list/clear page headers — --text, page numbers, date, fields; --first-page/--even; default = whole document footers … Set/list/clear page footers — e.g. "footers set FILE --page-number --of-pages" for "Page X of Y" - images … Add (--caption "Figure 1: …" for a captioned figure), extract, replace, delete, list images + images … Add (--caption "Figure 1: …" for a captioned figure), set (resize / --fit / alt text), extract, replace, delete, list images hyperlinks … Add, list, replace, delete hyperlinks (add uses --url; replace uses --with) tables … Create a table (create --after pN --rows N --cols M), then restructure — insert/delete rows & columns, merge/unmerge, set widths, borders, format lists FILE Renumber a numbered list — "lists set --at pN --start 5" / "--format upper-roman" / "--restart" / "--continue" diff --git a/src/cli/images/add.ts b/src/cli/images/add.ts index d204c0b..319b9f3 100644 --- a/src/cli/images/add.ts +++ b/src/cli/images/add.ts @@ -1,7 +1,6 @@ import { parseParagraphOptions, type RawValues } from "../insert/index"; import { parseTargetPlacement, placeSpec } from "../insert/place"; import { decodeInlineEscapes } from "../parse-helpers"; -import { type LengthOptions, parseLength } from "../parse-length"; import { EXIT, fail, @@ -11,6 +10,7 @@ import { tryParseArgs, writeStdout, } from "../respond"; +import { parseImageInches } from "./length"; const HELP = `docx images add — insert an image @@ -165,24 +165,11 @@ async function parseImageFlags(values: RawValues): Promise< for (const flag of ["width", "height"] as const) { const raw = values[flag] as string | undefined; if (raw === undefined) continue; - const twips = parseLength(raw, flag, IMAGE_LENGTH); - if (typeof twips !== "number") - return fail("USAGE", twips.error, twips.hint); - out[flag === "width" ? "widthInches" : "heightInches"] = - twips / TWIPS_PER_INCH; + const inches = parseImageInches(raw, flag); + if (typeof inches !== "number") + return fail("USAGE", inches.error, inches.hint); + out[flag === "width" ? "widthInches" : "heightInches"] = inches; } return out; } - -/** Image sizes: bare inches (the long-standing meaning), positive, and `px` at - * 96 dpi — an image is the one thing with a natural pixel size (the default - * `--width` already IS its pixel size at 96 dpi), so `300px` is what an agent - * holding pixel dimensions reaches for. */ -const IMAGE_LENGTH: LengthOptions = { - bare: "in", - positive: true, - pixels: true, -}; - -const TWIPS_PER_INCH = 1440; diff --git a/src/cli/images/delete.tsx b/src/cli/images/delete.tsx index a813fe5..cd4e46c 100644 --- a/src/cli/images/delete.tsx +++ b/src/cli/images/delete.tsx @@ -80,12 +80,7 @@ export async function run(args: string[]): Promise { return fail("IMAGE_NOT_FOUND", `Image not found: ${targetId}`); } - // `imgN` is positional in document order; collectImageRuns yields hits in the - // same order `read` assigns ids, so hit[N] is imgN (two ids can share one - // media part / relationship, so we resolve the occurrence, not just the rId). - const occurrences = new Images(document).list(); - const ordinal = imageOrdinal(targetId); - const occurrence = ordinal === null ? undefined : occurrences[ordinal]; + const occurrence = new Images(document).find(targetId); if (!occurrence) { return fail( "IMAGE_NOT_FOUND", @@ -145,12 +140,6 @@ export async function run(args: string[]): Promise { return EXIT.OK; } -function imageOrdinal(id: string): number | null { - const match = id.match(/^img(\d+)$/); - if (!match) return null; - return Number(match[1]); -} - /** Drop the relationship and media part only when the rId is referenced NOWHERE * in the document — scanning every attribute, not just drawings, so we never * orphan a reference we don't model (a VML `` fallback sharing the diff --git a/src/cli/images/index.ts b/src/cli/images/index.ts index 3599396..95843d9 100644 --- a/src/cli/images/index.ts +++ b/src/cli/images/index.ts @@ -8,6 +8,7 @@ const SUBCOMMANDS: Record Promise<{ run: CommandFn }>> = { extract: () => import("./extract"), list: () => import("./list"), replace: () => import("./replace"), + set: () => import("./set"), }; const HELP = `docx images — manage embedded images @@ -19,9 +20,13 @@ Verbs: add Insert an image (--after/--before/--at-start/--at-end; --image SRC) list Print image manifest as JSON extract Dump image bytes to a directory + set Resize (--width/--height keep the aspect, --fit cures overflow) or + change alt text (--alt) of an existing image; --batch for many replace Swap an image's bytes delete Remove an embedded image (prunes the part if unreferenced) +To center an image, align its paragraph: docx edit FILE --at pN --alignment center. + Run "docx images --help" for verb-specific help. `; diff --git a/src/cli/images/length.ts b/src/cli/images/length.ts new file mode 100644 index 0000000..2ad2e83 --- /dev/null +++ b/src/cli/images/length.ts @@ -0,0 +1,57 @@ +import { + type LengthError, + type LengthOptions, + parseLength, + parseLengthList, +} from "../parse-length"; + +/** Parse one image `--width`/`--height` value into inches through the shared + * length grammar (`IMAGE_LENGTH`): bare inches, or a unit suffix — `3cm` used + * to be silently read as 3 INCHES. Shared by `images add` and `images set`. */ +export function parseImageInches( + raw: string, + flag: string, +): number | LengthError { + const twips = parseLength(raw, flag, IMAGE_LENGTH); + if (typeof twips !== "number") return twips; + return twips / TWIPS_PER_INCH; +} + +/** Parse `images set --size WxH` — exactly the shape the `docx:image` hint + * prints (`size="2x1.5in"`: a unit on the height shared by both sides, the + * `docx:page size` convention), so a hint value re-applies verbatim. The + * height side must start with a number so a `px`'s `x` isn't the separator. */ +export function parseImageSizeInches( + raw: string, +): { widthInches: number; heightInches: number } | LengthError { + const invalid: LengthError = { + error: `Invalid --size: ${raw}`, + hint: "Use WxH as the docx:image hint prints it, e.g. --size 2x1.5in (or one side alone: --width 2in keeps the aspect ratio).", + }; + const sides = raw.trim().match(/^(.+?)\s*x\s*(\.?\d.*)$/i); + if (!sides) return invalid; + const twips = parseLengthList( + `${sides[1]},${sides[2]}`, + "size", + IMAGE_LENGTH, + ); + if (!Array.isArray(twips)) return twips; + if (twips.length !== 2) return invalid; + const [width, height] = twips as [number, number]; + return { + widthInches: width / TWIPS_PER_INCH, + heightInches: height / TWIPS_PER_INCH, + }; +} + +/** Image sizes: bare inches (the long-standing meaning), positive, and `px` at + * 96 dpi — an image is the one thing with a natural pixel size (the default + * `--width` already IS its pixel size at 96 dpi), so `300px` is what an agent + * holding pixel dimensions reaches for. */ +const IMAGE_LENGTH: LengthOptions = { + bare: "in", + positive: true, + pixels: true, +}; + +const TWIPS_PER_INCH = 1440; diff --git a/src/cli/images/set.ts b/src/cli/images/set.ts new file mode 100644 index 0000000..c8e64da --- /dev/null +++ b/src/cli/images/set.ts @@ -0,0 +1,351 @@ +import type { XmlNode } from "@core"; +import { findContainingParagraph } from "@core/comments"; +import { ImageFrameError, Images } from "@core/image"; +import { addAuditComment } from "../audit-comment"; +import { + batchExampleIntro, + readJsonlObjects, + rejectBatchOnlyFlags, +} from "../parse-helpers"; +import { formatImageSize } from "../read/annotations"; +import { + EXIT, + fail, + openOrFail, + RENDER_VERIFY_EXAMPLE, + renderVerifyHint, + respond, + respondAck, + SAVE_FLAGS, + setVerboseAck, + tryParseArgs, + writeStdout, +} from "../respond"; +import { parseImageInches, parseImageSizeInches } from "./length"; + +const HELP = `docx images set — resize an existing image or change its alt text + +Usage: + docx images set FILE --at imgN [--width L | --height L | --size WxH | --fit] [--alt TEXT] + docx images set FILE --batch FILE.jsonl + +Examples: + docx images set doc.docx --at img0 --width 2in # height follows (aspect kept) + docx images set doc.docx --at img1 --fit # shrink to the text width + docx images set doc.docx --at img0 --alt "Company logo" + docx images set doc.docx --at img0 --size 2x1.5in # the docx:image size="…" hint, verbatim +${batchExampleIntro("Resize / re-describe many images")} + printf '%s\\n' '{"at":"img0","width":"2in","alt":"Logo"}' '{"at":"img3","fit":true}' > sets.jsonl + docx images set doc.docx --batch sets.jsonl + +Target: + --at imgN The image to change (ids from \`docx read\`'s + hints or \`docx images list\`) + --batch FILE JSONL, one {"at":"imgN", …} per line (keys mirror the + flags: width, height, size, fit, alt). "-" reads stdin. + +Size (at most one): + --width LENGTH New width; the height follows to keep the aspect ratio. + Bare inches (2) or a unit suffix — in, cm, mm, pt, px (96dpi) + --height LENGTH New height; the width follows to keep the aspect ratio. + --size WxH Both sides exactly, as the docx:image hint prints them + (2x1.5in) — can distort if the ratio differs. + --fit Shrink to the page's text width (aspect kept) when the + image is wider — the cure for overflow="yes". Never enlarges. + +Description: + --alt TEXT Alt text (Word's "Edit Alt Text"); --alt "" clears it. + +Options: + --author NAME Author for the audit comment when track-changes is on + (default: $DOCX_AUTHOR) + -o, --output PATH Write to PATH instead of overwriting FILE + --dry-run Report what would change without writing the file + -v, --verbose Print the success ack JSON (default: a one-line confirmation) + -h, --help Show this help + +To CENTER an image, align its paragraph: docx edit FILE --at pN --alignment center. + +Image size/alt changes can't be recorded as tracked changes, so when +track-changes is on they apply directly and an audit comment marks each image. +A call that changes nothing (e.g. --fit on an image that already fits) exits +nonzero. After resizing, render and look: +${RENDER_VERIFY_EXAMPLE} + +Output: + Prints "images.set imgN" plus the image's new size hint (exit 0). --verbose + prints {ok:true, operation, path, images:[{imageId, size, alt, …}]}. Errors + print {code, error, hint?} with a nonzero exit. +`; + +const OPTION_SPEC = { + at: { type: "string" }, + width: { type: "string" }, + height: { type: "string" }, + size: { type: "string" }, + fit: { type: "boolean" }, + alt: { type: "string" }, + batch: { type: "string" }, + author: { type: "string" }, + ...SAVE_FLAGS, +} as const; + +export async function run(args: string[]): Promise { + const parsed = await tryParseArgs(args, OPTION_SPEC, HELP); + if (typeof parsed === "number") return parsed; + + if (parsed.values.help) { + await writeStdout(HELP); + return EXIT.OK; + } + + setVerboseAck(Boolean(parsed.values.verbose)); + + const path = parsed.positionals[0]; + if (!path) return fail("USAGE", "Missing FILE argument", HELP); + + const entries = await readEntries(parsed.values); + if (typeof entries === "number") return entries; + + const document = await openOrFail(path); + if (typeof document === "number") return document; + + // Every entry is applied in memory before anything is written, so a bad + // entry anywhere leaves the file untouched. `imgN` ids never shift here — + // `set` adds and removes no drawing — so batch locators address the document + // as read without a pre-resolve pass. + const images = new Images(document); + const results: AppliedSet[] = []; + const targeted = new Set(); + for (const [index, entry] of entries.entries()) { + const { at, ...change } = entry; + // One entry per image: a second would see the first's result as its + // "before" and stack a second audit comment on the same run. + const run = images.find(at)?.run; + if (run && targeted.has(run)) { + return fail( + "USAGE", + `entry ${index}: ${at} is already targeted by an earlier entry`, + "Put every change for one image (size + alt) in a single entry.", + ); + } + if (run) targeted.add(run); + try { + results.push({ imageId: at, ...images.set(at, change) }); + } catch (error) { + if (!(error instanceof ImageFrameError)) throw error; + const prefix = entries.length > 1 ? `entry ${index}: ` : ""; + return fail(error.code, `${prefix}${at}: ${error.message}`, error.hint); + } + } + + const changed = results.filter( + (result) => result.resized || result.altChanged, + ); + if (changed.length === 0) return failNoChange(results); + + const outputPath = parsed.values.output as string | undefined; + const payload = { + operation: "images.set", + path: outputPath ?? path, + ...(results.length === 1 + ? { imageId: results[0]?.imageId } + : { count: changed.length }), + images: changed.map(describeResult), + }; + + if (parsed.values["dry-run"]) { + await respond({ ...payload, dryRun: true }); + return EXIT.OK; + } + + if (document.isTrackChangesEnabled()) { + const authorFlag = parsed.values.author as string | undefined; + for (const result of changed) { + const paragraph = findContainingParagraph( + document.documentTree, + result.hit.run, + ); + if (!paragraph) continue; + addAuditComment( + document, + { kind: "run", paragraph, run: result.hit.run }, + auditMessage(result), + authorFlag, + ); + } + } + + await document.save(outputPath); + + const summary = changed.map( + (result) => `${result.imageId} ${formatState(result.after)}`, + ); + if (changed.some((result) => result.resized)) { + summary.push(renderVerifyHint(outputPath ?? path)); + } + await respondAck({ ok: true, ...payload }, summary.join("\n")); + return EXIT.OK; +} + +/** The entries to apply: one from the flags, or one per `--batch` JSONL line. */ +async function readEntries( + values: Record, +): Promise { + const batchSource = values.batch as string | undefined; + if (batchSource === undefined) { + const entry = parseEntry(values, "flags"); + if ("error" in entry) return fail("USAGE", entry.error, entry.hint); + return [entry]; + } + const conflict = await rejectBatchOnlyFlags( + values, + ["at", "width", "height", "size", "fit", "alt"], + "image change", + 'Put one {"at":"imgN", …} per line.', + ); + if (conflict !== undefined) return conflict; + let rawEntries: Record[]; + try { + rawEntries = await readJsonlObjects(batchSource); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return fail("USAGE", `Failed to read batch: ${message}`); + } + if (rawEntries.length === 0) return fail("USAGE", "Batch file is empty"); + const entries: SetEntry[] = []; + for (const [index, raw] of rawEntries.entries()) { + const entry = parseEntry(raw, "batch"); + if ("error" in entry) { + return fail("USAGE", `entry ${index}: ${entry.error}`, entry.hint); + } + entries.push(entry); + } + return entries; +} + +/** One change from CLI flags or a JSONL object (same keys). Batch values may + * be JSON numbers (bare inches) or strings; `fit` must be a real boolean. */ +function parseEntry( + raw: Record, + source: "flags" | "batch", +): SetEntry | { error: string; hint?: string } { + const flag = (key: string) => (source === "flags" ? `--${key}` : `"${key}"`); + const at = raw.at; + if (typeof at !== "string" || at.length === 0) { + return { error: `${flag("at")} imgN is required` }; + } + const entry: SetEntry = { at }; + + const lengths: Record = {}; + for (const key of ["width", "height", "size"] as const) { + const value = raw[key]; + if (value === undefined) continue; + if (typeof value !== "string" && typeof value !== "number") { + return { error: `${flag(key)} must be a length like "2in"` }; + } + lengths[key] = String(value); + } + if (raw.fit !== undefined && typeof raw.fit !== "boolean") { + return { error: `${flag("fit")} must be true or false` }; + } + const sizeFlags = [ + ...Object.keys(lengths), + ...(raw.fit === true ? ["fit"] : []), + ]; + if (sizeFlags.length > 1) { + return { + error: `Pass one size option, not ${sizeFlags.map(flag).join(" + ")}`, + hint: "--width OR --height keeps the aspect ratio; --size WxH sets both; --fit shrinks to the text width.", + }; + } + + if (lengths.size !== undefined) { + const size = parseImageSizeInches(lengths.size); + if ("error" in size) return size; + entry.widthEmu = inchesToEmu(size.widthInches); + entry.heightEmu = inchesToEmu(size.heightInches); + } + for (const key of ["width", "height"] as const) { + const value = lengths[key]; + if (value === undefined) continue; + const inches = parseImageInches(value, key); + if (typeof inches !== "number") return inches; + entry[key === "width" ? "widthEmu" : "heightEmu"] = inchesToEmu(inches); + } + if (raw.fit === true) entry.fit = true; + + if (raw.alt !== undefined) { + if (typeof raw.alt !== "string") { + return { error: `${flag("alt")} must be a string` }; + } + entry.alt = raw.alt; + } + + if (sizeFlags.length === 0 && entry.alt === undefined) { + return { + error: "Nothing to change", + hint: "Pass --width/--height/--size/--fit to resize, and/or --alt TEXT.", + }; + } + return entry; +} + +const inchesToEmu = (inches: number): number => Math.round(inches * 914400); + +/** A zero-effect `set` exits nonzero: a weak agent keys off the exit code, so + * "--fit on an image that already fits" must not read as a success. */ +function failNoChange(results: AppliedSet[]): Promise { + const states = results + .map((result) => `${result.imageId} ${formatState(result.before)}`) + .join("; "); + return fail( + "MATCH_NOT_FOUND", + `Nothing changed — the image${results.length === 1 ? " is" : "s are"} already that way (${states}).`, + "--fit only shrinks an image WIDER than the text width; pass --width/--height to resize to a specific size.", + ); +} + +/** The new state in the `docx:image` hint's own vocabulary, so the agent sees + * the result without re-reading. */ +function formatState(state: AppliedSet["after"]): string { + const size = `size="${formatImageSize(state.widthEmu, state.heightEmu)}"`; + return state.alt ? `${size} alt=${JSON.stringify(state.alt)}` : size; +} + +function describeResult(result: AppliedSet) { + return { + imageId: result.imageId, + size: formatImageSize(result.after.widthEmu, result.after.heightEmu), + widthEmu: result.after.widthEmu, + heightEmu: result.after.heightEmu, + alt: result.after.alt, + resized: result.resized, + altChanged: result.altChanged, + }; +} + +function auditMessage(result: AppliedSet): string { + const parts: string[] = []; + if (result.resized) { + parts.push( + `image resized: ${formatImageSize(result.before.widthEmu, result.before.heightEmu)} → ${formatImageSize(result.after.widthEmu, result.after.heightEmu)}`, + ); + } + if (result.altChanged) { + parts.push( + `alt text: ${JSON.stringify(result.before.alt)} → ${JSON.stringify(result.after.alt)}`, + ); + } + return parts.join("; "); +} + +type SetEntry = { + at: string; + widthEmu?: number; + heightEmu?: number; + fit?: boolean; + alt?: string; +}; + +type AppliedSet = { imageId: string } & ReturnType; diff --git a/src/cli/read/annotations.ts b/src/cli/read/annotations.ts index 68e7bdd..6759f62 100644 --- a/src/cli/read/annotations.ts +++ b/src/cli/read/annotations.ts @@ -129,3 +129,9 @@ export function deviatingCellMargins( export function emuToInches(emu: number): string { return `${Number.parseFloat((emu / 914400).toFixed(2))}`; } + +/** An image extent as the `docx:image size="WxHin"` hint prints it — the one + * spelling `read` emits and `images set --size` / its ack echo back. */ +export function formatImageSize(widthEmu: number, heightEmu: number): string { + return `${emuToInches(widthEmu)}x${emuToInches(heightEmu)}in`; +} diff --git a/src/cli/read/markdown.ts b/src/cli/read/markdown.ts index d2c1456..48df25f 100644 --- a/src/cli/read/markdown.ts +++ b/src/cli/read/markdown.ts @@ -43,8 +43,8 @@ import { } from "@core/track-changes/revision-visibility"; import { deviatingCellMargins, - emuToInches, formatCellMargins, + formatImageSize, formatNote, htmlAttr, type NotePair, @@ -780,10 +780,7 @@ function contentWidthEmu(geometry: SectionBreak | undefined): number { function formatImageNote(run: ImageRun, contentEmu: number): string { const pairs: NotePair[] = []; if (run.widthEmu && run.heightEmu) { - pairs.push([ - "size", - `${emuToInches(run.widthEmu)}x${emuToInches(run.heightEmu)}in`, - ]); + pairs.push(["size", formatImageSize(run.widthEmu, run.heightEmu)]); } if (run.floating) pairs.push(["float", "yes"]); if (run.wrap) pairs.push(["wrap", run.wrap]); diff --git a/src/core/ast/document/body.ts b/src/core/ast/document/body.ts index 0e238e8..bb37cd8 100644 --- a/src/core/ast/document/body.ts +++ b/src/core/ast/document/body.ts @@ -159,6 +159,22 @@ export class Body { return undefined; } + /** The display size (``, EMU) of the first image run carrying + * this content hash — what `read`'s `docx:image size` hint shows. The + * markdown importer keeps it when an `![alt](.)` is restated, + * so a markdown `edit` doesn't undo a resize (`images set`, or Word's own) + * by snapping the picture back to its native pixel size. */ + findImageSizeByHash( + hash: string, + ): { widthEmu: number; heightEmu: number } | undefined { + for (const run of flattenImageRuns(this.blocks)) { + if (run.hash !== hash) continue; + if (!run.widthEmu || !run.heightEmu) return undefined; + return { widthEmu: run.widthEmu, heightEmu: run.heightEmu }; + } + return undefined; + } + listHyperlinkIds(): string[] { return [...this.hyperlinkById.keys()]; } diff --git a/src/core/image/CLAUDE.md b/src/core/image/CLAUDE.md index 500b113..1446308 100644 --- a/src/core/image/CLAUDE.md +++ b/src/core/image/CLAUDE.md @@ -4,7 +4,7 @@ Three files reachable via the `@core/image` barrel ([index.ts](index.ts)): [form ## Emit + package ops -`loadImageSource(src)` in [source.ts](source.ts) resolves a path / `data:` URI / `http(s)` URL to `{bytes, extension, mimeType, pixelWidth?, pixelHeight?}` (header-parsed dims for png/jpeg/gif; other formats need explicit sizing). `computeExtentEmu` turns pixels + optional inch overrides into the `` EMU pair. The `Images` lens in [drawing.tsx](drawing.tsx) carries `new Images(document).list()` (finds drawing runs in `imgN` order — shared by `images replace`/`delete`) and `new Images(document).add(source)` (writes `word/media/imageN.ext`, mints the `image` relationship, registers the content-type ``). `` is the pure emitter for the inline `` run. +`loadImageSource(src)` in [source.ts](source.ts) resolves a path / `data:` URI / `http(s)` URL to `{bytes, extension, mimeType, pixelWidth?, pixelHeight?}` (header-parsed dims for png/jpeg/gif; other formats need explicit sizing). `computeExtentEmu` turns pixels + optional inch overrides into the `` EMU pair. The `Images` lens in [drawing.tsx](drawing.tsx) carries `new Images(document).list()` (finds drawing runs in `imgN` order — shared by `images replace`/`delete`) and `new Images(document).add(source)` (writes `word/media/imageN.ext`, mints the `image` relationship, registers the content-type ``). `` is the pure emitter for the inline `` run. `new Images(document).find("imgN")` resolves the OCCURRENCE (`list()[N]`; shared by `images delete`/`set`), and `.set(imgN, change)` is `images set`: it delegates to `setImageFrame` in [frame.ts](frame.ts), which mutates the existing ``/`` frame IN PLACE — `` and `` together (looked up by path; a bare `findDescendant("a:ext")` hits ``), alt on `` + `` — leaving wrap/position/crop untouched, and refuses a group/shape drawing (`graphicData@uri` ≠ picture) rather than scale only its outer box. A single axis keeps the frame's current aspect; `fitWidthEmu` shrinks only. The frame-size writer is the natural place for `images replace`'s aspect refit (#46) to share. On the import side, a restated `![alt](.)` keeps the size the image is SHOWN at (`Body.findImageSizeByHash`), so a markdown `edit` can't undo a resize by snapping back to native pixels. The `a:`/`pic:` namespaces are declared inline on `a:graphic`/`pic:pic` (matching Word) so the subtree validates regardless of the document root's declarations — `docx create`'s template declares `w/r/m/mc/wp/...` but **not** `a`/`pic`. Drawing object ids (`wp:docPr`/`pic:cNvPr` @id) must be unique per document, so `nextDrawingId` scans existing ids. `r:embed` is an *attribute*, so there's no `r` element namespace in `jsx/index.ts`. An image run inside `` surfaces `ImageRun.trackedChange` (populated in the `w:drawing` branch of `readRun` in `ast/read.ts`), same as a text run. diff --git a/src/core/image/drawing.tsx b/src/core/image/drawing.tsx index 703b127..9d7d370 100644 --- a/src/core/image/drawing.tsx +++ b/src/core/image/drawing.tsx @@ -3,6 +3,14 @@ import { iterateBlocks } from "../ast/document/body"; import { IMAGE_RELATIONSHIP_TYPE } from "../ast/document/relationships"; import { a, pic, w, wp } from "../jsx"; import type { XmlNode } from "../parser"; +import { getPageContentWidthEmu } from "../sections"; +import { + DRAWINGML_PICTURE_URI, + type ImageFrameChange, + ImageFrameError, + type ImageFrameResult, + setImageFrame, +} from "./frame"; import type { ImageSource } from "./source"; /** Cross-cutting lens over the document's inline images: walks the document @@ -40,6 +48,39 @@ export class Images { return hits; } + /** The occurrence `imgN` names — `list()[N]`. Two ids can share one media + * part / relationship, so this resolves the OCCURRENCE, not just the rId. + * Undefined for a malformed id or one past the last image. */ + find(imageId: string): ImageRunHit | undefined { + const match = imageId.match(/^img(\d+)$/); + if (!match) return undefined; + return this.list()[Number(match[1])]; + } + + /** Resize and/or re-describe the picture `imageId` in place (`images set`). + * `fit: true` shrinks it to the page's text width (the same width `images + * add` fits new images to), never enlarging. Throws `ImageFrameError` for + * an unknown id or a drawing that isn't a lone picture. */ + set( + imageId: string, + change: Omit & { fit?: boolean }, + ): ImageFrameResult & { hit: ImageRunHit } { + const hit = this.find(imageId); + const drawing = hit?.run.findChild("w:drawing"); + if (!hit || !drawing) { + throw new ImageFrameError( + "IMAGE_NOT_FOUND", + "Image not found", + "Image ids are img0, img1, … in document order — see `docx images list`.", + ); + } + const { fit, ...rest } = change; + const frameChange: ImageFrameChange = fit + ? { ...rest, fitWidthEmu: getPageContentWidthEmu(this.document) } + : rest; + return { ...setImageFrame(drawing, frameChange), hit }; + } + /** Write image bytes into `word/media/imageN.ext`, mint an image * relationship, and register the extension's content-type default. * Returns the rId + partName so the caller's `` blip can reference @@ -214,8 +255,6 @@ async function sha256Hex(bytes: Uint8Array): Promise { return hex; } -const DRAWINGML_PICTURE_URI = - "http://schemas.openxmlformats.org/drawingml/2006/picture"; const A_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/main"; const PIC_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/picture"; diff --git a/src/core/image/frame.ts b/src/core/image/frame.ts new file mode 100644 index 0000000..b08448a --- /dev/null +++ b/src/core/image/frame.ts @@ -0,0 +1,196 @@ +import type { XmlNode } from "../parser"; + +/** Apply an `images set` change to one picture's `` IN PLACE: resize + * its frame (`` + the picture's own ``, which must + * agree or Word re-derives one from the other) and/or rewrite its alt text + * (``/`` `descr`). Works on `` and + * `` frames alike; every other child (wrap, position, crop, effects) + * is left byte-identical. Returns the before/after state so the caller can + * report, audit, and detect a no-op. Throws `ImageFrameError` when the drawing + * isn't a single picture we can resize honestly. */ +export function setImageFrame( + drawing: XmlNode, + change: ImageFrameChange, +): ImageFrameResult { + const frame = drawingFrame(drawing); + if (!frame) { + throw new ImageFrameError( + "UNSUPPORTED", + "This drawing has no / frame to resize.", + ); + } + const before = readFrameState(frame); + const after: ImageFrameState = { ...before }; + + if (hasSizeChange(change)) { + if (!isSinglePicture(frame)) { + throw new ImageFrameError( + "UNSUPPORTED", + "This image is part of a drawing group or shape, not a lone picture, so it can't be resized on its own.", + "Resize the group in Word, or replace it with `docx images replace`.", + ); + } + const size = resolveFrameSize(before, change); + after.widthEmu = size.widthEmu; + after.heightEmu = size.heightEmu; + writeFrameSize(frame, size); + } + + if (change.alt !== undefined) { + after.alt = change.alt; + writeFrameAlt(frame, change.alt); + } + + return { + before, + after, + resized: + after.widthEmu !== before.widthEmu || + after.heightEmu !== before.heightEmu, + altChanged: after.alt !== before.alt, + }; +} + +/** The `` (in-flow) or `` (floating) frame — the one + * element child of `` that carries the extent. */ +function drawingFrame(drawing: XmlNode): XmlNode | undefined { + return drawing.children.find( + (child) => child.tag === "wp:inline" || child.tag === "wp:anchor", + ); +} + +function readFrameState(frame: XmlNode): ImageFrameState { + const extent = frame.findChild("wp:extent"); + return { + widthEmu: Number(extent?.getAttribute("cx") ?? "0") || 0, + heightEmu: Number(extent?.getAttribute("cy") ?? "0") || 0, + alt: frame.findChild("wp:docPr")?.getAttribute("descr") ?? "", + }; +} + +function hasSizeChange(change: ImageFrameChange): boolean { + return ( + change.widthEmu !== undefined || + change.heightEmu !== undefined || + change.fitWidthEmu !== undefined + ); +} + +/** A resizable lone picture: `` holds a `pic:pic`, not a + * `wpg:wgp` group or `wps:wsp` shape whose children carry their own geometry + * (scaling only the outer extent would distort or clip them). */ +function isSinglePicture(frame: XmlNode): boolean { + const graphicData = frame.findChild("a:graphic")?.findChild("a:graphicData"); + return graphicData?.getAttribute("uri") === DRAWINGML_PICTURE_URI; +} + +/** The new extent. A single axis keeps the frame's CURRENT aspect ratio (Word's + * lock-aspect corner drag); both axes are honored as given; `fitWidthEmu` + * shrinks a wider frame to that width, aspect kept, and never enlarges. */ +function resolveFrameSize( + current: ImageFrameState, + change: ImageFrameChange, +): { widthEmu: number; heightEmu: number } { + const { widthEmu, heightEmu, fitWidthEmu } = change; + if (widthEmu !== undefined && heightEmu !== undefined) { + return { widthEmu, heightEmu }; + } + const hasAspect = current.widthEmu > 0 && current.heightEmu > 0; + if ((widthEmu !== undefined || heightEmu !== undefined) && !hasAspect) { + throw new ImageFrameError( + "USAGE", + "The image has no current size to keep the aspect ratio from.", + "Pass both --width and --height.", + ); + } + if (widthEmu !== undefined) { + return { + widthEmu, + heightEmu: Math.round((widthEmu * current.heightEmu) / current.widthEmu), + }; + } + if (heightEmu !== undefined) { + return { + widthEmu: Math.round((heightEmu * current.widthEmu) / current.heightEmu), + heightEmu, + }; + } + if ( + fitWidthEmu !== undefined && + hasAspect && + current.widthEmu > fitWidthEmu + ) { + return { + widthEmu: fitWidthEmu, + heightEmu: Math.round( + (fitWidthEmu * current.heightEmu) / current.widthEmu, + ), + }; + } + return { widthEmu: current.widthEmu, heightEmu: current.heightEmu }; +} + +/** Write the size to BOTH places Word keeps it: the frame's `` and + * the picture's `` (looked up by path — a bare + * `findDescendant("a:ext")` would hit an `` extension). */ +function writeFrameSize( + frame: XmlNode, + size: { widthEmu: number; heightEmu: number }, +): void { + const extent = frame.findChild("wp:extent"); + extent?.setAttribute("cx", String(size.widthEmu)); + extent?.setAttribute("cy", String(size.heightEmu)); + const transformExtent = frame + .findDescendant("pic:spPr") + ?.findChild("a:xfrm") + ?.findChild("a:ext"); + transformExtent?.setAttribute("cx", String(size.widthEmu)); + transformExtent?.setAttribute("cy", String(size.heightEmu)); +} + +/** Alt text lives on `` (what Word's Alt Text pane edits and + * what `read` shows as `![alt]`), mirrored onto `` as Word + * writes it. An empty string clears it. */ +function writeFrameAlt(frame: XmlNode, alt: string): void { + const carriers = [ + frame.findChild("wp:docPr"), + frame.findDescendant("pic:cNvPr"), + ]; + for (const carrier of carriers) { + if (!carrier) continue; + if (alt === "") delete carrier.attributes.descr; + else carrier.setAttribute("descr", alt); + } +} + +export const DRAWINGML_PICTURE_URI = + "http://schemas.openxmlformats.org/drawingml/2006/picture"; + +export class ImageFrameError extends Error { + constructor( + public code: "USAGE" | "UNSUPPORTED" | "IMAGE_NOT_FOUND", + message: string, + public hint?: string, + ) { + super(message); + } +} + +/** What `images set` asks for. Sizes are EMU; omit a field to leave it alone. */ +export type ImageFrameChange = { + widthEmu?: number; + heightEmu?: number; + /** Shrink to at most this width (aspect kept); never enlarges. */ + fitWidthEmu?: number; + /** New alt text; `""` clears it. */ + alt?: string; +}; + +type ImageFrameState = { widthEmu: number; heightEmu: number; alt: string }; + +export type ImageFrameResult = { + before: ImageFrameState; + after: ImageFrameState; + resized: boolean; + altChanged: boolean; +}; diff --git a/src/core/image/index.ts b/src/core/image/index.ts index 57767b3..209136c 100644 --- a/src/core/image/index.ts +++ b/src/core/image/index.ts @@ -9,6 +9,7 @@ export { imageFormatForExtension, SUPPORTED_IMAGE_FORMATS, } from "./formats"; +export { type ImageFrameChange, ImageFrameError } from "./frame"; export { computeExtentEmu, type ImageSource, diff --git a/src/core/markdown/CLAUDE.md b/src/core/markdown/CLAUDE.md index d7572a4..97ae3ae 100644 --- a/src/core/markdown/CLAUDE.md +++ b/src/core/markdown/CLAUDE.md @@ -150,7 +150,7 @@ This is a deliberate v0.12 design choice. The cost of full round-trip would be c ## Image round-trip via content hash -`docx read --markdown` emits every embedded image as `![alt](.)` — the same content-addressed naming `docx images extract` writes to disk. On the import side, `tryReuseImageByHash` in [import.tsx](import.tsx) sniffs that URL shape, looks the hash up in the target document's `body` via `findImageByHash`, and reuses the existing relationship id when it matches. The result: a same-doc round-trip (`read --markdown → edit --markdown-file → write`) doesn't duplicate any `word/media/imageN` parts, and cross-doc round-trips work whenever the target happens to carry an identical image. +`docx read --markdown` emits every embedded image as `![alt](.)` — the same content-addressed naming `docx images extract` writes to disk. On the import side, `tryReuseImageByHash` in [import.tsx](import.tsx) sniffs that URL shape, looks the hash up in the target document's `body` via `findImageByHash`, and reuses the existing relationship id when it matches — at the size the image is SHOWN at (the first same-hash drawing's ``, via `body.findImageSizeByHash`), falling back to the media's native pixel size only when that extent is missing, so a restated image doesn't undo an `images set` (or Word) resize. The result: a same-doc round-trip (`read --markdown → edit --markdown-file → write`) doesn't duplicate any `word/media/imageN` parts, and cross-doc round-trips work whenever the target happens to carry an identical image. If the URL is hash-shaped but the doc carries no matching image, we surface a clear `IMAGE_SOURCE` error pointing at the hash. The walker doesn't silently fall through to `loadImageSource("abc123…ef0.png")` because that would just produce an ENOENT with no hint about what went wrong. diff --git a/src/core/markdown/import.tsx b/src/core/markdown/import.tsx index c9c7845..0535b5e 100644 --- a/src/core/markdown/import.tsx +++ b/src/core/markdown/import.tsx @@ -472,10 +472,19 @@ async function tryReuseImageByHash( "This URL looks like a docx-cli content-addressed image reference (the shape `.` that `docx read --markdown` emits). Either reference a file path, a data: URI, or an https:// URL — or use a hash that already exists in the target doc.", ); } - // Recompute extent from the stored media bytes — `` is set - // per-drawing in OOXML, not per-media-part, so we can't crib the - // dimensions from the existing usage's drawing element (it may have - // been resized). + // Keep the size the image is SHOWN at (`` is per-drawing, not + // per-media-part, so this is the first usage's) — a restated image must + // not snap a resized picture back to its native pixel size. + const shownSize = ctx.document.body.findImageSizeByHash(hash); + if (shownSize) { + return { + relationshipId: existing.relationshipId, + drawingId: nextDrawingId(ctx.document.documentTree), + ...shownSize, + alt: image.alt ?? undefined, + }; + } + // No usable extent on the existing drawing: size from the stored bytes. const bytes = await ctx.document.pkg.readBytes(existing.partName); if (!bytes) { throw new MarkdownImportError( diff --git a/tests/cli/harness.ts b/tests/cli/harness.ts index 2ec8e8c..6d07a2a 100644 --- a/tests/cli/harness.ts +++ b/tests/cli/harness.ts @@ -40,7 +40,7 @@ const SUBVERB_MUTATORS: Record> = { footers: new Set(["set", "clear"]), footnotes: new Set(["add", "edit", "delete"]), headers: new Set(["set", "clear"]), - images: new Set(["replace", "delete"]), + images: new Set(["replace", "delete", "set"]), hyperlinks: new Set(["add", "replace", "delete"]), lists: new Set(["set"]), raw: new Set(["insert", "replace", "edit", "part"]), diff --git a/tests/cli/images-set.test.ts b/tests/cli/images-set.test.ts new file mode 100644 index 0000000..0e232fe --- /dev/null +++ b/tests/cli/images-set.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { runCli, spawnCli, tempWorkspace } from "./harness"; +import { expectValidClean, readDocumentXml, readMarkdown } from "./helpers"; + +const FIXTURES = join(import.meta.dir, "..", "fixtures"); +const PNG = join(FIXTURES, "assets", "sample.png"); // 96x64 px → 1x0.67in + +type SetAck = { + operation: string; + imageId?: string; + count?: number; + images: Array<{ + imageId: string; + size: string; + widthEmu: number; + heightEmu: number; + alt: string; + }>; +}; + +/** A Letter doc (6.5in text width) with `p0` text and the image at `p1`, + * sized `widthInches` wide (3:2 aspect, alt "Old"). */ +async function docWithImage(widthInches: string): Promise { + const doc = join(tempWorkspace("images-set"), "doc.docx"); + await runCli("create", doc, "--text", "Hello"); + const added = await runCli( + "images", + "add", + doc, + "--after", + "p0", + "--image", + PNG, + "--alt", + "Old", + "--width", + widthInches, + ); + expect(added.exitCode).toBe(0); + return doc; +} + +function extents(xml: string): string[] { + return [...xml.matchAll(/<(wp|a):ext(?:ent)? cx="(\d+)" cy="(\d+)"/g)].map( + (match) => `${match[1]}:${match[2]}x${match[3]}`, + ); +} + +describe("docx images set — resize", () => { + let doc: string; + beforeEach(async () => { + doc = await docWithImage("3"); + }); + + test("--width keeps the aspect ratio and updates wp:extent AND a:xfrm/a:ext", async () => { + const result = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--width", + "1.5in", + ); + expect(result.exitCode).toBe(0); + const ack = result.parsed as SetAck; + expect(ack.operation).toBe("images.set"); + expect(ack.images[0]?.size).toBe("1.5x1in"); + expect(extents(await readDocumentXml(doc))).toEqual([ + "wp:1371600x914400", + "a:1371600x914400", + ]); + // Write-read loop: the docx:image hint shows the new size. + expect(await readMarkdown(doc)).toContain( + '', + ); + await expectValidClean(doc); + }); + + test("--height keeps the aspect ratio; units parse through the shared grammar", async () => { + const result = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--height", + "2.54cm", + ); + expect(result.exitCode).toBe(0); + expect((result.parsed as SetAck).images[0]?.size).toBe("1.5x1in"); + }); + + test("--size accepts the docx:image size hint verbatim (both sides exact)", async () => { + const result = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--size", + "2x0.5in", + ); + expect(result.exitCode).toBe(0); + expect(await readMarkdown(doc)).toContain('docx:image img0 size="2x0.5in"'); + }); + + test("the default ack names the image and its new size hint", async () => { + const result = await spawnCli( + "images", + "set", + doc, + "--at", + "img0", + "--width", + "2", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("images.set img0\n"); + expect(result.stdout).toContain('img0 size="2x1.33in" alt="Old"'); + }); + + test("one size option at a time; bad lengths are usage errors", async () => { + const both = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--width", + "2", + "--fit", + ); + expect(both.exitCode).toBe(2); + const bad = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--width", + "big", + ); + expect(bad.exitCode).toBe(2); + const nothing = await runCli("images", "set", doc, "--at", "img0"); + expect(nothing.exitCode).toBe(2); + }); + + test("an unknown image id is IMAGE_NOT_FOUND and nothing is written", async () => { + const before = await readDocumentXml(doc); + const result = await runCli( + "images", + "set", + doc, + "--at", + "img7", + "--width", + "1", + ); + expect(result.exitCode).toBe(3); + expect(result.stdout).toContain("IMAGE_NOT_FOUND"); + expect(await readDocumentXml(doc)).toBe(before); + }); +}); + +describe("docx images set --fit", () => { + test("shrinks an overflowing image to the text width and clears overflow=yes", async () => { + const doc = await docWithImage("9"); + expect(await readMarkdown(doc)).toContain('size="9x6in" overflow="yes"'); + const result = await runCli("images", "set", doc, "--at", "img0", "--fit"); + expect(result.exitCode).toBe(0); + const md = await readMarkdown(doc); + expect(md).toContain(''); + expect(md).not.toContain("overflow="); + }); + + test("never enlarges: --fit on an in-bounds image changes nothing and exits nonzero", async () => { + const doc = await docWithImage("2"); + const before = await readDocumentXml(doc); + const result = await runCli("images", "set", doc, "--at", "img0", "--fit"); + expect(result.exitCode).toBe(3); + expect(result.stdout).toContain("MATCH_NOT_FOUND"); + expect(await readDocumentXml(doc)).toBe(before); + }); + + test("resizes a Word-authored floating image in place, keeping the anchor", async () => { + const doc = join(tempWorkspace("images-set-float"), "doc.docx"); + await Bun.write(doc, Bun.file(join(FIXTURES, "large-mixed.docx"))); + const result = await runCli( + "images", + "set", + doc, + "--at", + "img1", + "--width", + "3in", + ); + expect(result.exitCode).toBe(0); + const md = await readMarkdown(doc); + expect(md).toMatch( + /docx:image img1 size="3x[\d.]+in" float="yes" wrap="topAndBottom" align="left"/, + ); + }); +}); + +describe("docx images set --alt", () => { + test("sets docPr/cNvPr descr, shows in read, and survives an edit that restates the image", async () => { + const doc = await docWithImage("3"); + const result = await runCli( + "images", + "set", + doc, + "--at", + "img0", + "--alt", + "Company logo", + "--width", + "2", + ); + expect(result.exitCode).toBe(0); + const xml = await readDocumentXml(doc); + expect(xml).toMatch(/]*descr="Company logo"/); + expect(xml).toMatch(/]*descr="Company logo"/); + + const line = (await readMarkdown(doc)) + .split("\n") + .find((text) => text.includes("docx:image img0")) as string; + expect(line).toContain("![Company logo]("); + expect(line).toContain('size="2x1.33in"'); + + // A markdown edit that restates the image keeps its alt AND its size — + // it must not snap back to the native pixel size. + const restated = line.replace(/