From 52ca8b9e00f5d6a15914070738acaa81be766b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:10:02 +0000 Subject: [PATCH] feat(charts): chartN locator, docx:chart read hint, charts list/extract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #43 (Phase 1 visibility + Phase 2 read-only verbs). A native Word chart used to read as an anonymous `[chart]`; now each one gets a positional chartN id (body, table cells, text boxes) and a hint, the AST NativeChartRun carries the cached series, data source and workbook part, and `docx charts list` / `charts extract --at chartN [--json]` expose the manifest and the data as CSV. Read-only: ChartsView has no writeTo, so chart parts and workbooks stay byte-identical across saves. Refs #43 Triage-Routine: true Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LkctELRxzFrjMAGvfnY1BE --- CLAUDE.md | 8 +- CONTRIBUTING.md | 2 + REFERENCE.md | 16 +- skills/docx-cli/SKILL.md | 4 +- skills/docx-cli/references/commands.md | 1 + src/cli/CLAUDE.md | 9 + src/cli/charts/extract.ts | 77 ++++++ src/cli/charts/index.ts | 41 ++++ src/cli/charts/list.ts | 58 +++++ src/cli/help.ts | 1 + src/cli/index.ts | 1 + src/cli/info/locators.ts | 2 + src/cli/info/schema.ts | 40 ++++ src/cli/info/skill.ts | 4 +- src/cli/read/markdown.ts | 64 ++++- src/cli/respond.ts | 2 + src/core/ast/CLAUDE.md | 1 + src/core/ast/document/charts.ts | 79 +++++++ src/core/ast/document/index.ts | 12 +- src/core/ast/document/relationships.ts | 29 +++ src/core/ast/index.ts | 2 + src/core/ast/read.ts | 85 +++++-- src/core/ast/types.ts | 65 ++++- src/core/chart/CLAUDE.md | 9 + src/core/chart/charts.ts | 78 ++++++ src/core/chart/csv.ts | 38 +++ src/core/chart/index.ts | 2 + src/core/chart/read.ts | 222 ++++++++++++++++++ src/core/index.ts | 2 + src/core/raw/relationships.ts | 21 +- tests/cli/charts.test.ts | 159 +++++++++++++ tests/core/chart.test.ts | 202 ++++++++++++++++ tests/fixtures/charts.docx | Bin 0 -> 8444 bytes tests/fixtures/setup/charts.ts | 205 ++++++++++++++++ .../integration/libreoffice-roundtrip.test.ts | 1 + 35 files changed, 1481 insertions(+), 61 deletions(-) create mode 100644 src/cli/charts/extract.ts create mode 100644 src/cli/charts/index.ts create mode 100644 src/cli/charts/list.ts create mode 100644 src/core/ast/document/charts.ts create mode 100644 src/core/chart/CLAUDE.md create mode 100644 src/core/chart/charts.ts create mode 100644 src/core/chart/csv.ts create mode 100644 src/core/chart/index.ts create mode 100644 src/core/chart/read.ts create mode 100644 tests/cli/charts.test.ts create mode 100644 tests/core/chart.test.ts create mode 100644 tests/fixtures/charts.docx create mode 100644 tests/fixtures/setup/charts.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8859b5d..d21cf86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ These conventions are NOT SUGGESTIONS. These are rules. - **JSX is for emitters only.** Files that construct fresh XML can be `.tsx`; readers/locators/analysis stay `.ts`. Components are PascalCase, accept props, may return `NullableXmlNode` (null skipped by flatten). Attribute names with colons use the hyphen shortcut (`w-val="x"` → `w:val="x"`) or JSX spread. - **Component vs view vs lens vs free function vs transient cursor**: five shapes, one decision tree. - A pure `props → XmlNode` builder is a PascalCase **component** — destructure its props in the signature (no `props.x` access), and don't take a `Document` (or any package state). - - Stateful OOXML state lives in **tree-owning views**, embedded as fields on `Document`: `Body`, plus one view per OPC part — `StylesView`, `NumberingView`, `CommentsView`, `NotesView`, `RelationshipsView`, `ContentTypesView`, `SettingsView`, `CorePropertiesView`, `MarginalsView`. Each owns its part's `XmlNode` tree and any maps keyed to it, and exposes a `fromPackage`/`fromXml`/`writeTo` lifecycle (`register` too, for the lazily-provisioned ones). `MarginalsView` is the one that owns MANY parts (every `word/header{N}.xml` / `word/footer{N}.xml`) keyed by part name rather than one — so it has no single `register`; the `Marginals` lens mints each part's rel + content-type as it allocates it. Cross-view dependencies (e.g., `NotesView.ensureNoteStyles(stylesView)`) are passed as method arguments — no view reaches up to `Document`. - - **Cross-cutting lenses** (`Images`, `Hyperlinks`, `Equations`, `TrackChanges`, `Comments`, `Fonts`, `Marginals`) are NOT fields on `Document` — they're stateless, constructed at the call site: `new Images(document).add(source)`, `new TrackChanges(document).accept(["tc0"])`, `new Marginals(document).set(sectPrs, "footer", "default", spec)`, `await new Fonts(document).setDefault("Times New Roman")`. They hold only a back-reference; the embedded views are the state they reach through. `Marginals` is the header/footer authoring lens — the noun pair `docx headers`/`docx footers` share it via `MarginalKind` the way `footnotes`/`endnotes` share `Note` — reaching through `MarginalsView` (part trees) + relationships/content-types (part registration) + settings (the even/odd toggle) + the live `` reference nodes (see [src/core/marginals](src/core/marginals/CLAUDE.md)). `Fonts` is the one that also touches an UNMODELED part — the document font lives in BOTH `word/styles.xml` `` (owned by `StylesView`) and `word/theme/theme1.xml`'s `` (not a view: read/mutated/staged through `Pkg` only when `set-default-font` runs, so unrelated saves never re-serialize the theme blob). + - Stateful OOXML state lives in **tree-owning views**, embedded as fields on `Document`: `Body`, plus one view per OPC part — `StylesView`, `NumberingView`, `CommentsView`, `NotesView`, `RelationshipsView`, `ContentTypesView`, `SettingsView`, `CorePropertiesView`, `MarginalsView`, `ChartsView`. Each owns its part's `XmlNode` tree and any maps keyed to it, and exposes a `fromPackage`/`fromXml`/`writeTo` lifecycle (`register` too, for the lazily-provisioned ones). `MarginalsView` is the one that owns MANY parts (every `word/header{N}.xml` / `word/footer{N}.xml`) keyed by part name rather than one — so it has no single `register`; the `Marginals` lens mints each part's rel + content-type as it allocates it. `ChartsView` also owns many parts (every `word/charts/chartN.xml` a document `chart` rel targets, with its own rels) and is READ-ONLY today — no `writeTo`, so a save never re-serializes a chart part (see [src/core/chart](src/core/chart/CLAUDE.md)). Cross-view dependencies (e.g., `NotesView.ensureNoteStyles(stylesView)`) are passed as method arguments — no view reaches up to `Document`. + - **Cross-cutting lenses** (`Images`, `Hyperlinks`, `Equations`, `TrackChanges`, `Comments`, `Fonts`, `Marginals`, `Charts`) are NOT fields on `Document` — they're stateless, constructed at the call site: `new Images(document).add(source)`, `new TrackChanges(document).accept(["tc0"])`, `new Marginals(document).set(sectPrs, "footer", "default", spec)`, `await new Fonts(document).setDefault("Times New Roman")`. They hold only a back-reference; the embedded views are the state they reach through. `Marginals` is the header/footer authoring lens — the noun pair `docx headers`/`docx footers` share it via `MarginalKind` the way `footnotes`/`endnotes` share `Note` — reaching through `MarginalsView` (part trees) + relationships/content-types (part registration) + settings (the even/odd toggle) + the live `` reference nodes (see [src/core/marginals](src/core/marginals/CLAUDE.md)). `Fonts` is the one that also touches an UNMODELED part — the document font lives in BOTH `word/styles.xml` `` (owned by `StylesView`) and `word/theme/theme1.xml`'s `` (not a view: read/mutated/staged through `Pkg` only when `set-default-font` runs, so unrelated saves never re-serialize the theme blob). - **Free functions** are reserved for: pure builders (the components above), the AST reader (`src/core/ast/read.ts` — Document's construction pass; populates the embedded views from XML and is the sole assigner of `tcN` ids), and emitter helpers in `src/core/blocks`/`table`/`sections` that thread a `Document` because they touch many slices in one call. If a free function's body operates on one slice of `document`, make it a method on that slice's view instead. - **Transient cursors** are the one stateful shape that is NEITHER a view nor a lens: a short-lived object holding position state over a SINGLE node's child list, valid only within one mutation pass (today `CellInsertionCursor` in [src/core/table](src/core/table/CLAUDE.md), which keeps a batch's inserts into one `` in entry order). It holds no `Document`, isn't embedded on one, and dies with the pass — so don't file it as a lens. It lives beside the primitives it sequences, and the CLI constructs one per target and calls it. - **JSX.Element = XmlNode** (single, not nullable). `Fragment` returns a `#fragment` sentinel unwrapped in `flatten()` and `serialize()`. Components return `null` to render nothing; `jsx()` converts that to an empty fragment. The `jsx`/`jsxs`/`jsxDEV` runtime exports are distinct functions, not `= jsx` aliases (knip flags aliased re-exports as duplicates) — don't collapse them. @@ -36,10 +36,10 @@ These invariants are NOT SUGGESTIONS. These MUST be followed. - **We do not handle all possible OOXML elements: so make in-place XML mutations, not AST/Markdown round-trips.** The AST or Markdown are just views. Mutate `XmlNode` refs, only emit fresh XML for inserted nodes. This way, elements we cannot parse will not be broken. See [src/core](src/core/CLAUDE.md). - **Most things, even those that are not easily representable, should be in Markdown.** For these, we (1) build comments into the markdown to give folks a better sense of what is going on, (2) make sure that these comment do not break the markdown and survive the roundtrip, and (3) provide intuitive verbs to deal with concepts. If some structure is truly not representable in Markdown, we nudge agents to render the pages and look. We should guarantee that the things that we're not doing the above for have no possible representation. - **Authoring has two channels: parsed and literal.** Markdown is the DEFAULT: `edit`/`insert` take their content as a positional argument (`docx edit FILE --at p3 "Net **30** days"`), which is exactly `--markdown` (kept as an alias). New markdown content must look like the document it lands in: a `- item` beside a list joins THAT list (`continueList` in `core/lists/continue-list.tsx`), and whatever the replaced paragraph / neighbor doesn't format falls back FIRST to the look that was AT that position (for `edit`, the replaced paragraph's rendered face + explicit size), THEN to the document's DOMINANT face/size — the `docx:base` `read` shows (`matchDocumentLook` in `core/dominant-formatting.ts`) — never the new style chain's default. `--markdown`/`--from` parse GFM (+ math + CriticMarkup + inline HTML); `--text-file` (on `insert` and `create`, `-` = stdin) is the PARSER-FREE channel — every character lands verbatim, each newline starts a new paragraph. It exists because GFM corruption of literal prose isn't always escapable: a bare URL autolinks with NO escape sequence at all, and CriticMarkup eats `{++…++}` regardless of backslashes (`*x*`, `3.`, `[t](u)` ARE backslash-escapable, but the agent — often Haiku — must get the whole dialect right, and the field evidence is they don't). So "put this exact text in, untouched" gets its own flag rather than an escaping burden on the weakest actor. `--text` stays inline single-paragraph; both it and the bulk-literal `--text-file` insert markdown-looking content VERBATIM (`--text "**bold**"` writes literal `**` — use `--markdown` to parse). The only value `--text` still refuses is a shell-gutted currency amount (`rejectShellMangledValue`: `"$300"` → `.00`), which is never intentional. **Every inline `--text`/`--markdown` argv value gets its `\n`/`\t`/`\r`(`\r\n`) decoded at the CLI ingress** (`decodeInlineEscapes` in `cli/parse-helpers.ts`) — a weak agent writes `--text "a\nb"` for a line break, but bash double-quotes hand the CLI the literal backslash-n, which used to land verbatim in the run. It's applied by EVERY inline authoring surface, not just the body: `edit`/`insert`/`create`, `footnotes`/`endnotes add`+`edit` (`--text` and inline `--markdown`), `comments add`+`reply` (`--text`), `headers`/`footers set` (`--text`), and the `find`/`replace` positionals (QUERY/PATTERN/REPLACEMENT) — where a decoded `\n` is ALSO the gate that routes the command to the cross-paragraph matcher (a `\n` in a find/replace pattern matches an in-paragraph line break OR the boundary between consecutive paragraphs — in the body or within one table cell, never across a cell wall; a `\n` in a replace REPLACEMENT means a paragraph mark, editor-style — single-line replacement across a boundary MERGES paragraphs, replacement `\n` SPLITS; untracked only, refuses under tracking). Decoded whitespace flows through the same real-character handling everything uses — the shared **`textToRunElements`** emitter in `core/blocks.tsx` (`textToRuns` + `RunElement`, nulls filtered) turns a real `\n`/`\t` into ``/``, so the note/comment/marginal emitters (which previously emitted a single raw `` that swallowed a newline) now render breaks just like a body paragraph; the markdown block splitter handles the `--markdown` side. Plain single-line text still collapses to one `` run, byte-identical to the old shape. ONLY those whitespace escapes decode: a `\`-before-punctuation (markdown's own `\*`/`\[`/`\.`, and a literal `\\`) passes through UNTOUCHED, so decoding can't corrupt markdown. The decode is INLINE-argv-only — `--text-file`/`--markdown-file`/`--from` read real characters from a file and `--batch` JSONL is already `JSON.parse`-decoded, so none are re-decoded; `--text-file` therefore stays the one channel where a bare `\n` really means backslash-n. -- **Comments are never anything but hints.** Every `` comment `read` writes into Markdown — locators (``) and the structural visibility annotations (`docx:section` — rendered at the section's START with an `applies-to="pX..pY (below)"` scope on deviating sections so the "which side of the boundary?" off-by-one is explicit, `docx:page`, `docx:table` (widths/borders + the `tables format` properties align/style/cell-margins/repeat-header/row-heights), `docx:cell` merge/shading/vAlign/halign/borders/margins, `docx:track-changes on|off pending=N` ALWAYS at the head (the one hint that states its default too — weak agents can't reliably read "no hint" as "off," and a wrong tracking guess is high-cost, so it's stated outright; `pending=N` likewise always, `pending=0` included, since the default accepted view hides a pending deletion outright, plus a compact `(hdr0: 1, fn2: 1)` when some pending changes live outside the body), `docx:header`/`docx:footer` for page headers/footers (content in a `text` attr, fields as `{page}`/`{date}`/… tokens, a `pending="tcN …"` attr naming the part's tracked changes in every view, and the text following the view — CriticMarkup under `--current`, safe inside the comment because `htmlAttr` escapes every `>` so a deletion's `--` can never form `-->` — authored via `docx headers`/`docx footers`; a UNIFORM marginal rides the head, a marginal that DIFFERS by section renders at that section's START, next to the content it governs), `docx:textbox tbxN anchor="pM"` … `docx:textbox-end tbxN` bracketing a text box's story, printed right after the paragraph that anchors it (the story's own paragraphs carry bare `tbxN:pK` locators), `docx:layout` flagging tab-aligned content that wraps in render — inside a multi-column section, OR a line whose trailing content rides a right-edge LEFT tab so a long value overflows the margin (the résumé `San`/`Francisco` split); the latter is also consolidated into ONE top-of-read `fix-all` summary naming the single-call cure `edit --at pN-pM --tabs right` (a render-only break Markdown can't show), and future image/style hints, all via `formatNote` in `cli/read/annotations.ts`) — is a READ-TIME VISIBILITY HINT. **Naming rule: a BARE comment is a locator (an address you pass to `--at`: ``, ``; a plain empty cell uses `` so it can be filled directly); a `docx:TYPE` comment is docx-cli METADATA** (structure/formatting). Metadata never rides a bare locator — it's its own `docx:` annotation, which may carry the relevant locator as a bare leading token (``). So `docx:` is greppable as "everything docx-cli added beyond addressing," and locators stay short. The Markdown importer DROPS them all (`walker.tsx`'s `case "html"` returns `[]`; the inline walker drops inline comments); they NEVER drive reconstruction or carry data through `read → create`. A comment can't silently corrupt the round-trip or smuggle structure back — what an agent sees is a hint, nothing more. The lossless view is `read --ast`; in-place `edit` preserves structure; the authoring verbs (`docx sections`, `docx tables …`) change it. Emit hints **deviation-only** (only what differs from the document default — even columns, default geometry, `single` borders, `align=left` emit nothing), with TWO deliberate exceptions that always emit even at their default: `docx:section` (every section gets a marker so the `sN` is always addressable) and `docx:track-changes` (states `off` and `pending=0` too, so a weak agent never infers state from a missing hint). Run FORMATTING is the one thing that's Markdown-representable and DOES round-trip, but it rides HTML ELEMENTS (``/``/``/``, via `gatherHtmlSpans`), NOT comments. Even `` (the dominant font/size note) is a hint: `read` omits the baseline from runs and shows the note so an agent can match it, but the importer drops it — a full `read → create` rebuild falls back to the template docDefaults (Calibri 11pt) for the dominant font/size. The footnote-shaped read annotations are hints too: the `[^tcN]` (`--current`) and `[^cN]` (`--comments`) labels and their definition lines are DROPPED on import (`remarkReadHintLabels` in `core/markdown/read-hint-labels.ts`; real notes always read as `[^fnN]`/`[^enN]`), so a pasted `--current` line behaves exactly like its label-free CriticMarkup. No comment, anywhere, is parse-back. +- **Comments are never anything but hints.** Every `` comment `read` writes into Markdown — locators (``) and the structural visibility annotations (`docx:section` — rendered at the section's START with an `applies-to="pX..pY (below)"` scope on deviating sections so the "which side of the boundary?" off-by-one is explicit, `docx:page`, `docx:table` (widths/borders + the `tables format` properties align/style/cell-margins/repeat-header/row-heights), `docx:cell` merge/shading/vAlign/halign/borders/margins, `docx:track-changes on|off pending=N` ALWAYS at the head (the one hint that states its default too — weak agents can't reliably read "no hint" as "off," and a wrong tracking guess is high-cost, so it's stated outright; `pending=N` likewise always, `pending=0` included, since the default accepted view hides a pending deletion outright, plus a compact `(hdr0: 1, fn2: 1)` when some pending changes live outside the body), `docx:header`/`docx:footer` for page headers/footers (content in a `text` attr, fields as `{page}`/`{date}`/… tokens, a `pending="tcN …"` attr naming the part's tracked changes in every view, and the text following the view — CriticMarkup under `--current`, safe inside the comment because `htmlAttr` escapes every `>` so a deletion's `--` can never form `-->` — authored via `docx headers`/`docx footers`; a UNIFORM marginal rides the head, a marginal that DIFFERS by section renders at that section's START, next to the content it governs), `docx:textbox tbxN anchor="pM"` … `docx:textbox-end tbxN` bracketing a text box's story, printed right after the paragraph that anchors it (the story's own paragraphs carry bare `tbxN:pK` locators), `docx:chart chartN type=… series=N points=N` trailing a native chart's `` `[chart]` `` token (title/size always when known, `data="external"|"none"` deviation-only; the data is `charts extract`), `docx:layout` flagging tab-aligned content that wraps in render — inside a multi-column section, OR a line whose trailing content rides a right-edge LEFT tab so a long value overflows the margin (the résumé `San`/`Francisco` split); the latter is also consolidated into ONE top-of-read `fix-all` summary naming the single-call cure `edit --at pN-pM --tabs right` (a render-only break Markdown can't show), and future image/style hints, all via `formatNote` in `cli/read/annotations.ts`) — is a READ-TIME VISIBILITY HINT. **Naming rule: a BARE comment is a locator (an address you pass to `--at`: ``, ``; a plain empty cell uses `` so it can be filled directly); a `docx:TYPE` comment is docx-cli METADATA** (structure/formatting). Metadata never rides a bare locator — it's its own `docx:` annotation, which may carry the relevant locator as a bare leading token (``). So `docx:` is greppable as "everything docx-cli added beyond addressing," and locators stay short. The Markdown importer DROPS them all (`walker.tsx`'s `case "html"` returns `[]`; the inline walker drops inline comments); they NEVER drive reconstruction or carry data through `read → create`. A comment can't silently corrupt the round-trip or smuggle structure back — what an agent sees is a hint, nothing more. The lossless view is `read --ast`; in-place `edit` preserves structure; the authoring verbs (`docx sections`, `docx tables …`) change it. Emit hints **deviation-only** (only what differs from the document default — even columns, default geometry, `single` borders, `align=left` emit nothing), with TWO deliberate exceptions that always emit even at their default: `docx:section` (every section gets a marker so the `sN` is always addressable) and `docx:track-changes` (states `off` and `pending=0` too, so a weak agent never infers state from a missing hint). Run FORMATTING is the one thing that's Markdown-representable and DOES round-trip, but it rides HTML ELEMENTS (``/``/``/``, via `gatherHtmlSpans`), NOT comments. Even `` (the dominant font/size note) is a hint: `read` omits the baseline from runs and shows the note so an agent can match it, but the importer drops it — a full `read → create` rebuild falls back to the template docDefaults (Calibri 11pt) for the dominant font/size. The footnote-shaped read annotations are hints too: the `[^tcN]` (`--current`) and `[^cN]` (`--comments`) labels and their definition lines are DROPPED on import (`remarkReadHintLabels` in `core/markdown/read-hint-labels.ts`; real notes always read as `[^fnN]`/`[^enN]`), so a pasted `--current` line behaves exactly like its label-free CriticMarkup. No comment, anywhere, is parse-back. - **Everything we parse MUST be represented in the AST**. Markdown is always either a subset or equal to what is in the AST, never less than it. `RUN_BEARING_WRAPPER_TAGS` in `src/core/parser/run-ops.ts` is the AST↔XML offset bridge; every offset-aware walker reads it. See [src/core](src/core/CLAUDE.md). - **`` is transparent at EVERY level, and text boxes are first-class.** ECMA-376 Part 3 lets a producer wrap a block, a paragraph's runs, or a run's children in a markup-compatibility wrapper and nest wrappers inside branches; Word writes every modern shape/text box that way (`wps` under ``, VML under ``, SAME story in both). Every walker resolves it through `alternateContentBranch` in `src/core/mc.ts` (first Choice, else Fallback) — the reader at block/paragraph/run level, the offset walkers via `wrapperContent(node)` in `parser/run-ops.ts` (NEVER `node.children` for a wrapper — that is what keeps a multi-Choice wrapper counted exactly once), and the text-box collector while descending a shape. Dropping the wrapper was issue #4: `replace --all` reported 2 and exited 0 while a third "Acme" sat in a text box a human reads first. A `` story is a block container like a cell (`TextBoxRun` on the anchor paragraph, `tbxN:pK` ids, `blockReferences` parents inside the Choice copy), so `find`/`replace`/`edit`/`insert`/`delete`/`comments` reach it by default; `Document.save` (and the raw gate, BEFORE validating) mirrors each Choice story onto its Fallback twin (`syncTextBoxFallbacks`, post-order so nested boxes sync inner-first) — verified against Word for Mac 365: Word regenerates a box's Fallback when IT edits the box and leaves an untouched box's Fallback verbatim (a stale one stays stale forever), so mirroring on our writes is exactly its behavior. `wc` counts what `read` shows — text boxes included, everywhere. **Word discards comments and footnotes/endnotes inside a text box on its next save** (verified: the markers vanish from the story and the comment from comments.xml, regardless of the Fallback), so `comments add` (`--at` AND `--anchor`) and `footnotes`/`endnotes add` REFUSE a `tbxN` target with `UNSUPPORTED` + a pointer to the anchor paragraph; tracked changes and hyperlinks inside a box survive Word and are allowed. The box rides its anchor RUN: EVERY whole-paragraph `edit` lifts object-bearing runs (drawing/pict/object/AlternateContent) out and puts them back — `--text` around the token diff (`extractObjectRuns`), `--markdown`/`--runs` around the node replace (`liftObjectRuns` → `reattachObjectRuns` in `track-changes/replace.tsx`, untracked carriers even under tracking; a `--markdown` that re-states an image via `![alt](sha256.ext)` drops the lifted DRAWING so `img0` isn't duplicated, while a text box always returns), a `replace` on the anchor run's text keeps a trailing zero-width shape (`sliceRun`), and a tracked delete of the anchor wraps the run in `` WITHOUT rewriting the story's `` (`TextBoxRun.trackedChange` hides it in the accepted view; `iterateBlocks({ view })`). An UNTRACKED removal of the anchor paragraph (`delete --at pN`, `edit --at pN --text ""`) re-anchors the box on the neighboring paragraph — previous, else next — via `relocateObjectRuns` in `track-changes/replace.tsx`; deleting the invoice's Notes line used to take the stamp box with it, silently. -- **Every element must have stable positional ids for locators** (`p0`, `t0`, `c0`, `img0`, `link0`, `tc0`, `hdr0`/`ftr0` for page headers/footers, `tbx0` for a text box — its story chains like a cell: `tbx0:p1`, `tbx0:t0:r0c0:p0`). Block ids shift after structural edits. Re-read between non-trivial mutations. To not force agents to re-read on every modification, we create batch versions of any editing call such that the already made locators can be used. +- **Every element must have stable positional ids for locators** (`p0`, `t0`, `c0`, `img0`, `chart0`, `link0`, `tc0`, `hdr0`/`ftr0` for page headers/footers, `tbx0` for a text box — its story chains like a cell: `tbx0:p1`, `tbx0:t0:r0c0:p0`). Block ids shift after structural edits. Re-read between non-trivial mutations. To not force agents to re-read on every modification, we create batch versions of any editing call such that the already made locators can be used. - **Locators are the backbone of the system.** Agents use these to reliably identify elements, ranges (both bounded and one-side bounded). If you're introducing a new concept, it is CRITICAL that these work and work well. - **The CLI surface should be thin.** The CLI surface is concerned with parsing user input. The core is where the actual domain logic happens. - **Never delete a relationship or part something still references** — a dangling rId corrupts the file ("unreadable content"), but an unreferenced part is harmless. So pruning is gated on a reference check that scans **everything we don't model** (VML `` fallbacks, OLE objects, ``, chart rels), not just the construct we authored: `isRelationshipReferenced(documentTree, rId)` before dropping a relationship, `hasRelationshipWithTarget` before deleting a shared media part (both in `core/relationships.ts`). When in doubt, leave the orphan. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12c3582..ce51a4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,7 @@ src/ diff/ # docx diff FILE --against SRC (markers.ts: locator normalization; renders read view, diffs via @core/diff) comments/ # add | reply | resolve | delete | list (helpers.tsx: paraId, run-splitting) images/ # list | extract | replace | delete + charts/ # list | extract (read-only; thin shell over @core/chart) hyperlinks/ # add | list | replace | delete (wrap.tsx: run-splitting) tables/ # row/column insert+delete | set-widths | merge | unmerge | borders (grid.ts: merge-aware model) track-changes/ # on|off | list | accept | reject (apply.ts holds the unwrap/delete logic) @@ -55,6 +56,7 @@ src/ ast/ # types + DocView + XML→AST reader; text.ts (paragraph helpers), sym.ts ( decode) locators/ # parse "p3:5-20" → Locator + resolve to refs image/ # drawing.tsx ( + addImagePart + collectImageRuns), source.ts (load path/data:/http + HEIC→JPEG), formats.ts (one mime↔ext table) + chart/ # native charts: read.ts (chart part → cached series), charts.ts (Charts lens), csv.ts equation/ # latexToOmml / ommlToLatex bidirectional (temml + own MathML↔OMML adapter) markdown/ # MarkdownImport lens: remark-parse + remark-gfm + remark-math + CriticMarkup plugin → walker → emitters raw/ # Raw lens: fragment gate pipeline (element-order, namespaces+dcx:raw marker, reference audit) + libxml2-wasm XSD engine over bundled ECMA-376 schemas (schemas/) diff --git a/REFERENCE.md b/REFERENCE.md index 263489d..88e4e1d 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -40,6 +40,7 @@ docx endnotes list FILE docx headers list FILE docx footers list FILE docx images list FILE +docx charts list FILE docx hyperlinks list FILE docx track-changes list FILE @@ -133,6 +134,15 @@ size="…in" margins="…in" text-width="…in" -->` note when the page deviates `float`/`wrap`/`align`/`overflow` only when they deviate (an inline, in-bounds image shows just its size). `overflow` flags an image wider than the usable text column (`ImageRun.floating`/`wrap`/`align` + EMU extents in `read --ast`). +- **Charts** (native Word charts) keep a `` `[chart]` `` token followed by a + `` + note: `chartN` (the `charts extract --at` handle), `type` (a combo chart joins + its types: `column+line`), the series/points counts and `size` always; `title` + when there is one; `data="external"` / `data="none"` only when the chart isn't + backed by an embedded workbook; `float`/`wrap`/`align` as for images. The data + itself is `docx charts extract` (CSV) or `read --ast` (`ChartRun.series`, read + from the chart part's caches — what Word draws). Charts are read-only for now: + editing the paragraph around one keeps the chart part and its workbook intact. - **Headers / footers** surface as `` / `` notes — led by the marginal's `hdrN`/`ftrN` id, the handle `raw get --at` and `headers`/`footers @@ -296,6 +306,9 @@ docx images extract FILE --to DIR [--at imgN] # --to = output directo docx images replace FILE --at imgN --with ./new.png docx images delete FILE --at imgN +docx charts list FILE # JSON array: {id, at, type, title?, series, points, data, workbook?} +docx charts extract FILE --at chartN [--json] # the chart's cached data as CSV (category,); --json = full series objects + docx hyperlinks add FILE --at pN:S-E --url URL docx hyperlinks replace FILE --at linkN --with URL docx hyperlinks delete FILE --at linkN @@ -445,7 +458,7 @@ The CLI is built for non-interactive agents. **Exit code is the success signal** ## Discovering ids -Locators come in two flavors. **Positional block ids** (`pN`, `tN`, `sN`) are derived from document order and **shift after structural edits** — re-read between non-trivial mutations. **Entity ids** (`cN`, `imgN`, `linkN`, `fnN`, `enN`, `tcN`, `eqN`, `hdrN`, `ftrN`) are surfaced by a `list` verb (or `read --ast`) and are what you pass to `--at`: +Locators come in two flavors. **Positional block ids** (`pN`, `tN`, `sN`) are derived from document order and **shift after structural edits** — re-read between non-trivial mutations. **Entity ids** (`cN`, `imgN`, `chartN`, `linkN`, `fnN`, `enN`, `tcN`, `eqN`, `hdrN`, `ftrN`) are surfaced by a `list` verb (or `read --ast`) and are what you pass to `--at`: | Id | Discover with | Used by | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | @@ -454,6 +467,7 @@ Locators come in two flavors. **Positional block ids** (`pN`, `tN`, `sN`) are de | `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` | +| `chartN` (chart) | `docx charts list FILE` (or the `` note / `read --ast`) | `charts extract --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` | diff --git a/skills/docx-cli/SKILL.md b/skills/docx-cli/SKILL.md index 0c21450..fc341c0 100644 --- a/skills/docx-cli/SKILL.md +++ b/skills/docx-cli/SKILL.md @@ -63,8 +63,8 @@ it if you use `--ast`. text box N (a floating stamp / pull quote / letterhead box — `read` prints its story right after the anchor paragraph between `docx:textbox tbxN` hints, and `find` / `replace --all` include it). -- Entities: `cN` comment, `imgN` image, `linkN` hyperlink, `fnN`/`enN` - foot/endnote, `tcN` tracked change, `eqN` equation. +- Entities: `cN` comment, `imgN` image, `chartN` chart, `linkN` hyperlink, + `fnN`/`enN` foot/endnote, `tcN` tracked change, `eqN` equation. - Get them from `docx read FILE` (locators ride the Markdown as `` comments; a plain empty cell appears as ``) or `docx read FILE --ast` (lossless JSON). diff --git a/skills/docx-cli/references/commands.md b/skills/docx-cli/references/commands.md index a7cc076..6eca6d1 100644 --- a/skills/docx-cli/references/commands.md +++ b/skills/docx-cli/references/commands.md @@ -39,6 +39,7 @@ docx info skill # this skill, regenerated from the binary | `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`. | +| `charts` | `list` (`chartN`, type, title, series), `extract --at chartN` (data as CSV, or `--json`). Read-only for now. | | `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..47548c2 100644 --- a/src/cli/CLAUDE.md +++ b/src/cli/CLAUDE.md @@ -240,6 +240,15 @@ Full contract in [src/core/markdown/CLAUDE.md](../core/markdown/CLAUDE.md). New structural annotation? Use `formatNote`, keep it deviation-only, and remember the importer won't reconstruct it — NO comment is parse-back, not even `docx:base`. +## `charts` — read-only chart surface + +`docx charts list` / `charts extract --at chartN [--json]` are thin shells over +the `Charts` lens in [`@core/chart`](../core/chart/CLAUDE.md); `read` prints +each native chart as `` `[chart]` `` + `` (`formatChartNote` in `read/markdown.ts`, sharing +`chartTypeName`/`chartPointCount` with `list` so they can't disagree). An +unknown id fails `CHART_NOT_FOUND` (exit 3). No chart verb mutates yet. + ## Help leads with markdown; `--text` is the literal escape hatch The read view is Markdown, so the natural write-back is markdown — and it's the DEFAULT: `edit`/`insert` take the content as a POSITIONAL argument after the flags (`docx edit FILE --at p3 "Net **30** days"`), which `adoptPositionalMarkdown` (`cli/parse-helpers.ts`) folds into `values.markdown` before validation so every downstream check sees one path; `--markdown` stays as an alias. A second content positional is REFUSED (an unquoted phrase would otherwise silently truncate to its first word), as is positional + `--markdown` or positional + `--batch`. The `edit`/`insert` help (and the top-level one-liners) show the positional form FIRST, in the usage line and the examples, and describe `--text` as the LITERAL channel (`--text "**bold**"` writes the asterisks) for values Markdown would mangle or for a character span. Weak agents copy `**…**` from the read view straight into `--text`/`replace` and ship literal asterisks (the résumé b2-r3 run); three of five agents also read `edit --help` through `head -50`/`head -80`, so the contract sits in the first ten lines. Alignment/spacing/indent/tabs ride along with `--markdown` on both verbs (applied to every paragraph the source produces); only `--style` (and insert's `--list`) is refused next to it, because a `# heading` or list item owns those. `insert` also blends a new paragraph into its neighbor like Word's Enter key — font/size plus tabs, indent, alignment and spacing (`inheritParagraphLayout` in `core/insert/inherit.ts`) — unless the caller sets them; a heading anchor lends nothing, a list anchor lends no layout. diff --git a/src/cli/charts/extract.ts b/src/cli/charts/extract.ts new file mode 100644 index 0000000..70b17d4 --- /dev/null +++ b/src/cli/charts/extract.ts @@ -0,0 +1,77 @@ +import { Charts, chartToCsv } from "@core/chart"; +import { + EXIT, + fail, + openOrFail, + respond, + tryParseArgs, + writeStdout, +} from "../respond"; + +const HELP = `docx charts extract — print one chart's data + +Usage: + docx charts extract FILE --at chartN [--json] + +Required: + --at chartN The chart (e.g. chart0; see \`docx charts list\`) + +Optional: + --json Print the full series objects (name, categories, values, + workbook refs, formatCode) instead of CSV + -h, --help Show this help + +Output: + CSV (RFC 4180) by default: a header row "category,,,…", + then one row per category. An empty cell is a gap in the data, not 0. + The values are the chart's CACHE — what Word draws on the page. Redirect to + save it: docx charts extract report.docx --at chart0 > chart0.csv + Errors print {code, error, hint?} with a nonzero exit. + +Examples: + docx charts extract report.docx --at chart0 + docx charts extract report.docx --at chart1 --json | jq '.series[0].values' +`; + +export async function run(args: string[]): Promise { + const parsed = await tryParseArgs( + args, + { + at: { type: "string" }, + json: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + HELP, + ); + if (typeof parsed === "number") return parsed; + + if (parsed.values.help) { + await writeStdout(HELP); + return EXIT.OK; + } + + const path = parsed.positionals[0]; + if (!path) return fail("USAGE", "Missing FILE argument", HELP); + + const chartId = parsed.values.at as string | undefined; + if (!chartId) return fail("USAGE", "Missing --at chartN", HELP); + + const document = await openOrFail(path); + if (typeof document === "number") return document; + + const chart = new Charts(document).get(chartId); + if (!chart) { + return fail( + "CHART_NOT_FOUND", + `Chart not found: ${chartId}`, + `Run "docx charts list ${path}" to see the chart ids.`, + ); + } + + if (parsed.values.json) { + await respond({ id: chart.id, title: chart.title, series: chart.series }); + return EXIT.OK; + } + await writeStdout(chartToCsv(chart)); + return EXIT.OK; +} diff --git a/src/cli/charts/index.ts b/src/cli/charts/index.ts new file mode 100644 index 0000000..baa1bc0 --- /dev/null +++ b/src/cli/charts/index.ts @@ -0,0 +1,41 @@ +import { fail, writeStdout } from "../respond"; + +type CommandFn = (args: string[]) => Promise; + +const SUBCOMMANDS: Record Promise<{ run: CommandFn }>> = { + extract: () => import("./extract"), + list: () => import("./list"), +}; + +const HELP = `docx charts — inspect native Word charts + +Usage: + docx charts FILE [options] + +Verbs: + list Print the chart manifest as JSON (id, type, title, series, points) + extract Print one chart's data as CSV (or --json) — the cached values Word draws + +Charts show in \`docx read\` as \`[chart]\` plus a hint; +chartN is the --at handle. + +Run "docx charts --help" for verb-specific help. +`; + +export async function run(args: string[]): Promise { + const verb = args[0]; + if (!verb || verb === "--help" || verb === "-h" || verb === "help") { + await writeStdout(HELP); + return verb ? 0 : 2; + } + const loader = SUBCOMMANDS[verb]; + if (!loader) { + return fail( + "USAGE", + `Unknown charts subcommand: ${verb}`, + 'Run "docx charts --help".', + ); + } + const module_ = await loader(); + return module_.run(args.slice(1)); +} diff --git a/src/cli/charts/list.ts b/src/cli/charts/list.ts new file mode 100644 index 0000000..de3e434 --- /dev/null +++ b/src/cli/charts/list.ts @@ -0,0 +1,58 @@ +import { Charts } from "@core/chart"; +import { + EXIT, + fail, + openOrFail, + respond, + tryParseArgs, + writeStdout, +} from "../respond"; + +const HELP = `docx charts list — print the chart manifest as JSON + +Usage: + docx charts list FILE [options] + +Options: + -h, --help Show this help + +Output: + A bare JSON array, one object per native chart in document order: + { id, at, type, title?, series, points, data, workbook? }. + id chartN — the --at handle for \`charts extract\` + at the paragraph holding the chart (pN, t0:r0c0:p0, tbx0:p0, …) + type chart type; a combo chart joins its types with "+" (column+line) + series series names + points number of categories plotted + data where Word's Edit Data reads from: embedded | external | none + workbook the embedded workbook's part name, when data is "embedded" + Errors print {code, error, hint?} with a nonzero exit. + +Examples: + docx charts list report.docx | jq -c '.[] | {id, type, title}' +`; + +export async function run(args: string[]): Promise { + const parsed = await tryParseArgs( + args, + { + help: { type: "boolean", short: "h" }, + }, + HELP, + ); + if (typeof parsed === "number") return parsed; + + if (parsed.values.help) { + await writeStdout(HELP); + return EXIT.OK; + } + + const path = parsed.positionals[0]; + if (!path) return fail("USAGE", "Missing FILE argument", HELP); + + const document = await openOrFail(path); + if (typeof document === "number") return document; + + await respond(new Charts(document).list()); + return EXIT.OK; +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 00da038..e7c5a58 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -44,6 +44,7 @@ Commands (each one-liner names capabilities you'd otherwise miss; see 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 + charts … Inspect native Word charts — list (chartN, type, title, series), extract a chart's data as CSV (--at chartN); read shows each as \`[chart]\` + a docx:chart hint 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/index.ts b/src/cli/index.ts index c9b2a65..5abf5a5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import { fail, writeStderr, writeStdout } from "./respond"; type CommandFn = (args: string[]) => Promise; const COMMANDS: Record Promise<{ run: CommandFn }>> = { + charts: () => import("./charts"), code: () => import("./code"), comments: () => import("./comments"), create: () => import("./create"), diff --git a/src/cli/info/locators.ts b/src/cli/info/locators.ts index d08fb33..1235c86 100644 --- a/src/cli/info/locators.ts +++ b/src/cli/info/locators.ts @@ -137,6 +137,7 @@ Discovering ids: default Markdown render) cN docx comments list FILE imgN docx images list FILE + chartN docx charts list FILE linkN docx hyperlinks list FILE fnN / enN docx footnotes list FILE / docx endnotes list FILE hdrN / ftrN docx headers list FILE / docx footers list FILE @@ -186,6 +187,7 @@ function renderJson(): unknown { "pN/tN/sN": "docx read FILE --ast", cN: "docx comments list FILE", imgN: "docx images list FILE", + chartN: "docx charts list FILE", linkN: "docx hyperlinks list FILE", "fnN/enN": "docx footnotes list FILE / docx endnotes list FILE", "hdrN/ftrN": "docx headers list FILE / docx footers list FILE", diff --git a/src/cli/info/schema.ts b/src/cli/info/schema.ts index bad3fca..9fdc0d4 100644 --- a/src/cli/info/schema.ts +++ b/src/cli/info/schema.ts @@ -258,10 +258,50 @@ const JSON_SCHEMA = { }, ChartRun: { type: "object", + description: + 'A non-picture drawing. kind "chart" is a native chart with its chartN id and cached data; other kinds are bare placeholders.', required: ["type", "kind"], properties: { type: { const: "chart" }, kind: { enum: ["chart", "shape", "smartart", "drawing"] }, + id: { type: "string", description: "chartN (kind chart only)" }, + chartTypes: { + type: "array", + items: { type: "string" }, + description: + 'One per chart group: "column", "bar", "line", "pie", "pie3D", "area", "scatter", … (several for a combo chart)', + }, + title: { type: "string" }, + series: { type: "array", items: { $ref: "#/$defs/ChartSeries" } }, + dataSource: { enum: ["embedded", "external", "none"] }, + workbook: { + type: "string", + description: "Embedded workbook part name (dataSource embedded)", + }, + widthEmu: { type: "number" }, + heightEmu: { type: "number" }, + floating: { type: "boolean" }, + wrap: { type: "string" }, + align: { type: "string" }, + trackedChange: { $ref: "#/$defs/RunTrackedChange" }, + }, + }, + ChartSeries: { + type: "object", + description: + "One chart series from the chart part's caches; a null point is a gap.", + required: ["categories", "values"], + properties: { + name: { type: "string" }, + nameRef: { type: "string" }, + categories: { + type: "array", + items: { type: ["string", "null"] }, + }, + categoriesRef: { type: "string" }, + values: { type: "array", items: { type: ["number", "null"] } }, + valuesRef: { type: "string" }, + formatCode: { type: "string" }, }, }, TextBoxRun: { diff --git a/src/cli/info/skill.ts b/src/cli/info/skill.ts index fea51d4..ef11cc6 100644 --- a/src/cli/info/skill.ts +++ b/src/cli/info/skill.ts @@ -116,8 +116,8 @@ it if you use \`--ast\`. text box N (a floating stamp / pull quote / letterhead box — \`read\` prints its story right after the anchor paragraph between \`docx:textbox tbxN\` hints, and \`find\` / \`replace --all\` include it). -- Entities: \`cN\` comment, \`imgN\` image, \`linkN\` hyperlink, \`fnN\`/\`enN\` - foot/endnote, \`tcN\` tracked change, \`eqN\` equation. +- Entities: \`cN\` comment, \`imgN\` image, \`chartN\` chart, \`linkN\` hyperlink, + \`fnN\`/\`enN\` foot/endnote, \`tcN\` tracked change, \`eqN\` equation. - Get them from \`docx read FILE\` (locators ride the Markdown as \`\` comments; a plain empty cell appears as \`\`) or \`docx read FILE --ast\` (lossless JSON). diff --git a/src/cli/read/markdown.ts b/src/cli/read/markdown.ts index d2c1456..82ab17d 100644 --- a/src/cli/read/markdown.ts +++ b/src/cli/read/markdown.ts @@ -11,6 +11,7 @@ import { type Locator, LocatorParseError, type Marginal, + type NativeChartRun, type Paragraph, parseLocator, type Run, @@ -22,6 +23,7 @@ import { type TextRun, type TrackedChange, } from "@core"; +import { chartPointCount, chartTypeName } from "@core/chart"; import { codeBlockLanguageFromStyleId, isCodeBlockStyleId, @@ -778,22 +780,52 @@ function contentWidthEmu(geometry: SectionBreak | undefined): number { * decision-relevant); `overflow`/`float`/`wrap`/`align` are deviation-only * (an inline, in-bounds image emits just its size). A DROPPED read-time hint. */ 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`, - ]); - } - if (run.floating) pairs.push(["float", "yes"]); - if (run.wrap) pairs.push(["wrap", run.wrap]); - if (run.align) pairs.push(["align", run.align]); + const pairs: NotePair[] = [...sizePairs(run)]; + pairs.push(...placementPairs(run)); if (run.widthEmu && run.widthEmu > contentEmu) pairs.push(["overflow", "yes"]); if (pairs.length === 0) return ""; return ` ${formatNote("image", pairs, [run.id])}`; } +/** The `` annotation for a native chart. `type`, the series/points + * counts and `size` always show (they're what an agent decides on); `title` + * when there is one; `data` only when it deviates from an embedded workbook + * (`external` / `none`); `float`/`wrap`/`align` as for images. A DROPPED + * read-time hint — the data itself is `docx charts extract`. */ +function formatChartNote(run: NativeChartRun): string { + const pairs: NotePair[] = [["type", chartTypeName(run)]]; + if (run.title) pairs.push(["title", run.title]); + pairs.push(["series", run.series.length]); + pairs.push(["points", chartPointCount(run)]); + pairs.push(...sizePairs(run)); + if (run.dataSource !== "embedded") pairs.push(["data", run.dataSource]); + pairs.push(...placementPairs(run)); + return formatNote("chart", pairs, [run.id]); +} + +/** `size="WxHin"` for a drawing with both extents. */ +function sizePairs(run: { widthEmu?: number; heightEmu?: number }): NotePair[] { + if (!run.widthEmu || !run.heightEmu) return []; + return [ + ["size", `${emuToInches(run.widthEmu)}x${emuToInches(run.heightEmu)}in`], + ]; +} + +/** Deviation-only placement of a floating drawing: `float`/`wrap`/`align`. */ +function placementPairs(run: { + floating?: boolean; + wrap?: string; + align?: string; +}): NotePair[] { + const pairs: NotePair[] = []; + if (run.floating) pairs.push(["float", "yes"]); + if (run.wrap) pairs.push(["wrap", run.wrap]); + if (run.align) pairs.push(["align", run.align]); + return pairs; +} + /** The section break carrying the document-wide geometry — the trailing * Returns the FIRST geometry-bearing section (page 1 — what the reader sees first * and the most intuitive "document geometry"), plus whether any later @@ -1608,7 +1640,17 @@ function renderRuns( else ctx.referencedEndnoteIds.add(run.id); out += `[^${run.id}]`; } else if (run.type === "chart") { - out += `\`[${run.kind}]\``; + // A native chart keeps the placeholder token and adds its `chartN` + + // type/title/series hint; it rides its revision like an image does. + out += + run.kind === "chart" + ? markRevision( + `\`[chart]\` ${formatChartNote(run)}`, + run.trackedChange, + view, + ctx, + ) + : `\`[${run.kind}]\``; } // A text box contributes nothing inline: its story renders as its own // block right after the anchor (see `renderTextBoxes`). diff --git a/src/cli/respond.ts b/src/cli/respond.ts index a08f7a7..bff82d6 100644 --- a/src/cli/respond.ts +++ b/src/cli/respond.ts @@ -26,6 +26,7 @@ export type ErrorCode = | "BLOCK_NOT_FOUND" | "COMMENT_NOT_FOUND" | "IMAGE_NOT_FOUND" + | "CHART_NOT_FOUND" | "IMAGE_SOURCE" | "HYPERLINK_NOT_FOUND" | "RELATIONSHIP_NOT_FOUND" @@ -281,6 +282,7 @@ function exitCodeFor(code: ErrorCode): number { case "BLOCK_NOT_FOUND": case "COMMENT_NOT_FOUND": case "IMAGE_NOT_FOUND": + case "CHART_NOT_FOUND": case "HYPERLINK_NOT_FOUND": case "RELATIONSHIP_NOT_FOUND": case "TRACKED_CHANGE_NOT_FOUND": diff --git a/src/core/ast/CLAUDE.md b/src/core/ast/CLAUDE.md index 15438fa..f98cafe 100644 --- a/src/core/ast/CLAUDE.md +++ b/src/core/ast/CLAUDE.md @@ -9,6 +9,7 @@ - `RelationshipsView`, `ContentTypesView`, `StylesView`, `NumberingView`, `CommentsView`, `NotesView` (×2 — footnotes/endnotes), `SettingsView`, `CorePropertiesView`. Same lifecycle on each: `static fromPackage(pkg)`, `static fromXml(xml)`, instance `writeTo(pkg)`, and (for the optional ones) `static register(deps)` that mints the relationship + content-type and returns a fresh view. - `CommentsView` is a folder ([document/comments/](document/comments/)) because it owns FOUR parts: `comments.xml` plus Word's paraId-keyed sidecars (`commentsExtended`, `commentsIds`, `commentsExtensible`). Its `writeTo` first runs `reconcileSidecars` ([reconcile-sidecars.ts](document/comments/reconcile-sidecars.ts)) — Word for Mac's own open-time repairs (mint missing paraIds and entries, re-key out-of-range paraIds WITH their sidecar entries and reply links — Word re-keys without them and drops every thread — prune stale ones, de-dupe durableIds; never alter an existing `paraIdParent`/`done`; never create an absent sidecar) — so EVERY save of a commented document cures a file an older producer left inconsistent, then writes only the parts whose serialization changed (an untouched sidecar keeps its original bytes). The same function, run on a clone, is what `docx validate` reports, so the report and the cure can't drift. paraId/durableId minting lives in [para-id.ts](document/comments/para-id.ts). - `MarginalsView` ([document/marginals.ts](document/marginals.ts)) — the multi-part exception: ONE view owns every `word/header{N}.xml` / `word/footer{N}.xml` tree, keyed by part name. `fromPackage` scans `pkg.listParts()`; `writeTo` serializes all. No `register` — the [`Marginals`](../../marginals/CLAUDE.md) lens mints each part's rel + content-type as it allocates the part. + - `ChartsView` ([document/charts.ts](document/charts.ts)) — the chart parts a document `chart` relationship targets, each with its own rels, preloaded in `fromPackage` so the synchronous body walk can parse them (`readChart` → `readChartContent` in [`@core/chart`](../chart/CLAUDE.md)). READ-ONLY: no `writeTo`, so chart parts and workbooks stay byte-identical across saves. - `Pkg` ([document/package.ts](document/package.ts)) — the generic OPC zip transport. Imports only `XmlNode` + JSZip. `Pkg.open` / `Pkg.empty` / `readPart` / `writeText` / `writeBytes` / `save`. Stays domain-agnostic. - [read.ts](read.ts) — the single walk over `` and the note parts; populates Body's maps, registers every tracked change into `Document.trackedChangeReferences`, and asks each part-view for its AST slice (`CommentsView.toComments`, `NotesView.toNotes`; `readMarginals` resolves each section's header/footer references → `MarginalsView` part trees → `Body.headers`/`footers`). - [types.ts](types.ts) — the AST types (`Block`, `Paragraph`, `Run`, `TextRun`, `Table`, `Comment`, `Note`, `TrackedChange`, …). What `read --ast` and `info schema` expose. diff --git a/src/core/ast/document/charts.ts b/src/core/ast/document/charts.ts new file mode 100644 index 0000000..96cc0d7 --- /dev/null +++ b/src/core/ast/document/charts.ts @@ -0,0 +1,79 @@ +import { XmlNode } from "../../parser"; +import type { Pkg } from "./package"; +import { + RelationshipsView, + relsPartNameFor, + resolveRelationshipTarget, +} from "./relationships"; + +/** One chart part: its `` tree plus its OWN rels (where + * `` resolves to the embedded or linked workbook). */ +export type ChartPart = { + tree: XmlNode[]; + relationships: RelationshipsView; +}; + +/** The chart parts (`word/charts/chartN.xml`) the document body references, + * keyed by part name — the multi-part shape `MarginalsView` set. READ-ONLY for + * now: the reader parses the cached series out of these trees, and nothing + * mutates them, so there is deliberately no `writeTo` — an unrelated save + * never re-serializes a chart part, and the chart, its rels and its workbook + * come through byte-identical. A future `charts set-data` that edits them adds + * the write path. */ +export class ChartsView { + constructor(private readonly parts: Map) {} + + /** Load every part a `chart` relationship of `word/document.xml` targets + * (transitional or Strict relationship type), with its rels part. */ + static async fromPackage( + pkg: Pkg, + documentRelationships: RelationshipsView, + ): Promise { + const parts = new Map(); + for (const relationship of documentRelationships.list()) { + if (!isChartRelationshipType(relationship.type)) continue; + if (relationship.targetMode === "External") continue; + const partName = resolveRelationshipTarget( + "word/document.xml", + relationship.target, + ); + if (parts.has(partName)) continue; + const tree = await pkg.readPart(partName); + if (!tree) continue; + const relsPartName = relsPartNameFor(partName); + parts.set(partName, { + tree, + relationships: await RelationshipsView.fromPackage(pkg, relsPartName), + }); + } + return new ChartsView(parts); + } + + /** Build a view from raw chart XML keyed by part name — the synthetic + * `Document.fromXml` path for reader tests. */ + static fromXml( + parts: Record = {}, + ): ChartsView { + const map = new Map(); + for (const [partName, { xml, relsXml }] of Object.entries(parts)) { + map.set(partName, { + tree: XmlNode.parse(xml), + relationships: RelationshipsView.fromXml( + relsXml, + relsPartNameFor(partName), + ), + }); + } + return new ChartsView(map); + } + + get(partName: string): ChartPart | undefined { + return this.parts.get(partName); + } +} + +/** `…/officeDocument/2006/relationships/chart` (transitional) or + * `http://purl.oclc.org/ooxml/officeDocument/relationships/chart` (Strict). */ +function isChartRelationshipType(type: string): boolean { + return type.endsWith("/relationships/chart"); +} diff --git a/src/core/ast/document/index.ts b/src/core/ast/document/index.ts index c1f8933..939bca4 100644 --- a/src/core/ast/document/index.ts +++ b/src/core/ast/document/index.ts @@ -3,6 +3,7 @@ import { declareUsedNamespaces } from "../../namespaces"; import { XmlNode } from "../../parser"; import { buildBody } from "../read"; import type { Body, TrackedChangeReference } from "./body"; +import { ChartsView } from "./charts"; import { CommentsView } from "./comments"; import { ContentTypesView } from "./content-types"; import { CorePropertiesView } from "./core-properties"; @@ -27,6 +28,7 @@ export class Document { settings?: SettingsView; styles?: StylesView; marginals?: MarginalsView; + charts?: ChartsView; body!: Body; trackedChangeReferences: Map = new Map(); @@ -44,6 +46,7 @@ export class Document { settings?: SettingsView; styles?: StylesView; marginals?: MarginalsView; + charts?: ChartsView; }) { this.pkg = init.pkg; this.documentTree = init.documentTree; @@ -57,6 +60,7 @@ export class Document { this.settings = init.settings; this.styles = init.styles; this.marginals = init.marginals; + this.charts = init.charts; } static async open(path: string): Promise { @@ -76,10 +80,11 @@ export class Document { * audits the raw parts first, then repairs through the views of the SAME * package rather than reading the file twice. */ static async fromPackage(pkg: Pkg): Promise { + const relationships = await RelationshipsView.fromPackage(pkg); const view = new Document({ pkg, documentTree: await pkg.ensurePart("word/document.xml"), - relationships: await RelationshipsView.fromPackage(pkg), + relationships, contentTypes: await ContentTypesView.fromPackage(pkg), coreProperties: await CorePropertiesView.fromPackage(pkg), settings: await SettingsView.fromPackage(pkg), @@ -89,6 +94,7 @@ export class Document { endnotes: await NotesView.fromPackage(pkg, "endnote"), comments: await CommentsView.fromPackage(pkg), marginals: await MarginalsView.fromPackage(pkg), + charts: await ChartsView.fromPackage(pkg, relationships), }); view.body = buildBody(view, pkg.path); @@ -113,6 +119,9 @@ export class Document { commentsXml?: string; commentsExtendedXml?: string; corePropertiesXml?: string; + /** Chart parts keyed by part name (`word/charts/chart1.xml`), each with + * its optional rels XML. */ + chartParts?: Record; path?: string; }): Document { const path = parts.path ?? "synthetic.docx"; @@ -131,6 +140,7 @@ export class Document { parts.commentsXml, parts.commentsExtendedXml, ), + charts: ChartsView.fromXml(parts.chartParts), }); view.body = buildBody(view, path); return view; diff --git a/src/core/ast/document/relationships.ts b/src/core/ast/document/relationships.ts index e1a1390..0eda0ab 100644 --- a/src/core/ast/document/relationships.ts +++ b/src/core/ast/document/relationships.ts @@ -15,6 +15,35 @@ export function relsPartNameFor(partName: string): string { return `${dir}/_rels/${base}.rels`; } +/** Resolve a relationship `Target` to a package part name, against the + * directory of the part whose rels declare it: from `word/document.xml`, + * `media/image1.png` → `word/media/image1.png` and `../customXml/item1.xml` → + * `customXml/item1.xml`; from `word/charts/chart1.xml`, + * `../embeddings/Book1.xlsx` → `word/embeddings/Book1.xlsx`. A leading `/` is + * package-absolute. */ +export function resolveRelationshipTarget( + sourcePartName: string, + target: string, +): string { + const sourceDirectory = sourcePartName.slice( + 0, + sourcePartName.lastIndexOf("/") + 1, + ); + const base = target.startsWith("/") + ? target.slice(1) + : `${sourceDirectory}${target}`; + const segments: string[] = []; + for (const segment of base.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + const HYPERLINK_RELATIONSHIP_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; diff --git a/src/core/ast/index.ts b/src/core/ast/index.ts index 3a53e63..44d2b6e 100644 --- a/src/core/ast/index.ts +++ b/src/core/ast/index.ts @@ -24,12 +24,14 @@ export type { Block, BreakRun, ChartRun, + ChartSeries, Comment, CommentAnchor, DocProperties, EquationRun, Hyperlink, ImageRun, + NativeChartRun, Note, NoteRefRun, Paragraph, diff --git a/src/core/ast/read.ts b/src/core/ast/read.ts index 926b87c..55ae49a 100644 --- a/src/core/ast/read.ts +++ b/src/core/ast/read.ts @@ -1,4 +1,5 @@ import { alignmentFromJc, normalizeTabAlign } from "../blocks"; +import { readChartContent } from "../chart/read"; import { equationLatex, formatEquationToken, @@ -28,6 +29,7 @@ import { type TrackedEquation, } from "./document/body"; import { numFmtToFormat } from "./document/numbering"; +import { resolveRelationshipTarget } from "./document/relationships"; import { decodeSym } from "./sym"; import type { Block, @@ -40,6 +42,7 @@ import type { Hyperlink, ImageRun, Marginal, + NativeChartRun, Paragraph, Run, SectionBreak, @@ -59,6 +62,7 @@ type WalkState = { trackedChangeIndex: number; equationIndex: number; textBoxIndex: number; + chartIndex: number; /** The paragraph being walked — what a text box read from one of its runs * records as its anchor. Saved/restored around a story walk, since the * story's own paragraphs re-enter `readParagraph`. */ @@ -103,6 +107,7 @@ export function buildBody(document: Document, path: string): Body { trackedChangeIndex: 0, equationIndex: 0, textBoxIndex: 0, + chartIndex: 0, currentBlockId: "", commentAnchors: new Map(), openComments: new Map(), @@ -1143,7 +1148,8 @@ function readRun( out.push(image); } out.push(...textBoxes); - if (!image && textBoxes.length === 0) out.push(classifyDrawing(child)); + if (!image && textBoxes.length === 0) + out.push(classifyDrawing(document, child, state, trackedChange)); continue; } // Legacy embeds — a VML text box surfaces as its story; anything else @@ -1249,11 +1255,17 @@ function readTextBoxes( }); } -/** The placeholder for a that is neither a picture nor a text - * box: chart / SmartArt / plain shape / anything else. */ -function classifyDrawing(drawing: XmlNode): ChartRun { - if (drawing.findDescendant("c:chart")) - return { type: "chart", kind: "chart" }; +/** The run for a that is neither a picture nor a text box: a + * native chart (its own `chartN`, data read from the chart part), or a + * SmartArt / shape / anything-else placeholder. */ +function classifyDrawing( + document: Document, + drawing: XmlNode, + state: WalkState, + trackedChange: TrackedChange | undefined, +): ChartRun { + const chart = drawing.findDescendant("c:chart"); + if (chart) return readChart(document, drawing, chart, state, trackedChange); if (drawing.findDescendant("dgm:relIds")) return { type: "chart", kind: "smartart" }; if (drawing.findDescendant("wps:wsp")) @@ -1261,6 +1273,40 @@ function classifyDrawing(drawing: XmlNode): ChartRun { return { type: "chart", kind: "drawing" }; } +/** `` → the document rel → the chart part (preloaded by + * `ChartsView`) → its cached series. The id is minted even when the part + * can't be resolved, so `chartN` stays positional over every chart drawing. */ +function readChart( + document: Document, + drawing: XmlNode, + chart: XmlNode, + state: WalkState, + trackedChange: TrackedChange | undefined, +): NativeChartRun { + const id = `chart${state.chartIndex++}`; + const relationshipId = chart.getAttribute("r:id"); + const target = relationshipId + ? document.relationships.findByRid(relationshipId)?.getAttribute("Target") + : undefined; + const partName = target + ? resolveRelationshipTarget("word/document.xml", target) + : ""; + const run: NativeChartRun = { + type: "chart", + kind: "chart", + id, + ...readChartContent(document.charts?.get(partName), partName), + }; + const extent = drawing.findDescendant("wp:extent"); + if (extent) { + run.widthEmu = Number(extent.getAttribute("cx") ?? "0"); + run.heightEmu = Number(extent.getAttribute("cy") ?? "0"); + } + applyFloatingPlacement(run, drawing); + if (trackedChange) run.trackedChange = trackedChange; + return run; +} + function applyRunProperties(run: TextRun, runProperties: XmlNode): void { const colorNode = runProperties.findChild("w:color"); if (colorNode) { @@ -1400,19 +1446,26 @@ function readImageFromDrawing( heightEmu, alt: alt || undefined, }; - // `` = floating (out of flow, with wrap); `` = the - // default in-flow shape. Only floating images carry wrap + horizontal align. - const anchor = drawing.findDescendant("wp:anchor"); - if (anchor) { - run.floating = true; - const wrap = readImageWrap(anchor); - if (wrap) run.wrap = wrap; - const align = readImageAlign(anchor); - if (align) run.align = align; - } + applyFloatingPlacement(run, drawing); return run; } +/** `` = floating (out of flow, with wrap); `` = the + * default in-flow shape. Only a floating image/chart carries wrap + horizontal + * align. */ +function applyFloatingPlacement( + run: { floating?: boolean; wrap?: string; align?: string }, + drawing: XmlNode, +): void { + const anchor = drawing.findDescendant("wp:anchor"); + if (!anchor) return; + run.floating = true; + const wrap = readImageWrap(anchor); + if (wrap) run.wrap = wrap; + const align = readImageAlign(anchor); + if (align) run.align = align; +} + const WRAP_TAGS: ReadonlyArray<[tag: string, label: string]> = [ ["wp:wrapSquare", "square"], ["wp:wrapTight", "tight"], diff --git a/src/core/ast/types.ts b/src/core/ast/types.ts index 9535af9..698be50 100644 --- a/src/core/ast/types.ts +++ b/src/core/ast/types.ts @@ -382,12 +382,67 @@ export type NoteRefRun = { id: string; }; -/** Placeholder for a non-picture drawing (chart, shape, SmartArt, etc.). The - * full content is preserved in the underlying XML; the run is a marker so - * callers can know "something visual lives here." */ -export type ChartRun = { +/** A non-picture drawing. A native Word chart (`kind: "chart"`) is a + * `NativeChartRun` with its own `chartN` handle and the data parsed from its + * chart part; a shape, SmartArt diagram or anything else is a bare + * `DrawingPlaceholderRun`. Either way the full content is preserved in the + * underlying XML — the run only says what lives there. */ +export type ChartRun = NativeChartRun | DrawingPlaceholderRun; + +/** Placeholder for a shape / SmartArt / unknown drawing: a marker so callers + * know "something visual lives here." */ +type DrawingPlaceholderRun = { + type: "chart"; + kind: "shape" | "smartart" | "drawing"; +}; + +/** A native chart (`` under a ``), read from its + * `word/charts/chartN.xml` part. `id` (`chartN`) is positional in document + * order across the body, table cells and text boxes (headers/footers aren't + * walked yet). The series data is the chart part's CACHE — what Word and + * LibreOffice actually draw — not the embedded workbook behind Word's Edit + * Data. Nothing here is written back; the chart part stays byte-identical. */ +export type NativeChartRun = { type: "chart"; - kind: "chart" | "shape" | "smartart" | "drawing"; + kind: "chart"; + id: string; + /** One label per chart group in the plot area, in order — a combo chart + * has several (`["column", "line"]`). The `` element's name minus + * `Chart` (`pie3D`, `area`, `scatter`, …), except a `barChart` / + * `bar3DChart` whose bars stand upright (`barDir="col"`), which is + * `column` / `column3D` — the name Word's Insert Chart dialog uses. Empty + * when the chart part can't be resolved. */ + chartTypes: string[]; + /** The chart title's text (rich-text paragraphs joined by a space). */ + title?: string; + series: ChartSeries[]; + /** Where Word's Edit Data reads from: an `embedded` workbook part, an + * `external` (linked) workbook, or `none` (cache-only chart). */ + dataSource: "embedded" | "external" | "none"; + /** The embedded workbook's part name (`word/embeddings/….xlsx`) when + * `dataSource` is `embedded`. */ + workbook?: string; + widthEmu?: number; + heightEmu?: number; + /** Placement of a `` chart — same fields as `ImageRun`. */ + floating?: boolean; + wrap?: string; + align?: string; + trackedChange?: TrackedChange; +}; + +/** One ``, from its caches. Point arrays are indexed by `c:pt/@idx`; a + * missing point (a gap) is `null`. `*Ref` fields are the series' workbook + * formulas (`Sheet1!$B$2:$B$5`) when it has them. */ +export type ChartSeries = { + name?: string; + nameRef?: string; + categories: (string | null)[]; + categoriesRef?: string; + values: (number | null)[]; + valuesRef?: string; + /** The value cache's number format (`0.0%`, `General`, …). */ + formatCode?: string; }; /** A text box — a shape whose `` holds its own paragraph story, diff --git a/src/core/chart/CLAUDE.md b/src/core/chart/CLAUDE.md new file mode 100644 index 0000000..4a3f603 --- /dev/null +++ b/src/core/chart/CLAUDE.md @@ -0,0 +1,9 @@ +# src/core/chart — native Word charts (read-only, first slice of #43) + +A `` holding `` is a native chart: the reader mints it a positional `chartN` (`WalkState.chartIndex`, document order over the body, table cells and text boxes — headers/footers aren't walked yet) and fills a `NativeChartRun` from the chart part. + +- [read.ts](read.ts) — `readChartContent(part, partName)`: the plot area's chart groups (`chartTypes`, through any `mc:AlternateContent`), the title text, every `c:ser` from its CACHES (`strCache`/`numCache`/`multiLvlStrCache`/`strLit`/`numLit`, padded to `ptCount`, a missing `c:pt` = `null`, never 0), and the data source (`c:externalData r:id` → the chart part's rels → `embedded` workbook part / `external` link / `none`). The caches are what Word and LibreOffice draw; the workbook is located, never opened. +- [charts.ts](charts.ts) — the `Charts` lens (`list()` / `get(chartN)`) over the AST; `chartTypeName` / `chartPointCount` are shared with the `docx:chart` read hint so the hint, `charts list` and `charts extract` can't disagree. +- [csv.ts](csv.ts) — `chartToCsv`: `category,` header + one row per point, RFC 4180 quoting. This is the shape a future `charts set-data --csv` should take back in. + +The chart parts come from `ChartsView` ([../ast/document/charts.ts](../ast/document/charts.ts)), preloaded because the body walk is synchronous. Nothing here mutates a chart: there is no `writeTo`, so an unrelated save keeps the chart part, its rels and its workbook byte-identical. A write path (set-data, add, delete) must update caches AND the embedded workbook together — both engines render the cache, so a cache-only edit looks right until someone clicks Edit Data. diff --git a/src/core/chart/charts.ts b/src/core/chart/charts.ts new file mode 100644 index 0000000..cfddfc8 --- /dev/null +++ b/src/core/chart/charts.ts @@ -0,0 +1,78 @@ +import type { Document } from "../ast/document"; +import { iterateBlocks } from "../ast/document/body"; +import type { ChartSeries, NativeChartRun } from "../ast/types"; + +/** The chart lens: read-only access to the document's native charts by their + * `chartN` handle. Stateless — it reads the AST the reader built (every chart + * run carries its parsed caches), so it never re-opens a chart part. */ +export class Charts { + constructor(private readonly document: Document) {} + + /** Every native chart in document order (the order `chartN` ids are minted), + * with the block that holds it. */ + list(): ChartListing[] { + return [...this.charts()].map(({ run, at }) => toListing(run, at)); + } + + /** The chart run for `chartN`, or undefined when no chart has that id. */ + get(chartId: string): NativeChartRun | undefined { + for (const { run } of this.charts()) if (run.id === chartId) return run; + return undefined; + } + + private *charts(): IterableIterator<{ run: NativeChartRun; at: string }> { + for (const block of iterateBlocks(this.document.body.blocks)) { + if (block.type !== "paragraph") continue; + for (const run of block.runs) { + if (run.type === "chart" && run.kind === "chart") + yield { run, at: block.id }; + } + } + } +} + +function toListing(run: NativeChartRun, at: string): ChartListing { + return { + id: run.id, + at, + type: chartTypeName(run), + ...(run.title ? { title: run.title } : {}), + series: run.series.map((series, index) => seriesName(series, index)), + points: chartPointCount(run), + data: run.dataSource, + ...(run.workbook ? { workbook: run.workbook } : {}), + }; +} + +/** The chart's type as one token: its chart groups joined by `+` + * (`column+line` for a combo), or `unknown` when the part didn't resolve. */ +export function chartTypeName(run: NativeChartRun): string { + return run.chartTypes.length > 0 ? run.chartTypes.join("+") : "unknown"; +} + +/** How many points (categories) the chart plots — the longest series. */ +export function chartPointCount(run: NativeChartRun): number { + let count = 0; + for (const series of run.series) { + count = Math.max(count, series.categories.length, series.values.length); + } + return count; +} + +/** A series' display name, falling back to Word's own `Series N` label. */ +export function seriesName(series: ChartSeries, index: number): string { + return series.name ?? `Series ${index + 1}`; +} + +/** One `charts list` item; `id` is the `--at` handle, `at` the paragraph that + * holds the chart. */ +type ChartListing = { + id: string; + at: string; + type: string; + title?: string; + series: string[]; + points: number; + data: NativeChartRun["dataSource"]; + workbook?: string; +}; diff --git a/src/core/chart/csv.ts b/src/core/chart/csv.ts new file mode 100644 index 0000000..2a7dd32 --- /dev/null +++ b/src/core/chart/csv.ts @@ -0,0 +1,38 @@ +import type { NativeChartRun } from "../ast/types"; +import { chartPointCount, seriesName } from "./charts"; + +/** The chart's cached data as RFC 4180 CSV: a header row + * (`category,,…`), then one row per point with its + * category label and each series' value. A gap (a point missing from a + * cache) is an empty cell, never `0`. Series that label their points + * differently share the first label any series gives that point. Rows end in + * LF (the RFC's CRLF is a nuisance in a terminal pipeline; RFC parsers accept + * either). */ +export function chartToCsv(run: NativeChartRun): string { + const header = [ + "category", + ...run.series.map((series, index) => seriesName(series, index)), + ]; + const rows = [header]; + for (let index = 0; index < chartPointCount(run); index++) { + const category = + run.series.find((series) => series.categories[index] != null)?.categories[ + index + ] ?? ""; + rows.push([ + category, + ...run.series.map((series) => { + const value = series.values[index]; + return value == null ? "" : String(value); + }), + ]); + } + return rows.map((row) => `${row.map(csvField).join(",")}\n`).join(""); +} + +/** Quote a field when it holds a comma, quote or line break (RFC 4180 §2.6), + * doubling embedded quotes. */ +function csvField(value: string): string { + if (!/[",\r\n]/.test(value)) return value; + return `"${value.replace(/"/g, '""')}"`; +} diff --git a/src/core/chart/index.ts b/src/core/chart/index.ts new file mode 100644 index 0000000..d4915a8 --- /dev/null +++ b/src/core/chart/index.ts @@ -0,0 +1,2 @@ +export { Charts, chartPointCount, chartTypeName } from "./charts"; +export { chartToCsv } from "./csv"; diff --git a/src/core/chart/read.ts b/src/core/chart/read.ts new file mode 100644 index 0000000..e796ddc --- /dev/null +++ b/src/core/chart/read.ts @@ -0,0 +1,222 @@ +import type { ChartPart } from "../ast/document/charts"; +import { resolveRelationshipTarget } from "../ast/document/relationships"; +import type { ChartSeries, NativeChartRun } from "../ast/types"; +import { alternateContentBranch, isAlternateContent } from "../mc"; +import { XmlNode } from "../parser"; + +/** What a chart part says about itself: the slice of `NativeChartRun` the + * reader can't get from the `` alone. */ +export type ChartContent = Pick< + NativeChartRun, + "chartTypes" | "title" | "series" | "dataSource" | "workbook" +>; + +/** Parse one `` part into its chart groups, title, cached series + * and data source. Read-only: the caches are what Word and LibreOffice draw, so + * they're what `read` reports; the embedded workbook is only located, never + * opened. A chart part we can't parse reads as an empty, cache-less chart. */ +export function readChartContent( + part: ChartPart | undefined, + partName: string, +): ChartContent { + const chartSpace = part + ? XmlNode.findRoot(part.tree, "c:chartSpace") + : undefined; + if (!part || !chartSpace) { + return { chartTypes: [], series: [], dataSource: "none" }; + } + const chart = chartSpace.findChild("c:chart"); + const groups = chartGroups(chart?.findChild("c:plotArea")); + const content: ChartContent = { + chartTypes: groups.map(chartTypeLabel), + series: groups.flatMap((group) => + group.findChildren("c:ser").map(readSeries), + ), + ...readDataSource(chartSpace, part, partName), + }; + const title = chart ? readTitle(chart) : undefined; + if (title) content.title = title; + return content; +} + +/** The plot area's chart-group elements (``, ``, …) + * in order, looking through any markup-compatibility wrapper. Axes, layout and + * the data table are siblings and are skipped. */ +function chartGroups(plotArea: XmlNode | undefined): XmlNode[] { + if (!plotArea) return []; + const groups: XmlNode[] = []; + visit(plotArea.children); + return groups; + + function visit(children: XmlNode[]): void { + for (const child of children) { + if (isAlternateContent(child)) { + const branch = alternateContentBranch(child); + if (branch) visit(branch.children); + continue; + } + if (/^c:\w+Chart$/.test(child.tag)) groups.push(child); + } + } +} + +/** `c:pie3DChart` → `pie3D`; an upright `c:barChart` → `column` (see + * `NativeChartRun.chartTypes`). `barDir` is required by the schema; when a + * producer omits its `val`, the CT_BarDir default is `col`. */ +function chartTypeLabel(group: XmlNode): string { + const name = group.tag.slice("c:".length, -"Chart".length); + if (name !== "bar" && name !== "bar3D") return name; + const direction = group.findChild("c:barDir")?.getAttribute("val") ?? "col"; + return direction === "col" ? name.replace("bar", "column") : name; +} + +function readSeries(series: XmlNode): ChartSeries { + const out: ChartSeries = { categories: [], values: [] }; + const name = series.findChild("c:tx"); + if (name) { + const literal = name.findChild("c:v")?.collectText(); + const cached = readStringPoints(name)[0]; + const text = literal ?? cached; + if (text) out.name = text; + const nameRef = formulaOf(name); + if (nameRef) out.nameRef = nameRef; + } + // Scatter and bubble charts carry x/y values instead of categories/values. + const categories = series.findChild("c:cat") ?? series.findChild("c:xVal"); + if (categories) { + out.categories = readStringPoints(categories); + const categoriesRef = formulaOf(categories); + if (categoriesRef) out.categoriesRef = categoriesRef; + } + const values = series.findChild("c:val") ?? series.findChild("c:yVal"); + if (values) { + out.values = readStringPoints(values).map(parseCachedNumber); + const valuesRef = formulaOf(values); + if (valuesRef) out.valuesRef = valuesRef; + const formatCode = findCache(values) + ?.findChild("c:formatCode") + ?.collectText(); + if (formatCode) out.formatCode = formatCode; + } + return out; +} + +/** A cached `c:v` as a number; an empty or non-numeric value is a gap. */ +function parseCachedNumber(value: string | null): number | null { + if (value === null || value.trim() === "") return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +/** The `c:f` formula of a data source (`c:strRef` / `c:numRef` / + * `c:multiLvlStrRef`), if it references the workbook. */ +function formulaOf(source: XmlNode): string | undefined { + for (const reference of source.children) { + const formula = reference.findChild("c:f")?.collectText(); + if (formula) return formula; + } + return undefined; +} + +const CACHE_TAGS = [ + "c:strCache", + "c:numCache", + "c:multiLvlStrCache", + "c:strLit", + "c:numLit", +] as const; + +/** The point container of a data source: a reference's cache, or a literal + * (`c:strLit` / `c:numLit`) sitting directly in the source. */ +function findCache(source: XmlNode): XmlNode | undefined { + for (const container of [source, ...source.children]) { + for (const tag of CACHE_TAGS) { + const cache = container.findChild(tag); + if (cache) return cache; + } + } + return undefined; +} + +/** A cache's points as strings, indexed by `c:pt/@idx` and padded to + * `c:ptCount` — a missing point (a gap in the data) is `null`. A multi-level + * category cache reads its first `c:lvl`: the innermost labels, the ones each + * point sits on. */ +function readStringPoints(source: XmlNode): (string | null)[] { + let cache = findCache(source); + if (cache?.tag === "c:multiLvlStrCache") + cache = cache.findChild("c:lvl") ?? cache; + if (!cache) return []; + const declared = Number( + cache.findChild("c:ptCount")?.getAttribute("val") ?? "0", + ); + const points: (string | null)[] = []; + for (const point of cache.findChildren("c:pt")) { + const index = Number(point.getAttribute("idx")); + if (!isPointIndex(index)) continue; + points[index] = point.findChild("c:v")?.collectText() ?? ""; + } + const length = Math.max(points.length, isPointIndex(declared) ? declared : 0); + return Array.from({ length }, (_, index) => points[index] ?? null); +} + +/** A chart can't plot more points than a worksheet has rows (1,048,576), so + * a larger `idx`/`ptCount` is corrupt input — skip it rather than allocate. */ +const MAX_POINTS = 1_048_576; + +function isPointIndex(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value < MAX_POINTS; +} + +/** The title's text: rich-text runs (`c:tx/c:rich`), one string per + * paragraph joined by a space, or a formula-backed title's cached string. + * An auto title (no `c:tx`) or a deleted one reads as none. */ +function readTitle(chart: XmlNode): string | undefined { + const text = chart.findChild("c:title")?.findChild("c:tx"); + if (!text) return undefined; + const rich = text.findChild("c:rich"); + if (rich) { + const paragraphs = rich + .findChildren("a:p") + .map((paragraph) => collectDrawingText(paragraph).trim()) + .filter((paragraph) => paragraph.length > 0); + return paragraphs.length > 0 ? paragraphs.join(" ") : undefined; + } + const cached = readStringPoints(text)[0]; + return cached ? cached : undefined; +} + +/** Text of a DrawingML paragraph: every `a:t`, with `a:br` as a space. */ +function collectDrawingText(paragraph: XmlNode): string { + let out = ""; + for (const child of paragraph.children) { + if (child.tag === "a:r" || child.tag === "a:fld") + out += child.findChild("a:t")?.collectText() ?? ""; + else if (child.tag === "a:br") out += " "; + } + return out; +} + +/** Where Word's Edit Data reads from: `` → the chart + * part's rels → an internal workbook part (`embedded`) or a + * `TargetMode="External"` link (`external`); no `externalData` is a + * cache-only chart (`none`). */ +function readDataSource( + chartSpace: XmlNode, + part: ChartPart, + partName: string, +): Pick { + const relationshipId = chartSpace + .findChild("c:externalData") + ?.getAttribute("r:id"); + if (!relationshipId) return { dataSource: "none" }; + const relationship = part.relationships.findByRid(relationshipId); + const target = relationship?.getAttribute("Target"); + if (!relationship || !target) return { dataSource: "none" }; + if (relationship.getAttribute("TargetMode") === "External") + return { dataSource: "external" }; + return { + dataSource: "embedded", + workbook: resolveRelationshipTarget(partName, target), + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index 46d8b4b..1dc31bb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -28,6 +28,7 @@ export type { BreakRun, CellMargins, ChartRun, + ChartSeries, Comment, CommentAnchor, ContentControl, @@ -36,6 +37,7 @@ export type { Hyperlink, ImageRun, Marginal, + NativeChartRun, Note as Footnote, NoteRefRun as FootnoteRefRun, Paragraph, diff --git a/src/core/raw/relationships.ts b/src/core/raw/relationships.ts index 6d05168..56d7bdb 100644 --- a/src/core/raw/relationships.ts +++ b/src/core/raw/relationships.ts @@ -1,4 +1,5 @@ import type { Document } from "../ast/document"; +import { resolveRelationshipTarget } from "../ast/document/relationships"; import { XmlNode } from "../parser"; import { RawError } from "./error"; import { assertWellFormed, parseRoots } from "./parse"; @@ -160,7 +161,7 @@ function checkInternalTarget(document: Document, node: XmlNode): void { if (node.getAttribute("TargetMode") === "External") return; const target = node.getAttribute("Target"); if (!target) return; - const partName = resolveRelativePart(target); + const partName = resolveRelationshipTarget("word/document.xml", target); if (document.pkg.hasPart(partName)) return; throw new RawError( "INVALID_XML", @@ -169,24 +170,6 @@ function checkInternalTarget(document: Document, node: XmlNode): void { ); } -/** Resolve a rels Target against its base (`word/`, since this is the - * document part's rels): `media/image1.png` → `word/media/image1.png`, - * `../customXml/item1.xml` → `customXml/item1.xml`, a leading `/` is - * package-absolute. */ -function resolveRelativePart(target: string): string { - const base = target.startsWith("/") ? target.slice(1) : `word/${target}`; - const segments: string[] = []; - for (const segment of base.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - segments.pop(); - continue; - } - segments.push(segment); - } - return segments.join("/"); -} - /** Insert: every explicit Id must be free (in the part AND the fragment); * a node without an Id gets the next `rIdN` minted. Replace: the Id must * stay the target's — stamped when omitted, rejected when different (a diff --git a/tests/cli/charts.test.ts b/tests/cli/charts.test.ts new file mode 100644 index 0000000..9d6b10e --- /dev/null +++ b/tests/cli/charts.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import JSZip from "jszip"; +import { runCli } from "./harness"; +import { freshFixture, readMarkdown } from "./helpers"; + +const FIXTURES = join(import.meta.dir, "..", "fixtures"); +const CHARTS = join(FIXTURES, "charts.docx"); +const STRICT = join(FIXTURES, "strict-profile.docx"); + +describe("docx read — native charts", () => { + test("each chart keeps its token and gains a docx:chart hint led by chartN", async () => { + const markdown = await readMarkdown(CHARTS); + expect(markdown).toContain( + '`[chart]` ', + ); + // chartN numbering continues into table cells; a combo joins its types. + expect(markdown).toContain( + '| `[chart]` |', + ); + }); + + test("an embedded-workbook chart omits data= (deviation-only), Strict namespaces included", async () => { + const markdown = await readMarkdown(STRICT); + expect(markdown).toContain( + '`[chart]` ', + ); + expect(markdown).toContain("`[smartart]`"); + }); + + test("--ast carries the parsed chart (everything read shows, and more)", async () => { + const result = await runCli("read", CHARTS, "--ast"); + const ast = result.parsed as { + blocks: { runs?: { type: string; id?: string; series?: unknown[] }[] }[]; + }; + const chart = ast.blocks + .flatMap((block) => block.runs ?? []) + .find((run) => run.type === "chart"); + expect(chart).toMatchObject({ + kind: "chart", + id: "chart0", + chartTypes: ["column"], + title: "Revenue by Quarter", + dataSource: "none", + }); + expect(chart?.series).toHaveLength(2); + }); + + test("editing the paragraph around a chart keeps the chart and its part", async () => { + const docPath = await freshFixture("charts-edit", CHARTS); + const before = await JSZip.loadAsync(await Bun.file(docPath).arrayBuffer()); + const chartXml = await before + .file("word/charts/chart1.xml") + ?.async("string"); + const result = await runCli("edit", docPath, "--at", "p2", "Revenue: "); + expect(result.exitCode).toBe(0); + const markdown = await readMarkdown(docPath); + expect(markdown).toContain('docx:chart chart0 type="column"'); + expect(markdown).toContain("Revenue:"); + const after = await JSZip.loadAsync(await Bun.file(docPath).arrayBuffer()); + expect(await after.file("word/charts/chart1.xml")?.async("string")).toBe( + chartXml, + ); + }); +}); + +describe("docx charts list", () => { + test("prints a bare JSON array whose id is the --at handle", async () => { + const result = await runCli("charts", "list", CHARTS); + expect(result.exitCode).toBe(0); + expect(result.parsed).toEqual([ + { + id: "chart0", + at: "p2", + type: "column", + title: "Revenue by Quarter", + series: ["West", "East"], + points: 4, + data: "none", + }, + { + id: "chart1", + at: "t0:r1c0:p0", + type: "column+line", + series: ["Units", "Margin"], + points: 4, + data: "none", + }, + ]); + }); + + test("an embedded chart names its workbook part", async () => { + const result = await runCli("charts", "list", STRICT); + expect(result.parsed).toEqual([ + { + id: "chart0", + at: "p1", + type: "column", + series: ["Series 1", "Series 2", "Series 3"], + points: 4, + data: "embedded", + workbook: "word/embeddings/Microsoft_Excel_Worksheet.xlsx", + }, + ]); + }); + + test("a document without charts lists none", async () => { + const result = await runCli( + "charts", + "list", + join(FIXTURES, "minimal.docx"), + ); + expect(result.exitCode).toBe(0); + expect(result.parsed).toEqual([]); + }); +}); + +describe("docx charts extract", () => { + test("CSV by default: header, one row per category, a gap is an empty cell", async () => { + const result = await runCli("charts", "extract", CHARTS, "--at", "chart0"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe( + "category,West,East\nQ1,120,90\nQ2,135,110\nQ3,150,\nQ4,170,140\n", + ); + }); + + test("--json prints the full series objects", async () => { + const result = await runCli( + "charts", + "extract", + CHARTS, + "--at", + "chart1", + "--json", + ); + expect(result.exitCode).toBe(0); + const payload = result.parsed as { + id: string; + series: { name: string; values: number[]; valuesRef: string }[]; + }; + expect(payload.id).toBe("chart1"); + expect(payload.series[1]).toMatchObject({ + name: "Margin", + values: [0.21, 0.24, 0.22, 0.27], + valuesRef: "Sheet1!$C$2:$C$5", + }); + }); + + test("an unknown chart id is CHART_NOT_FOUND (exit 3)", async () => { + const result = await runCli("charts", "extract", CHARTS, "--at", "chart9"); + expect(result.exitCode).toBe(3); + expect(result.parsed).toMatchObject({ code: "CHART_NOT_FOUND" }); + }); + + test("--at is required", async () => { + const result = await runCli("charts", "extract", CHARTS); + expect(result.exitCode).toBe(2); + }); +}); diff --git a/tests/core/chart.test.ts b/tests/core/chart.test.ts new file mode 100644 index 0000000..1e83117 --- /dev/null +++ b/tests/core/chart.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test"; +import { Document, type NativeChartRun, type Paragraph } from "@core"; +import { Charts, chartToCsv } from "@core/chart"; + +const CHART_NS = + 'xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; + +function drawing(rId: string, { anchor = false } = {}): string { + const placement = anchor + ? 'center' + : ''; + const close = anchor ? "" : ""; + return `${placement}${close}`; +} + +function chartDocument( + bodyXml: string, + charts: Record, +): Document { + const relationships = Object.keys(charts) + .map( + (partName, index) => + ``, + ) + .join(""); + return Document.fromXml({ + documentXml: `${bodyXml}`, + relationshipsXml: `${relationships}`, + chartParts: charts, + }); +} + +function chartSpace(inner: string, trailing = ""): string { + return `${inner}${trailing}`; +} + +function onlyChart(document: Document): NativeChartRun { + const charts = new Charts(document).list(); + expect(charts).toHaveLength(1); + const run = new Charts(document).get("chart0"); + if (!run) throw new Error("expected chart0"); + return run; +} + +const BAR_SERIES = ` +'Summary, FY'!$B$1Revenue +'Summary, FY'!$A$2:$A$4NorthSouth, "Gulf"West +'Summary, FY'!$B$2:$B$40.0%0.250.5 +`.replace(/\n/g, ""); + +describe("chart reader", () => { + test("a column chart: id, type, title, series from the caches, gap as null", () => { + const document = chartDocument(`${drawing("rIdC0")}`, { + "word/charts/chart1.xml": { + xml: chartSpace( + `Revenue by RegionFY2026${BAR_SERIES}`, + ), + }, + }); + const chart = onlyChart(document); + expect(chart).toMatchObject({ + type: "chart", + kind: "chart", + id: "chart0", + chartTypes: ["column"], + title: "Revenue by Region FY2026", + dataSource: "none", + widthEmu: 914400, + heightEmu: 457200, + }); + expect(chart.series).toEqual([ + { + name: "Revenue", + nameRef: "'Summary, FY'!$B$1", + categories: ["North", 'South, "Gulf"', "West"], + categoriesRef: "'Summary, FY'!$A$2:$A$4", + values: [0.25, null, 0.5], + valuesRef: "'Summary, FY'!$B$2:$B$4", + formatCode: "0.0%", + }, + ]); + }); + + test("CSV: header + one row per point, RFC 4180 quoting, a gap is an empty cell", () => { + const document = chartDocument(`${drawing("rIdC0")}`, { + "word/charts/chart1.xml": { + xml: chartSpace( + `${BAR_SERIES}`, + ), + }, + }); + const chart = onlyChart(document); + expect(chart.chartTypes).toEqual(["bar"]); + expect(chartToCsv(chart)).toBe( + 'category,Revenue\nNorth,0.25\n"South, ""Gulf""",\nWest,0.5\n', + ); + }); + + test("a combo chart lists every chart group; literals and unnamed series read too", () => { + const document = chartDocument(`${drawing("rIdC0")}`, { + "word/charts/chart1.xml": { + xml: chartSpace( + `${BAR_SERIES}ab12`, + ), + }, + }); + const chart = onlyChart(document); + expect(chart.chartTypes).toEqual(["column3D", "line"]); + expect(chart.series[1]).toEqual({ categories: ["a", "b"], values: [1, 2] }); + expect(new Charts(document).list()[0]).toMatchObject({ + type: "column3D+line", + series: ["Revenue", "Series 2"], + points: 3, + }); + }); + + test("multi-level categories read the innermost level", () => { + const document = chartDocument(`${drawing("rIdC0")}`, { + "word/charts/chart1.xml": { + xml: chartSpace( + `Sheet1!$A$2:$B$3JanFeb2026Sheet1!$C$2:$C$334`, + ), + }, + }); + const chart = onlyChart(document); + expect(chart.chartTypes).toEqual(["pie"]); + expect(chart.series[0]?.categories).toEqual(["Jan", "Feb"]); + expect(chart.series[0]?.categoriesRef).toBe("Sheet1!$A$2:$B$3"); + }); + + test("data source: an embedded workbook resolves against the chart part; a link is external", () => { + const relsXml = (target: string, mode = "") => + ``; + const space = chartSpace( + "", + '', + ); + const document = chartDocument( + `${drawing("rIdC0")}${drawing("rIdC1", { anchor: true })}`, + { + "word/charts/chart1.xml": { + xml: space, + relsXml: relsXml("../embeddings/Microsoft_Excel_Worksheet1.xlsx"), + }, + "word/charts/chart2.xml": { + xml: space, + relsXml: relsXml("file:///C:/Book.xlsx", ' TargetMode="External"'), + }, + }, + ); + const charts = new Charts(document); + expect(charts.get("chart0")).toMatchObject({ + chartTypes: ["area"], + dataSource: "embedded", + workbook: "word/embeddings/Microsoft_Excel_Worksheet1.xlsx", + }); + expect(charts.get("chart1")).toMatchObject({ + dataSource: "external", + floating: true, + wrap: "square", + align: "center", + }); + expect(charts.get("chart1")?.workbook).toBeUndefined(); + }); + + test("chartN is positional across body and cells; a dangling chart still gets its id", () => { + const document = chartDocument( + `${drawing("rIdMissing")}${drawing("rIdC0")}`, + { + "word/charts/chart1.xml": { + xml: chartSpace(""), + }, + }, + ); + const listing = new Charts(document).list(); + expect(listing.map(({ id, at, type }) => ({ id, at, type }))).toEqual([ + { id: "chart0", at: "p0", type: "unknown" }, + { id: "chart1", at: "t0:r0c0:p0", type: "line" }, + ]); + }); + + test("an out-of-range point index or count is ignored, not allocated", () => { + const document = chartDocument(`${drawing("rIdC0")}`, { + "word/charts/chart1.xml": { + xml: chartSpace( + '12', + ), + }, + }); + expect(onlyChart(document).series[0]?.values).toEqual([1]); + }); + + test("SmartArt and shapes stay bare placeholders with no chart id", () => { + const document = chartDocument( + '', + {}, + ); + const paragraph = document.body.blocks[0] as Paragraph; + expect(paragraph.runs).toEqual([{ type: "chart", kind: "smartart" }]); + expect(new Charts(document).list()).toEqual([]); + }); +}); diff --git a/tests/fixtures/charts.docx b/tests/fixtures/charts.docx new file mode 100644 index 0000000000000000000000000000000000000000..230728ee1aad45f3a478325e7adcbe2af1b32708 GIT binary patch literal 8444 zcmai4bzBrow_ds%2@wSZ=`I1Or5ow)1*E%?PU)2HrA0|;1S#os0R=?5yCm+SFZ#;; zzI*5Q+x=rX&zzYvXU;tH42m*v@TdR)02%r*)#a%He6xcA0GJU006gd~+9I|#P9Pg6 zeHC{*kfSb>o3&LdR!C3LW_1dGJCr#^ zCBEPSD_kn}lUeq>k9oadwq8_N3o}Qqo^u8m`ixPR*s8iIWsg4Gh;}-|^q_QWm?@KI zsn+}kK&@9i^u~v->3Iw}xUYGzlIVia5}5F%h(dTwY-$4~oBKjjTZ;(btcB zn2jxi7vGI>Pb&_9sOd0QJNbeKZ}_*(6H`i*Dvlw_l`^zmEtiNm$7ae-Qe zAKde65KsAgS0*i-rO_V($9)Ug)7DsRFy^m^X3t$96fXf z(@Ik$QPeE<)b4NaQG$BK{U?vML4M7WA}>zupEB>SPt>ZJFqXxOn>l$1OsbxubWK96R zbt_t!UR$KG+pHsQX-HA!pp}uDjy(wIWt`cpUmiB(-dmAOAIxI0>@Bwk`#N$I%xSlg z+3}ts>~iRpi?=PD0IH~c3-DS)G!8>QI@dsylnxHxjCWNg0^vQ;U4!%m2L&s^#r zgcDnrANMx#f0qWYVy{mW%wSfeBN)_)$VF9>}c>t&4uJE(HA5176dR8ch1fid=5A?jCt6E z4-REKeKXJB_*$Q71#f&_SDcw8=aMW;ON{(!Q)C030ogNaw>KB;7DndVjIlh^Pg9g~ zH6yp(l)pvwR;zz|^m&KDlCIa5@v5cnG^GG^F&>hdq3E5%x|L%?#4@Vo4jb+n5ZgIM zb|g+Zp_xgAlje~OY4jq--EHf-d>4Jp6h*!-;)dl2qgLY6DJuSr56lbhGFlJ`)olc1@s5LOHVyfNpPZXDEb{DxN7Q|igwWcg2TgrC{!Od8Q`mqor5A= zt?s80-sTT}WO7B(HPIpW7Z6slFhuw@8%_aMG@$TrsO3-)b ze%+#;($$UI3x0VC_useeO(pxeZU0lr`#2kcj1E1wMBxE|2Y(y>dkg=pft_Q0&>D!1 zFwE%{#Ob*}DI{DZ&wWp+4qKqj`3V;1yT_hxi#x1xGI+v|31T${BfVB2N=c>BU{hOj zT~M_z4YFU3+LKZz@_8!nrUs;C$#vOD@jPEl(z3WEyveu=9CfvF;}#J<7^>$#+Uk|5 zxJD7cVq2|?U0iV&=4i9hHSq}hPtgg#VDgdGHxboT$~|ebsYKS8t~pJ1iC;--aAYbzan-wY}65)v}3W*TRVy?$twTG2m{&U55?zgZ=_I;x^n z;c(2Bbu3VY&cBM9FhXJG>9<)+v?IQHB5fc(#2MwnqwQ%ck#1a1z&T&=4v1IB@di;L zM|&^BKG7MBADYmt<^|cA+r6J}+S!RDt(mf4s?jd)PDw_#T___7z{QQ;d&W6!TuTCz zq_n*GFhS>OFS4crMik97TFn2u<@j@w~@S{Gmtx%#+b~W8|RP9j)4oq!A!(9tT6S-6BLzaFvF*pIFc5 zg@0ir1%PYT$!Y8PiRcqRVTUa`*y^P%Z9D=JJ(3G4d`X9x64vvk)8@%-XJK9vI^Vp`3$Hn2@636Vd$@LG#`ap3Gs?D0>q9)TItD~a4W7J%uXhP z<+J915MtdGxn0SQP9oA~4dm$qmg`b;@yg)zu#^5SB#DQ0B_k2=wwyZW>17A$R}u-8 z!TMeNfz~#k&l!|+ciLR5U0$H>)p2}!?-JYU-O$h|Fx=gI{Zv-0(?Uyq8jrx|H%0ly z+Qe2G<*uNa5DCqS-~7W7ejOkD0K_drOXabJyZ-M(b0PN)|o4k zYQ}PECV1b2Aa@w9j&!JhFp7I-XucF}d4ZQ}H*3qEA#K!eo{{ovnjA6Jl(^1tGx8aaRpy{H5Z z9n%80xh4W6*Rb4*xN*jjLbs+7&yg@_9d8!m=(?ykgkXDO6)(4c4N4NNATTgFeshNybC! zn6F8dPm3MZmR>kmVy(KBFxJ)gMgzngY@UAB=IcZzi=O{dNc+{A7wMu#VsjAV?s)~C%B~$42OU2O0>StLk7;c2#yzqU$)jtbbYK92qSSJL+guN3Hdo&-D zjxEz7D!>$6R}l{kEYtRLw=%>&dT zZZQyCM11_bL>aC5-HI%!R{N9DhZ*b!S>zHe4k6{wIdbLpvqF-}KG-8?4F_+4xlRou zModf6M2bnt@gaR-45kFK4d!rKTg~grbG&b(?2`6zoIWh+*etO{UY<}AUiNf?tS9`b z(Z6f)Bq2tOu2^Olg?3FAE7p8=g0C>BurI1Vt-Z|sLL2aLmZj3wtZjC3aEXpKxp%sh z@Ot?Cn}1_9$;sNi^I1>r^K_?*05_hdt+|4(iB(%g66f~wkb`rHCBoYvLL+vE=l>=Q zPUawM(4G2g=6g|{2E9NHkO2VqZlnFn@aH${PsXpQU^~Nw---osC3A8;>W*VTa7Mef z{`|yxB%jp7#6-(THVjsZnl&6&#B&wkA3!WyFrD_DH3}O-uW~|g?J>7--!WTh>Xx7+gf8O%TTYuoGB=@D7L%@qpvmS3($bWUYdtshZ&*V5AXf4 zQY-uC)-V$IPP5_)_%@n2`Wo)HwJX|wRc&fQ2NIFnZ$aFuUSd9u%08>Er0O5I)!kej zBRlMdPw<->+dWB6qn4aH!2Ty{Za#23n7oW;+4C}rKs&A}(I4exyM(J$CbGS%jL#0e zODFW()M=`4bsf)S$2+x9v1mx7h1PUv><~XPNEj1I=};3U1PCni__4z+vgGNwXh&K1 zwK+1HozXI4)?t@P4mDD=jHvFyJTrPqX#G8P+(LznIg{V>YIp4*q?O<2aP_Q1zl|#j zFDJGUwdy_H$$EFi_0<*H?&k`@CXb8AheM^O$$~y-`zL~Wg?cpAE#CY4Yw?70-bX76 z(VV-O0Y6kvAIjq(aS!9Xms7468h|&?YR>qm2WzjI8{hfB^li*YHWJ12VQ$kHA&fV# zRQP5=Y`Uwy>R=?)Z(jrD5GDZ?)Wxu0auS?q$KI@Yu&Pg>jxkIs41!g^SR-ROJDyB7 zJ$t>(w*5{X#4LNX7T?&(KSq4>VOO}WtKCPMLSvY1!{Hze%Q*ff=0l*m zJ>4bS7*4{KnpKq}p%d*$@lKw|J=zmQO5Vg!8q+4&LRhd?7Ymi@A{TyG)Z%u8Sonwf zn9L}g90L|$`i6;3WeV_wH^E(I#pml_xa+{Mry;Qm)=0S zI{V5*f2-O+87fhg$CR>#l%?&tNn;g^%_T@S!;3sDwnacEOa7apOA`q1L0UrG97XygeeZT(H?hg0Umj^vDT3AqH<^ zzH9sl4uo3Sg+yA!ow1Q+9@+6-^)hmmv|UBn`=mDn+RxqLs;6^f7EyH4X(mhrLXwAM zcoUryWIwBe)@9#6!Wvo__pTb&8ZF}y(yGzi>>%C0rzXcwuaLIU-sW*hyLW{I4AROj zoE%p!<>MQCOI9Si@)WHWzC*L+V-QKo}E6Zng^ey9|NtMjjsDz z4w)#QX5%*<7}^rP)!NVP^V$sUd0^1*B4X67X+^eeaFCD=Sx&9F_;G|wD$S$~ z@h1o#_gd|3w`|V2`DA4#OcPS1rdF2)3@E+#^0DPR+S!3gN~+)C>v&7-aw!hkL?^Ln zCE;k#oU=Z>)GHO-JZU0(Ai&aQh~mOGkhy{7A8dQ#f$_Nx^;{TYULQ4Pubp8q@A8nr zic$ULzPHhA+~)$H->Tp*I@EoxA_oWM4mURnZ0KqB`$lGHXZPzu_Q3)w`vnj6DZ|ex zoAE_Fe~>)F<^nMsu68x14@)s7nPKdmQP7l}em4_!F1RKFwBkN|(h3Zno(g|vBOMoz zDF0$cZBeE59y4XMlm=Bqm0~Oo#nfJeH{uF8B?hZRK;*qOrG0Jbyv=Mv`v3^V3!PQ5 zcDCwq&d4M?yZzAsxx-?HELwDwGFa79RY}wD$EjEJs`BzbIvx2v4)-?wnmuf*@ll2r zLqmY~2Ufkq<;;2fo8U9V_1HZ0Dfp!g4WjjCk@5USw0#dnW-8qFYxqLpWx99l9Gob8 zfqUy3aQQp$ZCJ+oTkvWTYMd?+4y0Dj>>peoJN7G?Ma(|@*!r7nL!)ztC!pUR|HkHq z4o-J3yObGMtCmnoW(BIX*c?I=-l(d6ht=IuTOhsr+NJ z4Lc?x(kec#wZgiekq~FgV(#21c(4Cky5a8Is0$h@U^70Yzf_{~lgrhYsdTER$h|Z{ z={-YD0PqPelSA%dIV4Xbrj>YR1%H5l2d$D!8A)gs5ECE4dA5+`l}V`yul+EQheb62 zIa?Hz-pfJ1$e|(eQWk%n&z6S=+e7dO-18KHSrgs92LX;8Nn0(1RRv6=kylBpnSf}y zu;b?Ch2Qf!NHb7VWfvXEfJ)Y-yH~jwN2n}zFHIGqDv^hX$XRp&@Q*W&hFRxNNeFLE zO=DD4#KsCGNGOd27+GO_kO6(=qi7fRudPcds-lqJXlf?VUv1#yW-NGuX5Z{iwm{_Y zNg!9S3rw#wVp8n#tUq~B~!;Auibq?{k=-Q%Q)jfOJ00y>~+RPi#%rJ@@@d%nN3sd zo?9~0+?Rs<^KY0x5?ACWThJPXM+7&Vm$)Vpb%=3F6fPP@*!dZ@BFX~xBQ7Zu;WiPv zTo%zWU6uG9xCvcsG*FqAz`RQn4kK5NN~d{}Bd_vPi`U<0$M>Rm8rYlF7_^h3CnGu~ zn`FN<+b7KHo^_3$oZs}}hyKR@UInJ8Y=*m|RwdYO7H&*OO#GcVvLxDSwbHL`sNGA|TL zS;)4KiFVVPX77Na+6S!*KD9yA6R}I?x$&gBdixI^O6i)6;`_GJ#K7QLw=_J_x2a?3 zs)=}o9%L_u@4zC9no+5v&<<+KHW>cu$ot+Xvy>Jl>uVVmwXv!8tN!jY_?0uSqYx91z&Q%j zWb7(}Jv)@WVG93JZJX4Z^wJQTO7{Jk%q%RA+Uz%Gbp?;5ST90d_+Tpxy$_{zVQpoT z4Fte_7r74QOwm_iOy2DIwpkqhF*eu0v*NwsFP2=JhDEd9Cg$XW@Gi_E8|HBS{tW{> zS7DA9tSh3|^XY_&D0tjj)Nn_ABa=U@9@lFNOZr6$@z1yRMF}Ks@pu>~yPs4&2QfS$ z8tXC=Z#HG!v2{#SWr!cmM`BvVSmhct=+;~7^~2f<(MTkWb4Djfdm7K`N%uMA9UMru zNoY!G-LG0e52+<+|CLpG(+hbJRgGR^_eWkPu9woEeCU-f7>d=lgVA^zs0{r5kMrKV zwzx`gDcY1XI$b)?FBw+=3!=3Mp8DrER>X8z>YO4e%D}+l1OEG?;!O$u)BeK~I78xq0;Z)ou>D9O!{}s|Ww>^zI|rEeZhW{Ehi32mfErZk~#MwHs#;=sx@B;plH) zcb|@KK>)yyTgbmv=>N;(&878MyYUzeo$X(m_uqc+s@}Iy0O0-Kz<+!He#LtQUkwou;@9JfLz@E?` z{sR9?4Y~`zt04V>7eNWZPxu`@>242q1)D!Tcw+q1!(Z~vAN-Bw-&y_lvbSOWgTGZS z{$BRG+QlEoYkvd(C>eiz|7XJZr;9qM8uEML{-KY5(EoenH|*(GyUCVSC`I}iyWd>u z@1fsiQ@2>C { + const result = await $`bun ${cliEntry} ${args}`.quiet(); + return result.stdout.toString(); +} + +const QUARTERS = ["Q1", "Q2", "Q3", "Q4"]; + +/** A string cache: `` with its formula and points. */ +function strRef(formula: string, values: string[]): string { + const points = values + .map((value, index) => `${value}`) + .join(""); + return `${formula}${points}`; +} + +/** A number cache; a `null` value is a gap (no `c:pt` for that index). */ +function numRef(formula: string, values: (number | null)[]): string { + const points = values + .map((value, index) => + value === null ? "" : `${value}`, + ) + .join(""); + return `${formula}General${points}`; +} + +function series( + index: number, + column: string, + name: string, + values: (number | null)[], + extra = "", +): string { + return [ + ``, + `${strRef(`Sheet1!$${column}$1`, [name])}`, + extra, + `${strRef("Sheet1!$A$2:$A$5", QUARTERS)}`, + `${numRef(`Sheet1!$${column}$2:$${column}$5`, values)}`, + "", + ].join(""); +} + +const AXES = + '' + + ''; + +function chartSpace(title: string | null, plot: string): string { + const titleXml = title + ? `${title}` + : ''; + return [ + '', + '', + `${titleXml}${plot}${AXES}`, + '', + "", + ].join(""); +} + +const COLUMN_CHART = chartSpace( + "Revenue by Quarter", + [ + '', + series(0, "B", "West", [120, 135, 150, 170]), + series(1, "C", "East", [90, 110, null, 140]), + '', + ].join(""), +); + +const COMBO_CHART = chartSpace( + null, + [ + '', + series(0, "B", "Units", [40, 45, 52, 61]), + '', + '', + series( + 1, + "C", + "Margin", + [0.21, 0.24, 0.22, 0.27], + '', + ).replace("", ''), + '', + ].join(""), +); + +/** An inline `` paragraph pointing at chart relationship `rId`. */ +function chartParagraph(rId: string, drawingId: number, name: string): string { + return [ + '', + '', + ``, + '', + ``, + "", + ].join(""); +} + +/** `raw part add` the chart part, then `raw insert` its relationship; returns + * the minted rId. */ +async function addChartPart(partNumber: number, xml: string): Promise { + await cli( + "raw", + "part", + "add", + out, + "--name", + `word/charts/chart${partNumber}.xml`, + "--xml", + xml, + "--content-type", + CHART_CONTENT_TYPE, + ); + const relOutput = await cli( + "raw", + "insert", + out, + "--xml", + ``, + ); + const rId = relOutput.trim().split("\n")[0]; + if (!rId?.match(/^rId\d+$/)) + throw new Error(`expected a minted rId, got: ${relOutput}`); + return rId; +} + +const REPORT_MARKDOWN = [ + "# Quarterly Report", + "", + "Revenue grew every quarter; East has no Q3 figure yet.", + "", + "| Units and margin |", + "| --- |", + "| placeholder |", + "", + "Figures are unaudited.", +].join("\n"); +const markdownPath = resolve( + root, + "tests/fixtures/setup/.charts-report.tmp.md", +); +await Bun.write(markdownPath, REPORT_MARKDOWN); +await cli("create", out, "--force", "--from", markdownPath); +await Bun.file(markdownPath).delete(); + +const columnRid = await addChartPart(1, COLUMN_CHART); +await cli( + "raw", + "insert", + out, + "--after", + "p1", + "--xml", + chartParagraph(columnRid, 101, "Chart 1"), +); + +const comboRid = await addChartPart(2, COMBO_CHART); +await cli( + "raw", + "replace", + out, + "--at", + "t0:r1c0:p0", + "--xml", + chartParagraph(comboRid, 102, "Chart 2"), +); + +console.log(`built ${out}`); diff --git a/tests/integration/libreoffice-roundtrip.test.ts b/tests/integration/libreoffice-roundtrip.test.ts index 537866e..5a6b41e 100644 --- a/tests/integration/libreoffice-roundtrip.test.ts +++ b/tests/integration/libreoffice-roundtrip.test.ts @@ -35,6 +35,7 @@ const CORE_FIXTURES = [ "headers-footers.docx", // header/footer parts: default two-zone (text+date), first-page (titlePg, + a from an inline-escape \n), footer PAGE/NUMPAGES fields; root starts as xmlns:w only, so every r:id re-declares xmlns:r on save "normalize-query.docx", // find normalization coverage + cross-paragraph replace (merged + split paragraph XML) "raw-ooxml.docx", // `docx raw` escape hatch: drop-cap framePr, nested table, sectPr patch loop (lnNumType) + dcx:raw markers + "charts.docx", // native c:chart drawings (cache-only, wired via raw part add + rel + drawing): a column chart in the body, a column+line combo in a table cell "text-boxes.docx", // Word-shaped text box (mc:AlternateContent wps Choice + VML Fallback) edited through tbxN:pK, Fallback re-synced on save ] as const;