Skip to content

ci: add e2e tests & readiness checks#132

Open
MathiasLundgrenEY wants to merge 2754 commits into
iOfficeAI:mainfrom
MathiasLundgrenEY:main
Open

ci: add e2e tests & readiness checks#132
MathiasLundgrenEY wants to merge 2754 commits into
iOfficeAI:mainfrom
MathiasLundgrenEY:main

Conversation

@MathiasLundgrenEY

Copy link
Copy Markdown

Adds Playwright e2e tests, a startup readiness endpoint, and CI readiness polling.

goworm added 30 commits May 19, 2026 03:17
…arning

EmitWord stamps noMarkRPrInherit=true on every emitted `add r` row so the
markRPr->rPr inheritance fill stays opt-out on dump->batch replay (matches
the source's "run has no rFonts even though para mark does" shape).

AddRun consumed the flag at line ~1587 to gate the inheritance fill, but
the bare-key fallback at the bottom of AddRun never saw it on its curated
allowlist. ApplyRunFormatting and TryCreateTypedChild both miss (it is not
a real OOXML attribute), so every dump-emitted run landed the key in
LastAddUnsupportedProps and the batch driver printed:

  WARNING: UNSUPPORTED props: noMarkRPrInherit

Add "nomarkrprinherit" to addRunCuratedBare so the inheritance-toggle
sentinel is treated as already-consumed instead of re-flagged as unknown.
…clock

Two findings from Mac PowerPoint round-trip on the modern gallery:

1. p15 -out variant: Mac PowerPoint's Effect Options writes a single
   invX="1" attribute, not invX="1" invY="1" together. Setting both
   makes PowerPoint silently reject the whole <p15:prstTrans> element
   and play the mc:Fallback fade instead — the file opens with no
   transition highlighted in the gallery and no preview animation.
   Drop invY from the -out emission and the readback regex. Now matches
   PowerPoint's own output byte-for-byte for direction-sensitive
   presets (peelOff, airplane, origami, wind, fallOver, drape).

2. The "Cube" / "Rotate" / "Orbit" UI tiles are NOT separate elements —
   they're all the same <p14:prism/> element with two boolean attrs:
     bare <p14:prism/>                            → "Cube"   (Exciting)
     <p14:prism isContent="1"/>                   → "Rotate" (Dynamic Content)
     <p14:prism isContent="1" isInverted="1"/>    → "Orbit"  (Dynamic Content)
   "Clock" is similarly <p:wheel spokes="1"/> — a single-spoke wheel.

Wire all four as CLI tokens:
- `cube` accepted as input alias for `prism` (same bare XML, readback
  stays as `prism` to preserve the OOXML element name)
- `rotate` and `orbit` are new canonical tokens, round-trip via
  PrismTransition.IsContent / IsInverted
- `clock` accepted as input alias for `wheel-1` (readback stays as
  `wheel-1`)
- Readback for <p14:prism> distinguishes the 3 variants by attr
  combination so set transition=rotate/orbit round-trips

schemas/help/pptx/transition.json: extend enum with the 4 new tokens.

examples/ppt/transitions/transitions-dynamic.{sh,pptx}: replace the
broken prism-up / prism-right entries (prism is direction-less; the
suffix was silently dropped) with explicit prism / rotate / orbit
slides that actually round-trip.

examples/ppt/transitions/transitions-modern.{md,pptx}: regenerate with
the invX-only fix; add a "UI tiles backed by other elements" table
explaining the cube/rotate/orbit/clock cross-namespace mapping so
agents reading the modern trio know where to find those four tiles.

Tests (PptxTransitionR24Tests): 5 new round-trip cases (cube/prism/
rotate/orbit/clock). 64 transition tests pass.
…xplicit font

EmitSection injected `set / docDefaults.font.latin=""` whenever the post-
baseline-filter prop bag lacked a docDefaults.font key — intended to clear
the BlankDocCreator-stamped Times New Roman when the source omits the slot.

The baseline-skip loop directly above suppresses emits for keys whose source
value matches the blank's value. When the source DOES carry an explicit
`docDefaults.font` matching the blank (Calibri vs. Calibri) the skip fires
and the prop is absent post-filter, which the empty-clear injection then
misread as "source has no font" and stamped an empty string into the batch.
Replay set the empty value, clearing the source font (rFonts ascii/hAnsi
became blank) and dropping the document's intended typeface on round-trip.

Guard the injection on the raw source Format presence instead of the
filtered prop bag — only inject the empty clear when the source truly
carries no docDefaults.font[.latin]. Existing TNR-clear path for source
documents lacking the slot is preserved.
pptx dump emits `name` and `lang` on textbox add items (AddShape consumes
both — name defaults to "TextBox {N}" via cNvPr, lang stamps drawingML
rPr/@lang on the first run). pptx/textbox.json declared neither, so the
emitted batch tripped schema drift checks even though the handler accepted
the values.

textbox does not extend `_shared/shape`, so the `name` declaration in the
shared base does not flow through. Add `name` and `lang` directly on
pptx/textbox.json mirroring the pptx/shape.json declarations.
pptx dump emits zorder on shape/textbox add items: AddShape (Add.Shape.cs
line 737) and ApplyShapePropsCore (Set.Shape.cs line 743) both consume
zorder/z-order/order and reposition the shape in the slide shape tree.
Schema declared zorder as get-only on both pptx/shape.json and
pptx/textbox.json, so the emitted batch tripped schema-drift checks.

Update pptx/shape.json to declare zorder add=true / set=true with the
aliases (z-order, order) and an example. The matching textbox.json
declaration was already updated in the previous commit when name/lang
were added.
…files

The .md walkthroughs are user-facing examples documentation focused
on CLI usage; the OOXML element names, namespace prefixes, mc:Choice/
mc:Fallback wrapper anatomy, and prst attribute mechanics that crept in
during development belong in source-code comments, not here. Rewrite
the affected passages to describe the same behavior in CLI terms:

- "OOXML representation" code blocks (modern, morph) — removed.
- "Stored as <p:transition spd=...>" — replaced with "Get surfaces the
  value as the read-only transitionSpeed format key".
- "<p14:reveal/> element had no dir attribute" / "p15 namespace" /
  "CT_OptionalBlackTransition" — replaced with plain-language
  descriptions of the user-visible behavior.
- transitions-modern.md's "UI tiles backed by other elements" table
  drops the OOXML column, keeps just (UI name → CLI token).

Net effect: the same agent or user reading these .md files now learns
which CLI token to use and what to expect from Get/Preview, without
the implementation-detail noise.
`get /slide[N]/shape[K]` bubbled the first run's size and color up to
the shape-level Format dictionary unconditionally. When the textbox
held runs with mixed formatting (e.g. run[1] red 28pt, run[2] blue
14pt), the shape-level Format reported only run[1]'s values, so an
agent inspecting `Format["color"]` couldn't tell whether the textbox
was uniformly red or actually mixed.

Walk every run in the text body once before emitting; if the size or
color set has more than one distinct value, suppress that key from
shape-level Format. `ContainsKey("size")` / `ContainsKey("color")` is
now the contract for "this is a meaningful summary".

Run-level Get is unchanged — `/slide[N]/shape[K]/paragraph[P]/run[R]`
still returns the per-run value. Set on the shape path also stays as
a broadcast (writes the same value to every run) so the asymmetry
between input and output stays explicit.

Heuristic stays narrow: only `size` and `color` are checked. Font /
bold / italic / underline etc. continue to surface the first run's
value — they have lower mixed-formatting incidence in practice and
expanding the probe risks suppressing keys users expect to see.
`add /slide[N] --type slide --prop title=T --prop text=B` emitted two
shapes asymmetrically: the title got `<p:ph type="title"/>`, but the
content was a bare textbox with no placeholder reference. On Get one
came back as `type=title phType=title isTitle=true`, the other as a
plain `type=textbox` with no phType. The content side also never
inherited layout styling because it wasn't bound to any layout slot.

Both shapes are auto-emitted in response to the same `add slide`
shortcut and bind the same way conceptually — populate a layout slot
with caller text. Tag the content shape with the matching marker so
the two paths produce symmetric on-disk shapes and symmetric readback.

CreateTextShape gains `placeholderType` / `placeholderIndex` optional
parameters; the title path keeps its isTitle flag (with the implicit
title placeholder), and the content path now passes
`placeholderType: PlaceholderValues.Body, placeholderIndex: 1`.
NodeBuilder's existing placeholder classifier handles the Get-side
readback — no further changes there. Existing callers that didn't
need a placeholder (everything except Add.Slide's content path) keep
the previous default-null behavior.

Side effect: `/slide[N]/placeholder[K]` now finds the content shape
alongside the title, so direct slot addressing works without needing
the `/slide[N]/shape[K]` form.
…d depth

Two doc-only schema clarifications.

pptx/slide.json — layout slot population

Reading `help slide --json` showed `layout` documented as a metadata
field with no guidance on what happens to the layout's placeholder
slots. The actual contract: `layout` is metadata only — slots are NOT
auto-materialized. Population paths are the `--prop title=…` /
`--prop text=…` shortcuts (which emit shapes with <p:ph type="title"/>
/ <p:ph type="body" idx="1"/> markers respectively) and the explicit
`--type placeholder --prop phType=…` route. Surface those three paths
in the layout description, and cross-reference the OOXML markers in
the `title` / `text` property descriptions so on-disk shape and Get
readback (type=title vs type=placeholder phType=body) are predictable.

pptx/table.json — default read depth

`get /slide[N]/table[K]` returned table-level Format plus row stubs
but empty cells, while `get /slide[N]/shape[K]` returned paragraphs
and runs in one call. Reading the schema didn't reveal that
`--depth N` already controls how deep Get descends. Document the knob
in the table's top-level note: depth=1 is the row-stub summary;
depth=2 materializes cells; direct addressing via
`get .../row[R]/cell[C]` is always available. The default stays at 1
because large tables (e.g. 50x10) at depth=2 dominate the response.
`officecli add file.pptx --type slide` (no parent argument) raised the
default System.CommandLine error "Required argument missing: <parent>"
with no hint that pptx slide takes '/', docx takes /body, xlsx takes
/Sheet1. New users had to guess the per-handler convention or read
source.

Expand the parent argument's Description so the per-handler examples
appear in `officecli add --help` output. Also flag the zsh single-
quote habit for paths with brackets, since unquoted /slide[1] gets
glob-expanded by zsh and bash.
zsh treats `[N]` as a glob character class; bash with extglob also
expands unquoted brackets. Copy-pasting an example like
`--prop from=/slide[1]/shape[1]` from `officecli help` output into a
zsh shell yielded `zsh: no matches found`, and the user had to
recognize the brackets needed quoting.

Wrap the path token (not the whole example string) in single quotes
across the four `examples` arrays where bracketed paths appeared:

  schemas/help/pptx/connector.json   — from / to shape refs
  schemas/help/pptx/moderncomment.json — parent comment ref
  schemas/help/pptx/picture.json     — link=slide[N] action token
  schemas/help/xlsx/slicer.json      — pivotTable ref

`note` / `path` / `positional` fields (which document the canonical
form, not copy-paste material) stay unquoted — they're definitions,
not shell-ready strings.
Bug: EmitChart never emitted a `set /slide[N]/chart[K]/axis[@ROLE=ROLE]` row.
Chart-level shortcuts (axisMin/axisMax/axisTitle) only target the primary
value axis, so any per-role override — especially role=value2 on a secondary
axis — was silently lost on dump→batch replay. role=value2 min/max/title
all dropped; primary axis tick/format tweaks not covered by chart-level
keys also dropped.

Root cause: EmitChart only emitted a single `add chart` row with chart-level
props. The Set side accepts axis sub-paths, but the dump side never walked
them.

Fix: After the add row, iterate {category, value, value2, series}. For each
role, Get the axis sub-path, filter out BuildAxisNode's synthetic defaults
(visible=true, majorGridlines true/false matching AddChart's seeded state,
majorTickMark=out, crosses=autoZero, etc.), and emit a set row when any
non-default key remains. Missing roles (pie / doughnut have no axes) are
silently skipped via the Get-throws catch.

New behavior: dump now round-trips per-axis min/max/title/tick/gridline
overrides. Secondary value axis (role=value2) finally survives a replay.
Chart types without the axis silently skip — no spurious rows for pie etc.
Bug: dump only emitted slideWidth / slideHeight on the `set /` row.
firstSlideNum, rtl, show.loop, show.narration, show.useTimings,
print.what, print.colorMode, compatMode, removePersonalInfo were all
silently dropped on dump→batch replay.

Root cause: EmitPresentationProps hard-coded the slide-size pair and
ignored every other key TrySetPresentationSetting (Set.Presentation.cs)
accepts.

Fix: Iterate a curated allowlist (PresentationEmitKeys) mirroring the
setter's accepted bare keys, plus a special `direction → rtl` rewrite
because Get emits `direction=rtl` while the setter case key is `rtl`.
Empty strings are dropped (the standard "value matches OOXML default,
PopulatePresentationSettings omitted it" signal).

New behavior: presentation attribute set survives round-trip. Defaults
still produce zero items so unchanged decks don't gain spurious rows.
Bug: a slide with [group at zorder=1, shape at zorder=2] (group behind,
shape in front) replayed as [shape, group] = [1, 2] — z-order flipped
because the group's add row carried no zorder prop and AddGroup defaults
to append.

Root cause: EmitGroup ran FilterEmittableProps on the direct-Get of the
group path, which strips zorder. The slide-enumeration NodeBuilder branch
that surfaces zorder fires only when the group appears as a *child* of the
slide, not on a direct Get of the group's own path.

Fix: after FilterEmittableProps, look up zorder on the source grpNode
(passed in by the slide walker) and preserve it. Schema follows suit —
pptx/group.json declares zorder add/set/get=true (was all false), matching
the AddGroup / SetGroup handler behavior that already accepted the prop.
Schema-emit drift: handler-side add/set already accepted these keys, but
the help schema either omitted them entirely or marked them add=false.
dump emit (which mirrors what add/set accept) produced JSON whose property
set was not reflected in `officecli help <type>` output — agents inspecting
the schema as the canonical contract had no way to learn the key existed.

Aligned declarations:

_shared/chart: dispBlanksAs, varyColors flipped to add=true (set/get were
  already true; emit went into the chart add row, schema lagged).

pptx/chart, pptx/table: declare zorder add/set/get=true (handler accepted
  via ApplyShapePropsCore; emit started carrying it after the R6 shape
  schema alignment).

pptx/paragraph: declare bold, italic, color, size, lang. dump's single-run
  fold path collapses a paragraph's only run's character props onto the
  paragraph (defRPr); the schema needs to declare them so the resulting
  set row's keys validate.

docx/comment: declare runStart add=true. dump emits this when the comment
  range starts inside a paragraph (after the Nth run) so replay can restore
  the intra-paragraph anchor.

docx/table-cell: declare skipGridSync set=true. The R3 set-side fix
  recognized it; the schema follows.

No handler changes — pure schema declarations matching long-standing
handler behavior.
…hor crossBetween before majorUnit

Radar and bubble dump→batch replays produced files PowerPoint refused
to open. Two schema-order bugs in chart setters:

1. The varyColors setter anchored the new element after Grouping /
   BarGrouping / BarDirection only. Radar and scatter chart types have
   no Grouping element; their schema prefix is `radarStyle, varyColors,
   ser*` / `scatterStyle, varyColors, ser*`. The fallback PrependChild
   landed varyColors before radarStyle / scatterStyle, which the OOXML
   validator rejects as an unexpected child.

2. The axis crossBetween setter used AppendChild on the value axis. The
   CT_ValAx tail is `crossAx, crosses?, crossesAt?, crossBetween?,
   majorUnit?, minorUnit?, dispUnits?, extLst?`, so AppendChild lands
   crossBetween after majorUnit when the chart builder already emitted
   majorUnit. PowerPoint silently rejected the resulting file.

Extend the varyColors anchor chain to also accept RadarStyle / ScatterStyle,
and re-anchor crossBetween after crossesAt / crosses / crossAx so it
precedes majorUnit regardless of emit order.
ChartHelper.Reader emitted Format[\"transparency\"] in raw OOXML alpha
units (e.g. 70000 for 30% opaque). The series transparency setter
expects 0..100 percent and converts to alpha = (100 - transparency) *
1000. Replaying a dumped chart fed 70000 to the setter, producing a
negative alpha element that fails the SrgbClr/Alpha MinInclusive=0
schema constraint and breaks PowerPoint open.

Emit transparency as a 0..100 percent computed from (100000 - alpha) /
1000, matching the setter input contract. The companion `alpha` field
still mirrors the raw OOXML units for round-trippable color-with-alpha
inputs.
…eeds no run

AddPlaceholder seeds the txBody first paragraph with <a:endParaRPr> only —
no <a:r> element. The batch emitter walked the source paragraph and
treated the seeded paragraph the same as a shape/textbox seed (which DOES
include one empty <a:r>), so the first run was emitted as `set
.../paragraph[1]/run[1]`. When the source paragraph carried run-only
attrs (e.g. lang) without text on the paragraph-level collapse, replay
targeted a non-existent run and the batch step failed.

Thread a `seededFirstParaHasRun` flag through EmitTextBody / EmitParagraph
and set it false for the placeholder caller. When the seeded paragraph
has no run, both the multi-run path and the single-run-collapse runOnly
follow-up switch from `set .../run[1]` to `add run` so the run is
materialized on demand. Shape/textbox callers keep the existing
rewrite-the-seed behavior to avoid the +1 phantom-run drift on round trips.
EmitRun and EmitFirstRunAsSet routed run-internal `link=slide[N]`
through DummyCtxStripSlideJump, which silently removed the link prop
on the assumption the shape-level emit had already deferred it. But a
shape can carry both a shape-level link and per-run slide jumps on its
text, and the shape-level defer only owns the shape's hyperlink. The
run's slide-jump was lost on every dump, leaving the replayed run with
no hyperlink at all.

Thread SlideEmitContext through EmitTextBody → EmitParagraph → EmitRun/
EmitFirstRunAsSet and add DeferRunSlideJumpLink — the run analogue of
DeferSlideJumpLink. Slide-jump links get queued onto ctx.DeferredLinks
with the run's positional path as the target, joining the existing
end-of-batch flush so the cross-slide reference resolves once every
target slide is materialized. External URLs and named actions stay on
the inline `add run` path (AddRun.ApplyRunHyperlink writes them
directly).
…series set

ChartHelper.Reader emits per-series outline as three Format keys
(outlineColor, lineWidth, lineDash) but the series setter only honored
the compound `outline=color:width:dash` form. Dump→batch replays of
charts with explicit per-series outline lost the line spec because the
emitter forwarded the read-side keys verbatim and the setter routed
them to unsupported.

Add `outlinecolor` / `linecolor` cases that update only the SolidFill
inside the outline element (preserving any existing width / dash), and
add `outlinewidth` / `outlinedash` aliases mirroring the existing
`lineWidth` / `lineDash` handlers. SolidFill is inserted before any
PrstDash child to keep CT_LineProperties schema order.
… on chart add

The chart batch emitter flattens NodeBuilder's per-series Format keys
onto the chart-level `add` row as dotted `series{N}.<key>` so the chart
setter can apply them after the chart is built. The flatten list
covered color / lineWidth / lineDash / marker / markerSize / smooth /
outlineColor / transparency but omitted gradient. Charts whose series
each carried a distinct GradientFill replayed against the chart-level
gradient fallback only — every series got the first series's gradient
spec.

Add gradient, outlineWidth and outlineDash to the flatten list so each
series's own spec round-trips. The dotted setter route through the
existing per-series cases (gradient / outlinewidth / outlinedash /
linecolor) already lands them correctly.
AddPlaceholder always assigned an idx to non-title placeholders by
matching the layout slot. When source XML carried <p:ph type='subTitle'/>
with no idx attribute, NodeBuilder correctly omitted phIndex from the
dump (its emit-only-when-Index-has-value contract), but AddPlaceholder
on replay auto-bound idx=1 from the layout match. The resulting
<p:ph type='subTitle' idx='1'/> inherited body's default bullet style
from the layout/master cascade and rendered a phantom bullet that the
source never had.

Detect whether the caller explicitly provided idx and, when phType is
subTitle without an explicit idx, bind to the layout slot by type alone
— leaving Index unset on the <p:ph> emit. ECMA-376 lets the type
attribute drive the slot match for the title family; subtitle has the
same property. Other placeholder types keep the existing auto-allocate
behavior because they routinely share a phType across a slide (body
slots at idx=1, idx=2, ...) and need the disambiguator.
NodeBuilder's line readback covered solid fill / none / dash / width
but ignored <a:gradFill> inside <a:ln>. Reader emit dropped the
gradient and replay rebuilt the shape with a bare <a:ln/>, which
PowerPoint resolves to a default thin black stroke. Gradient lines —
including the spec-form connectors that present multi-color flow
lines — visually flattened on every round trip.

Surface the gradient as Format["line.gradient"] using the same spec
form the shape-fill gradient already emits, and add `line.gradient` /
`linegradient` cases on both the shape (ShapeProperties.cs) and the
connector (Set.Shape.cs) setters. The fill child is inserted before
any PrstDash sibling to keep CT_LineProperties schema order
(fill → prstDash → headEnd → tailEnd), which PowerPoint silently
rejects when violated.
The earlier fix re-anchored crossBetween in ChartHelper.Axis's axis-role
setter, but the chart-level `crossbetween` case in ChartHelper.Setter
still used AppendChild and landed crossBetween after majorUnit /
minorUnit. Dumped charts that go through the chart-level Set path (bar,
column) replayed with an out-of-order valAx that PowerPoint silently
rejected.

Apply the same anchor chain (CrossesAt → Crosses → CrossingAxis →
InsertAfterSelf) on the chart-level path so both entry points land
crossBetween in the right position regardless of whether the source
build emitted majorUnit before the cb set.
`add chart --prop series1.outlineColor=#FF0000` (and outlineWidth /
outlineDash) silently dropped because DeferredSeriesSubkeys only listed
the compound `outline` key. IsDeferredKey returned false for the dotted
subkey form, so the property was handled before the series element
existed in the chart XML, then discarded.

Add `outlinecolor`, `outlinewidth`, `outlinedash` to DeferredSeriesSubkeys
so they route through HandleSeriesDottedProperty after build, matching
the existing dotted-subkey contract.
ConnectorToNode had per-key readback for outline width, dash, solid
color, and alpha, but no branch for <a:gradFill> on the connector's
<a:ln>. A connector authored with a gradient line (or one whose
gradient was applied via `set --prop line.gradient=…`) round-tripped
to a bare <a:ln/> on dump→batch replay because the spec key never
surfaced in Get; replay then fell back to the theme's default thin
stroke.

Read the GradientFill via ReadGradientString and emit `line.gradient`,
mirroring the existing shape-outline gradient readback.
A series authored with `trendline=poly:3` or `trendline=movingAvg:5`
round-tripped to bare `poly` / `movingAvg` on Get and dump, silently
dropping the polynomial degree and moving-average window size. The
reader only emitted <c:trendlineType val>; the adjacent <c:order val>
and <c:period val> children were ignored.

Format the spec via FormatTrendlineSpec, which appends `:N` for poly
(from <c:order>) and movingAvg (from <c:period>). Other trendline
types (linear, log, exp, power) keep their bare-name spec. Applied at
both the chart-level trendline summary and the per-series readback so
dump→batch replay rebuilds the exact curve.
ReadGradientSpec only appended the `:angle` suffix when the linear
gradient's angle was non-zero, so a series gradient authored as
`gradient=A5A5A5-D0D0D0:0` round-tripped to bare
`A5A5A5-D0D0D0` — the explicit zero-degree (left→right) direction
collapsed into "no angle specified", letting the writer fall back to
the schema default on the next round-trip.

Emit `:0` whenever <a:lin ang=…> is present, regardless of value.
The angle is a meaningful user choice (0 = horizontal, 90 = vertical,
…) and must survive dump→batch replay verbatim.
Shape gradients use `C1-C2-ANGLE` while chart series gradients use
`C1-C2:ANGLE`. Users routinely mix the two — feeding a chart-style
spec into a shape (`fill=FF0000-00FF00:45` or `gradient=…:90`)
parsed the colon as part of the second color token, then the parser
either rejected the value or silently dropped the angle.

Normalize a trailing `:N` (integer in [-360, 360]) to `-N` at the
front of NormalizeGradientValue when the input is not already a
typed prefix (radial:/path:/linear;). Get/dump still emit each
surface's canonical separator unchanged, so existing round-trips
and the schema contract are untouched.
…pec call

InnerText is a nullable reference type; pass "" when null so the
non-nullable parameter contract holds. The guarding HasValue check
ensures Val itself is non-null, but does not propagate to InnerText.
goworm and others added 29 commits May 26, 2026 15:50
- pptx/chart.json chartType: inline the enum values list (the per-format override replaced the shared base atomically; without inlining `values`, programmatic consumers like SchemaContractTests lose the legal-value array).
- pptx/textbox.json + shape.json size/bold/italic/color: flip get=false; these forward to the first run on Add/Set but Get exposes only effective.size / effective.color / inheritance summary at the shape level, with bare bold/italic only on the inner run.
- _shared/table-row.json cols: revise description ("asserts the new row's cell count matches the table grid") and example to cols=2 — handler enforces match, "override" was misleading.
- pptx/table-cell.json text: override to add=false; pptx tables are strictly rectangular per OOXML, so a standalone Add cell only succeeds when the row currently has fewer cells than the grid (an illegal state). Set + Get unchanged.
tooltip is the screen-tip on a hyperlink; without a 'link' to attach to, the value would be silently dropped. Mirror the Set-side guard so callers see a descriptive ArgumentException instead of a no-op, and bundle link+tooltip in a single call (or Add the shape first, then Set link+tooltip together).

CONSISTENCY(shape-tooltip-requires-link).
CheckInBackground returned early on Directory.CreateDirectory failure
and SaveConfig silently swallowed write errors. Together they meant
every officecli call in a read-only-home container respawned the
refresh process — one HEAD /releases/latest per command instead of
one per 24h, polluting origin analytics and the caller's egress.

LoadConfig + SaveConfig now iterate a candidate list: ~/.officecli/
config.json first, then $TMPDIR/officecli-config.json only when
IsInContainer() trips (docker, k8s, podman, lambda, cloudrun, gcp-
functions). Desktop and VM users never touch /tmp; iteration stops
at first success. SaveConfig returns false when every candidate
fails and CheckInBackground uses that to skip the spawn.

While here, shorten the UA: OfficeCLI/{ver} replaces OfficeCLI-
UpdateChecker/{ver}, with " (container)" appended in container envs.
Server-side stats on d.officecli.ai accept both forms via
OfficeCLI(?:-UpdateChecker)?/(\d+\.\d+\.\d+) during the rollover.

ConfigDir + ConfigPath are now get-only properties so tests can
swap \$HOME between cases without restarting the process.
… together; map friendly lineDash aliases to sys*/lg* variants

crosses / crossesAt branches mutually wiped each other: both branches called RemoveAllChildren on the COUSIN type before writing, so a single Set call that supplied both keys silently dropped whichever ran first. Restrict each branch to remove only its own type (CT_ValAx schema lets both children coexist, and a Set with both keys is the natural way to switch a value axis crossing point).

Also fix lineDash mapping per schemas/help/_shared/chart-series.json contract: friendly aliases ("dash" / "dot" / "dashDot") all resolve to the sys* OOXML variants (SystemDash / SystemDot / SystemDashDot). The previous mapping wrote the literal Dash / Dot / DashDot enum members, so Set("dash") + Get round-tripped as "dash" instead of the documented "sysDash". 'solid' remains the only round-trip-stable token.
Set bold.cs=false (and italic.cs=false) writes <w:bCs val="false"/> via the explicit-false-override path. The I18n reader only checked element presence, so Get reported bold.cs=true even after the user had toggled it off — diverging from how the bare bold / italic readback (which uses IsToggleOn) treats the same Val=false sentinel.

Gate the read on element AND (Val absent OR Val truthy), so the explicit-off form rounds-trips as "absent from Format" instead of as a phantom true.
…fusals

Two production hardening hunks were added in cee0b5b and parts of 84e18bb to satisfy tests that pinned aspirational "should throw" contracts; no real user reported the silent-drop / silent-no-op as a problem, so adding throws on previously-tolerant code paths is a behavior break for any existing caller passing tooltip without link, or echoing core properties through Set.

Revert:
- PowerPointHandler.Add.Shape.cs: shape Add no longer throws on tooltip without link (full revert of cee0b5b).
- PowerPointHandler.Set.Media.cs: picture Set tooltip alone returns to "silent passthrough" (the test that wanted a throw was the only voice for the new behavior).
- PowerPointHandler.Set.cs: drop the created / modified / extended.application read-only guards; Set silently ignores them as before.

The substantive bug fixes from the same commits (errBars canonical case, axis crosses + crossesAt mutual-wipe, bold.cs Val=false readback, lineDash friendly aliases, schema/handler-reality alignment, tblGridChange capture, numLevel → ilvl rename) stay.
…rts to get=false

Earlier session change flipped formula.get on the shared equation base from false to true on the strength of "handler already emits Format[\"formula\"]". That holds for pptx (PowerPointHandler.NodeBuilder.cs reconstructs LaTeX from <m:oMath>) but NOT for docx — WordHandler's equation Get path doesn't surface a formula key. The shared base therefore overstated docx's contract.

Restore the conservative shared baseline (get=false) and add a pptx-specific override that re-asserts get=true. docx equation Get readback stays honest until WordHandler grows the matching emit (no current user has reported needing it).
The xpath / action null guards added in 84e18bb weren't fixing any reported issue — they were "while I'm here" defensive code added alongside legitimate handler-canonical fixes. No caller passes null today, and if one ever did, the NullReferenceException a few lines down is just as loud as the ArgumentNullException would be. Strip the gratuitous additions; the partPath guard (which predates this session) stays.
…ses column alignment

\mathbb{R}/\mathcal{L} emitted m:scr without required val attribute, and
in wrong child order (m:sty before m:scr violates CT_MRPR sequence). Set
val to DoubleStruck/Script and put scr before sty.

cases environment emitted m:mcJc directly under m:mc, but schema requires
it wrapped in m:mcPr. Files with matrices in cases failed validation and
would not open in Word.
…row commands

Tokenizer mapped \| to literal '|'; now tokenizes as Vert command so it
renders as ‖ (double vertical bar). The original behavior collapsed norm
expressions like \|x\|_2 to |x|_2.

Command table additions: to gets mapsto iff implies impliedby land wedge
lor vee lnot neg mid parallel — all previously fell through as literal
text in math output.
…urface

Adds 25 examples (31-55) covering matrices (pmatrix/bmatrix/vmatrix),
cases, auto-sized delimiters (left/right with various bracket types,
floor/ceiling), overbrace/underbrace/overset, math fonts (mathbb/cal/
bf/rm), cancel/cancelto/boxed, accents (bar/vec/tilde/ddot/overline),
hyperbolic and inverse trig, operatorname, modular arithmetic, double
integral with text, big operators (bigcup/bigcap/coprod), full uppercase
Greek set, matrix dots (cdots/vdots/ddots), spacing controls, textcolor,
set theory, norm and inner product.
…espace

LaTeX like `\sum_{n=1}^{\infty} \frac{1}{n^s}` rendered with an empty
dotted placeholder between the summation symbol and the fraction, with
`\frac` floating as a sibling outside `<m:e/>` instead of being the
summand.

Root cause: the tokeniser collects the space between `\infty}` and
`\frac` into a standalone Text(" "). The n-ary base parser then called
ParseSingleArg on that whitespace token, which strips leading whitespace
and returns an empty run for a pure-whitespace token. The base was
therefore emitted as empty and `\frac` was parsed as the next sibling.

Fix: in the `\sum`/`\int`/`\iint`/`\iiint`/`\prod`/`\coprod`/`\bigcup`/
`\bigcap` branch, skip any pure-whitespace Text tokens between the
sub/sup limits and the base expression. The fraction (or whatever
follows the limits) now lands inside `<m:e/>` and Word renders it as the
summand without the empty box.
…instead of font-size

Add placeholder Add forwarded its leftover prop bag through Set, which
then routed `size=half` through ParseFontSize and threw
ArgumentException("Invalid font size: 'half'"). The placeholder @sz
attribute (full/half/quarter — half-slide / quarter-slide layout slots,
ECMA-376 §19.7.10) was never consumed by AddPlaceholder, so the value
fell through to the run/shape font-size branch and crashed before any
unsupported-prop accounting could see it.

Consume size/sz in AddPlaceholder, map matching values onto
PlaceholderShape.Size, and add the key to the consumed set so it no
longer leaks into the passthrough Set call.
…n arithmetic

=OFFSET(A1,5,0)&"x" was returning "0x" when A6 was empty. Empty/missing
cells reached through direct ref / OFFSET / INDIRECT collapsed to
FormulaResult.Number(0), and the string-concat operator (`&`) then
stringified the 0 into "0". Excel coerces empty cells to "" in concat
and 0 in arithmetic; collapsing both contexts to the same numeric 0
loses the concat semantics.

Add FormulaResult.Blank() — a no-value result whose AsString()
returns "" (so concat matches Excel) and whose AsNumber() returns 0
(so arithmetic keeps working). ToCellValueText preserves the
direct-reference "=A6" → 0 behaviour by handling IsBlank explicitly.
Empty-cell paths in ResolveCellResult now return Blank() instead of
Number(0).
… falls back to OS culture when --locale absent

`officecli create file.docx` now shapes blank docs around the user's locale
in two ways:

1. Explicit --locale ar-SA (and similar RTL locales) stamps <w:bidi/> on
   sectPr (page layout direction) AND on docDefaults/pPrDefault (paragraph
   default direction). Paragraphs added afterwards inherit RTL without any
   per-paragraph direction=rtl. RTL locale set: ar, he, iw, yi, ji, ur,
   fa, ps, sd, ks, ug, ku, ckb, dv, syr, nqo (CJK and Thai are LTR and
   keep current behavior).

2. When --locale is not given, fall back to the OS user culture so
   Arabic / Hebrew / Chinese / … users get a doc shaped for their
   language without having to repeat the locale on every command. The
   OS snapshot is captured once at process startup in Program.cs
   (CFLocale on macOS, $LANG/$LC_ALL on Linux, user UI culture on
   Windows) before the rest of the cli pins the thread-current culture
   to Invariant for deterministic OOXML/JSON/CSS output. Latin-script
   Western locales (en/fr/de/es/it/pt/nl/ru/pl) and empty/C/POSIX
   cultures resolve to null so no-arg `create` keeps the modern
   Calibri/LTR baseline (no Times New Roman regression for English
   users, neutral output in CI without locale config). Auto-detected
   locales emit a one-line stderr note ("locale 'ar-SA' inferred from
   OS user culture …") so the shaping is visible and overridable.

Schema fix in the same change: docdefaults.rtl=true used to write
<w:rtl/> inside rPrDefault/rPr, which the OOXML validator rejects
(CT_RPrDefault excludes <w:rtl/> from its content model even though
Word still renders the file). The setter now writes <w:bidi/> inside
pPrDefault/pPr instead — the canonical "this document's paragraphs are
RTL by default" location Word emits itself — and cleans up the legacy
rPrDefault/<w:rtl/> shape on re-toggle so older docs migrate on next
set. Get-readback follows: docDefaults.rtl is sourced from pPrDefault/
bidi, not rPrDefault/rtl. validate now passes cleanly on docs created
with --locale ar-SA.
Surfaced by running a fictional Arabic-agent workflow through officecli
end-to-end (create blank doc with LANG=ar_SA, build letter/invoice/
list, render with real Word, observe friction). Four fixes:

1. `add table --prop columns=N` was silently dropped, leaving a 1-column
   table with no UNSUPPORTED warning. `columns` is the natural English
   spelling and the first thing an agent reaches for; the canonical key
   is `cols`. Now accepts both. The deeper "Add never warns on unknown
   props" issue (foreach iteration marks every key as accessed in the
   tracker — see TrackingPropertyDictionary.cs:24-37) is a wider design
   gap and out of scope for this change.

2. settings.xml schema order: themeFontLang was being inserted at the
   very front of <w:settings>, before characterSpacingControl. The
   correct CT_Settings sequence has characterSpacingControl (~pos 63),
   compat (~pos 78), themeFontLang (~pos 80) — themeFontLang belongs
   AFTER compat. The validator surfaced the violation as "unexpected
   child characterSpacingControl" which was misleading; the offending
   element was actually themeFontLang. Since themeFontLang only gets
   written when --locale (or the OS-snapshot fallback) yields a value,
   every RTL-locale doc was failing validation while LTR docs passed.
   AI agents creating Arabic content would see `validate` fail on
   every fresh file and think their usage was wrong.

3. help schema for /numbering/abstractNum's `format` property listed
   chineseCounting / japaneseCounting / koreanCounting but omitted the
   script-localized formats users on the other side of the locale map
   actually need: arabicAlpha (أ ب ت ث), arabicAbjad (أ ب ج د),
   hebrew1, hebrew2, hindiNumbers/Vowels/Counting/Consonants,
   thaiCounting/Numbers/Letters. All are accepted by the handler;
   they just weren't discoverable through `help docx abstractNum`.

4. Tables added at /body in an RTL doc rendered with LTR column order
   unless the user remembered to pass --prop direction=rtl on every
   table. Paragraphs already inherit section bidi for free; tables use
   <w:bidiVisual/> on tblPr which Word does NOT propagate from sectPr.
   AddTable now consults a new IsTableContextRtl(parent) helper —
   walks to the owning section (or docDefaults/pPrDefault) — and
   auto-stamps BiDiVisual when no explicit direction prop was passed.
   Explicit direction=ltr still suppresses it, so the override path
   stays open.
…table

Two leftovers from the previous Arabic-agent UX pass:

1. Every blank docx (RTL or LTR) failed `officecli validate` with
   "[MarkupCompatibility] The Ignorable attribute is invalid - The
   value 'w14 w15 wp14' contains an invalid prefix that is not
   defined" — the root <w:document> element declared wp14 and w15
   namespaces but not w14, while listing all three under mc:Ignorable.
   Since the Add helpers stamp w14:paraId / w14:textId on every
   paragraph they create, the missing declaration was a real schema
   violation, not just a cosmetic one. Add the missing
   AddNamespaceDeclaration("w14", "…/office/word/2010/wordml").

2. `officecli add file.docx /body --type table --prop columns=4`
   silently produced a 1-column table — the canonical key is `cols`
   (now aliased to `columns` after the previous pass) but ANY bare
   typo (`column`, `numcols`, `colwidths=` misspelled as `colwidth`)
   still slipped through without warning. Root cause:
   WordHandler.Add.Table.cs iterates `properties` via
   `foreach (var (k,v) in properties)`, which routes through
   TrackingPropertyDictionary.GetEnumerator and marks every key as
   accessed (deliberate — see TrackingPropertyDictionary.cs:24-37,
   chart/media handlers need this for the
   `.Where(IsDeferredKey)` pattern). With every key reported as
   "accessed", tracking.UnusedKeys came back empty and the CLI emitted
   no UNSUPPORTED warning. Fix layered in two pieces: the switch over
   bare keys grows a default case that pushes truly unknown bare keys
   (not r{N}c{M} cell content, not dotted-key fallback territory) to
   LastAddUnsupportedProps; and CommandBuilder.Add merges that list
   into the unsupported set that the CLI surfaces — mirroring what the
   resident-server path already does. JSON envelope picks up the same
   "code: unsupported_property" warning shape.
…concept name

Set on a run/ptab/instrText/etc. with `align=center` previously surfaced
the literal user key plus the generic valid-run-props list, e.g.
`align (valid run props: text, bold, italic, ...)`. The hint pointed to
typography props but never named the actual concept the user was trying
to reach — alignment, which is a paragraph- or ptab-only property in
OOXML, not a run prop.

When the key matches align/halign/alignment, emit
`align (alignment is a paragraph/ptab property, not a run prop)` so the
unsupported notice points at the right path level. Ptab runs still take
the dedicated `case "align" when ptabEl != null` branch above and are
unaffected.
The find prop skip-list excluded 'scope' from format/paragraph
bucketing, but no code path ever read it — silently accepted and
silently ignored. Drop it from the literal so it routes through
the normal property dispatch and becomes visible as unsupported.
Start of a selector-vocabulary migration: text/value selectors are
moving out of --prop into their own top-level flags so --prop can
go back to meaning 'property to mutate'. First flag in this family
is --find, covering the most common selector pattern.

query: --text is renamed to --find (hard rename, no alias — the
old flag had no users). Resident-server arg key 'text' → 'find'
in lockstep. Semantics unchanged: case-insensitive substring filter.

set: --find is added as a top-level flag that merges into the props
array as 'find=value' before downstream dispatch, so handlers, the
resident server, and batch all keep working with zero changes. The
legacy --prop find=VALUE form still works but emits a stderr hint
steering users to --find. Combining both --find and --prop find=
is rejected as ambiguous.

Local-only repro scripts and test comments were updated to the new
flag name; those files are gitignored and not part of the commit.
Completes the find/replace selector-vocabulary pair started by
--find. --replace VALUE merges into the props array as
'replace=value' before handler dispatch, mirroring --find's
sugar-only approach so handlers stay untouched.

Empty string is preserved as a meaningful value (Word delete-only:
'--replace ""' wraps the match in w:del without an insertion),
so the merge guard uses != null rather than IsNullOrEmpty.

The legacy --prop replace=VALUE form still works but emits the
same deprecation hint as --prop find=. When both legacy keys are
present, the hint coalesces into one line.
3.1.3 has a moderate-severity advisory: a crafted CFB file with cyclic
Left/Right sibling links in its directory entries drives
DirectoryTree.TryGetDirectoryEntry into an infinite loop, blocking the
calling thread until the process is killed.

OleHelper.UnwrapOle10NativeIfCfb feeds untrusted bytes from `add ole
src=...` and from foreign docx/pptx OLE parts into RootStorage.Open, so
the attack surface is reachable in normal officecli usage.

3.1.4 is API-compatible; OLE test sweep (1114 tests, 0 failures) and
release build (0 warnings) pass locally. NU1902 warning from the CI
publish step is silenced as a side effect.
…ts in outline

query: split `listobject` (real ListObjects only) from `table` (real plus
heuristically detected blocks). Detection is strict and high-precision —
a sparse-cell anchor pass requiring a contiguous >=2-column non-numeric-text
header, >=1 data row, and no overlap with a real ListObject. Detected blocks
carry type=detectedtable, stable=false, and an honest range path
(/Sheet1/A1:D10) — never a fabricated /table[N]. Cost is O(non-empty cells),
independent of the declared sheet dimension, and every qualifying block on
every sheet is reported.

view outline: add per-sheet table and chart counts alongside the existing
formula/error/pivot/ole counts (text and JSON variants), so a single outline
call reveals a workbook's tabular and chart structure across all sheets.
Convert `--prop find=`/`--prop replace=` to the new top-level `--find`/
`--replace` flags in the Word revisions example. `--prop regex=true` stays
(regex has not migrated). Empty replace uses `--replace ""` to preserve the
delete-only tracked-change semantics.
Add agents-project with scripts and documentation to help import agents.zip into an agents/ directory. Includes support for both shell scripts and npm commands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants