diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json
index fe5d52a6..b0277a2a 100644
--- a/config/external-sources.lock.json
+++ b/config/external-sources.lock.json
@@ -114,8 +114,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"adapter": "codex-plugin",
- "commit": "b4531dfc05a3ef0ba93b57e8bfa5f48564a4b4d6",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9",
+ "syncedAt": "2026-10-24T16:00:00Z"
},
{
"id": "taste-skill",
@@ -132,8 +132,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
- "commit": "3d98900e9b73c45f035f8109e9d41ba6a51685e7",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb",
+ "syncedAt": "2026-10-24T16:00:00Z"
},
{
"id": "frontend-slides",
@@ -168,8 +168,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
- "commit": "523e5bd972f94c3cac8742fec0d54d43824a17bb",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "commit": "78759bf62e9a1ef43e926ff9e50afa4cfe9a63cb",
+ "syncedAt": "2026-10-24T16:00:00Z"
},
{
"id": "grill-me",
@@ -258,8 +258,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
- "commit": "3dd7baa502a13a96836680fa4d84cb5020ae94fc",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991",
+ "syncedAt": "2026-10-24T16:00:00Z"
},
{
"id": "shuorenhua",
diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json
index efdfc3e1..7c77a66b 100644
--- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
- "commit": "b4531dfc05a3ef0ba93b57e8bfa5f48564a4b4d6",
+ "commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9",
"adapter": "codex-plugin",
"sourcePath": "plugins/caveman",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "syncedAt": "2026-10-24T16:00:00Z"
}
diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
index 8a73d0c2..93ba465e 100644
--- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
+++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
- "syncedAt": "2026-10-23T16:02:55Z"
+ "syncedAt": "2026-10-24T16:02:42Z"
}
diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
index 78b03986..eeed608e 100644
--- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
- "commit": "3dd7baa502a13a96836680fa4d84cb5020ae94fc",
+ "commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991",
"adapter": "skill-collection",
"sourcePath": "skills",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "syncedAt": "2026-10-24T16:00:00Z"
}
diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md
index 18c4d972..f6e5b495 100644
--- a/plugins/codex/plugins/ppt-master/README.md
+++ b/plugins/codex/plugins/ppt-master/README.md
@@ -13,7 +13,9 @@
English | [中文](./README_CN.md)
-> 🆕 **As a finance professional (CPA · CPV · Consulting Engineer (Investment)), I drew on my expertise and project experience to build a tool website: [Construction Project Economic Evaluation](https://cs.hehugo.com/)** (in Chinese) — enter your project data and get a full financial evaluation online: investment, revenue, cost, debt service, profit, cash flow and balance sheet statements, plus IRR, NPV, sensitivity and break-even analysis, exportable to Excel / Word. Built for energy projects such as wind, solar and storage, with a general-purpose mode for other projects. The site is in an early trial phase; the first 100 accounts get 3 free runs, later accounts get 1.
+> 💼 **I'm open to new opportunities.** Investment background (CPA · CPV · Consulting Engineer), also building AI tools and open source. Happy to talk about any opportunity: [heyug3@gmail.com](mailto:heyug3@gmail.com) · [hehugo.com](https://www.hehugo.com/).
+
+> 🆕 **I built a construction project economic evaluation site: [cs.hehugo.com](https://cs.hehugo.com/)** (in Chinese) — the wind, solar and storage templates are now open to everyone; just register and use them (a comparable module in one commercial package sells for ¥100,000).
## ❤️ Sponsors
@@ -424,8 +426,6 @@ If PPT Master has been helpful to you, individual support of any amount helps ke
-> 🎁 Sponsor ¥100 or more (in multiples of ¥100) via Alipay and put the email you registered on [Construction Project Economic Evaluation](https://cs.hehugo.com/) in the payment note to receive compute credits on that site. Every ¥100 earns 20 credits (one run costs 1 credit).
-
---
Made with ❤️ by [Hugo He](https://www.hehugo.com/) — if this project helps you, please give it a ⭐ and consider [sponsoring](#sponsors--support).
diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
index 72ea50e0..46eebf61 100644
--- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
- "commit": "523e5bd972f94c3cac8742fec0d54d43824a17bb",
+ "commit": "78759bf62e9a1ef43e926ff9e50afa4cfe9a63cb",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "syncedAt": "2026-10-24T16:00:00Z"
}
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py
index 4411b7fd..22ad4773 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/authoring_roundtrip.py
@@ -63,6 +63,8 @@ from svg_authoring_view import (
)
from svg_compatibility import normalize_single_child_group_filters
from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS, parse_transform_matrix
+from svg_to_pptx.native_objects.chart_patch import chart_edit_is_data_only, patch_chart_package
+from svg_to_pptx.native_objects.marker_common import _load_payload
SVG_NS = "http://www.w3.org/2000/svg"
@@ -1711,6 +1713,61 @@ def _native_restore_placeholder(
return placeholder
+def prepare_imported_chart_edit(
+ current: ET.Element,
+ baseline: ET.Element,
+ source: ET.Element,
+ *,
+ current_root: ET.Element | None = None,
+ baseline_root: ET.Element | None = None,
+) -> tuple[dict | None, str | None]:
+ """Reuse import-time JSON and source parts for a data-only chart change."""
+ if any(element.get("data-pptx-replace-with") != "chart"
+ or element.get("data-pptx-native-authority") != "json"
+ for element in (current, baseline)):
+ return None, None
+ edited = _load_payload(current, "chart")
+ original = _load_payload(baseline, "chart")
+ unchanged = edited == original
+ if not unchanged and not chart_edit_is_data_only(original, edited):
+ return None, "series/point topology or non-data JSON fields changed"
+ comparison = copy.deepcopy(_contextual_authoring_element(current, current_root))
+ reference = _contextual_authoring_element(baseline, baseline_root)
+ comparison.find(f'{{{SVG_NS}}}metadata[@type="application/json"]').text = (
+ reference.find(f'{{{SVG_NS}}}metadata[@type="application/json"]').text
+ )
+ ignored = frozenset({SOURCE_REF_ATTRIBUTE, "data-pptx-fallback-sha256"})
+ if semantic_subtree_sha256(comparison, ignored_attributes=ignored) != semantic_subtree_sha256(
+ reference, ignored_attributes=ignored,
+ ):
+ return None, "chart SVG geometry or presentation changed alongside its data"
+ package = _load_payload(source, "chart").get("source_package")
+ if unchanged:
+ return package, None
+ if package is None:
+ raise AuthoringRoundtripError("Data-only chart edit has no closed source package; reimport the source deck")
+ try:
+ return patch_chart_package(package, original, edited), None
+ except RuntimeError as exc:
+ raise AuthoringRoundtripError(f"Cannot preserve data-only chart edit: {exc}") from exc
+
+
+def chart_rebuild_warning(project_path: Path, source_slide: int, source_ref: str, reason: str) -> str:
+ """Report the object's recorded import losses when a chart must rebuild."""
+ report_path = project_path / "validation" / "conversion-report.json"
+ diagnostics = []
+ if report_path.is_file():
+ report = _load_json(report_path, context="import conversion report")
+ diagnostics = [item for item in report.get("diagnostics", [])
+ if item.get("slide_index") == source_slide
+ and str(item.get("shape_id")) == source_ref.split(":", 1)[-1]]
+ details = "; ".join(dict.fromkeys(
+ f"{item.get('code')}: {item.get('message')}" for item in diagnostics
+ )) or "no object-specific import diagnostics were recorded"
+ return (f"native-chart-rebuilt: {reason}; JSON rebuild may omit source-only formatting. "
+ f"Import diagnostics: {details}")
+
+
def _materialize_document(
project_path: Path,
document: AuthoringDocument,
@@ -1963,6 +2020,28 @@ def _materialize_document(
if clone.get(name) is None and source.get(name) is not None:
clone.set(name, str(source.get(name)))
_restore_preserved_effect_metadata(clone, source)
+ package, reason = prepare_imported_chart_edit(
+ element, baseline_occurrences[source_ref][0].element, source,
+ current_root=current_occurrences[source_ref][0].root,
+ baseline_root=baseline_occurrences[source_ref][0].root,
+ )
+ if package is not None:
+ metadata = clone.find(f'{{{SVG_NS}}}metadata[@type="application/json"]')
+ payload = _load_payload(clone, "chart")
+ payload["source_package"] = package
+ metadata.text = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+ clone.set("data-pptx-roundtrip-object", "source-chart-package")
+ if reason:
+ message = chart_rebuild_warning(project_path, document.source_slide, source_ref, reason)
+ object_id = element.get("id") or source_ref
+ output_index = page.output_index if page else document.source_slide
+ warnings.append({
+ "code": "native-chart-rebuilt", "output_page": output_index,
+ "source_slide": document.source_slide, "object_id": object_id,
+ "source_ref": source_ref, "message": message,
+ })
+ print(f"[ROUNDTRIP][WARNING] page={output_index} object={object_id} "
+ f"source_ref={source_ref}: {message}", file=sys.stderr)
warning = _restore_unchanged_text_body(
clone, source, current_occurrences[source_ref][0], baseline_occurrences[source_ref][0],
)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/native-data.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/native-data.md
index e4016f83..bd1457e2 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/native-data.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/native-data.md
@@ -25,12 +25,14 @@ Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (header cells expo
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. SVG-first parity reads a header band drawn as one rect spanning the header rows as `style.header_fill` unless every header cell carries that same `fill` (and the dominant header text colour as `style.header_text` unless every header cell carries it), so a drawn band is projected at table level rather than repeated per cell. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
-**Hard rule — the table payload is complete**: a payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules — every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Merged cells — canonical rectangular contract only**: positive integer `row_span` / `col_span` on the anchor, every covered cell blank (canonical `{"merge_continuation": true}`), spans inside the grid and non-overlapping; the exporter writes `rowSpan` / `gridSpan` / `hMerge` / `vMerge`. Covered-cell blankness is checked before expanding defaults: accepted authored values are `null`, `""`, and objects containing only optional `text` (absent, `null`, or `""`) and `merge_continuation` (absent or exactly `true`), including `{}`. Authored formatting fields make a covered cell nonblank; inherited defaults do not, so `{"text": ""}` stays blank with `defaults.cell.color`. CamelCase aliases, raw OOXML merge fields, top-level merge lists, nonblank covered cells, invalid spans, and overlaps fail.
+**Hard rule — the table payload is complete**: a payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules — every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Merged cells — canonical rectangular contract only**: positive integer `row_span` / `col_span` on the anchor, every covered cell blank (canonical `{"merge_continuation": true}`), spans inside the grid and non-overlapping; the exporter writes `rowSpan` / `gridSpan` / `hMerge` / `vMerge`. Covered-cell blankness is checked before expanding defaults: accepted authored values are `null`, `""`, and objects with optional `text` (absent, `null`, or `""`) and `merge_continuation` (absent or exactly `true`), including `{}`. Covered cells also accept the cell formatting fields and `cell_style` defined above; resolved fills, opacity, borders, padding, and cell alignment attributes are written to the covered `a:tcPr`, preserving its `a:tc` merge flags. Formatting and inherited defaults do not make a covered cell nonblank; non-empty `text` and any `paragraphs` field are rejected. CamelCase aliases, raw OOXML merge fields, top-level merge lists, nonblank covered cells, invalid spans, and overlaps fail.
## §2 Chart schemas
Chart payloads carry no `schema` key: the marker's `data-pptx-replace-with` value selects the chart schema, and an invented key is rejected.
+Classic charts accept root `lang`, a non-empty language tag validated by the shared language-tag helper. It preserves imported `c:lang` and takes precedence over the deck language for chart text; omission uses the deck language, then `en-US`. ChartEx rejects this field. Import projects supported plot spacing, inner manual layout, series paint/markers, axis settings and typography, and chart chrome into the same fields used for authored charts. Chart colors use RGB values: imported theme colors and their transforms are resolved against the source palette, rather than retained as theme-slot references.
+
- **Category charts** — `column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Column, bar, line, and area values may be `null` for a gap (the workbook cell stays blank, the cache omits the point, `dispBlanksAs` is `gap`; at least one value per series is a number) — a fallback that breaks one line into segments is two series of the same colour with `null` outside their segment. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values; every entry a colour — a `null` gap is line-only). Bar categories run top-down in payload order — the exporter reverses the category axis and moves the value-axis crossing so the value axis keeps its declared position; `axes.category.reverse: false` restores PowerPoint's bottom-up order (column charts stay left-to-right unless `reverse: true`). Bar/column spacing: root `gap_width` (`0..500`, the empty slot share as a percentage of one bar; PowerPoint default `150`; one category slot is the plot height (or width) divided by the category count and `bar = slot / (1 + gap_width / 100)`, so a chosen bar size converts back to `gap_width = (slot / bar - 1) × 100`) and clustered `overlap` (`-100..100`, the share of one bar the next series covers, negative for a gap); SVG-first export reads both from the fallback bars inside `plot_area` when the payload omits them, JSON-first uses the defaults.
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `line_style` itself is a chart-root option — `series[].line_style` is rejected; inside a `combo` it sits on the plot or on a typed series. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides — with an explicit `show_*` flag every point keeps its label and `delete: true` hides one; without one only the listed points are labeled); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none; `series[].data_labels` takes the same object for one series, so two lines can label different points. Set `data_labels.number_format` to match the fallback (the parity gate reads `0`, `0.0`, `0.00`, `#,##0`, `#,##0.0`, their `%` forms — which scale the value by 100 — and a quoted literal prefix/suffix such as `"$"#,##0` or `0.0"%"`; other codes match only the literal value)'s rendered value labels. Rendered forms follow the en-US pattern (`.` decimal, `,` thousands); a deck written with locale separators (`1.234,5`) keeps such numbers out of the chart marker — integer ticks, no `data_labels`, the formatted figures in surrounding text or table cells.
- **Combo** — shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible); column plots take `gap_width` / `overlap`, line plots `line_style` / `marker_size`. An area series is a fill without an outline; `line_width` adds a same-colour outline around the whole region (baseline included), so a fallback that strokes only the top edge — or draws markers on it — is an area plot under a line plot with the same values. Export layers areas below columns and lines.
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
index 7e9ab706..c8775f0e 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/svg-pipeline.md
@@ -255,9 +255,11 @@ as the round-trip text-capacity gate. It resolves the output roster from
font-family, font-size, text-width, and canvas metrics only to new text or
changed source-ref objects. The gate asserts horizontal capacity: it estimates
single-line width for each positioned line and does not model vertical
-wrapping. Width beyond an explicit ancestor `data-pptx-frame` is blocking;
-overflow against the nearest-rect-sibling fallback is advisory, while bounds
-leaving the page canvas remain blocking. Other advisories remain non-blocking.
+wrapping. Width beyond the nearest owning `data-pptx-frame` (imported) or
+`data-pptx-bounds` (authored) is blocking. Without explicit bounds, only a rect
+in the text's own group containing its anchor can supply an advisory frame;
+page-level decorations and rectangles beside ancestor groups are excluded.
+Bounds leaving the page canvas remain blocking. Other advisories remain non-blocking.
Unchanged source refs, source proxies, and generated-project-only spec,
template, canonical-authoring, and resource-manifest checks are excluded.
@@ -971,7 +973,7 @@ Behavior:
- `--no-merge`: each dy-stacked line becomes an independent frame with its own placement.
- Detection is conservative: mixed-layout `` falls back to per-line frames. Use `--reflow-text` only for resizable body copy and `--no-merge` only for independent line objects or absolute line positions.
- Native release export reads `svg_output/`; `-s ` selects another project-relative SVG source. `-s final` remains an explicit diagnostic comparison against post-processed SVGs and does not change artifact ownership. `--enable-dangerous-nonconforming-svg-export` is a separate, explicitly requested flat compatibility path for either the default or selected source; it forces flat structure, restores no imported source object, and cannot combine with `--roundtrip` or `--quick-generate`.
-- `--roundtrip` accepts only `authoring-svg-flat/` and the source/contracts emitted by `pptx_to_svg.py --roundtrip`; predecessor root sidecars and alternate `-s` inputs fail. It restores unchanged refs from `analysis/roundtrip-svg/`, preserves unchanged Slide XML/relationships and source resources byte-for-byte, rebuilds a page whose authoring changed, and rebuilds every output page that references a changed resource. Closed unchanged chart packages recover exactly; editing their fallback disables stale replacement. Optional root `page_plan.json` uses the versioned deck-plan contract above; the no-plan path remains the identity export. Motion fields are compared independently with the import baseline: omitted slide `transition` preserves source motion even when importer defaults say `none`, while changed `defaults.animation` participates in animation replacement. Effect-only changes preserve source `advClick` / `advTm`; explicit advance settings or narration timings replace them. Explicit `-t ` without `--transition-duration` on a source without transitions uses the default duration.
+- `--roundtrip` accepts only `authoring-svg-flat/` and the source/contracts emitted by `pptx_to_svg.py --roundtrip`; predecessor root sidecars and alternate `-s` inputs fail. It restores unchanged refs from `analysis/roundtrip-svg/`, preserves unchanged Slide XML/relationships and source resources byte-for-byte, rebuilds a page whose authoring changed, and rebuilds every output page that references a changed resource. Closed unchanged chart packages recover exactly; editing their fallback disables stale replacement. With `--native-charts-and-tables`, an imported JSON-first chart whose edit changes only numeric data (supported null gaps included), category labels, or series names — types, axis assignments, series/point counts, every non-data JSON field, and its SVG presentation unchanged against the `analysis/roundtrip-svg/` baseline — patches a copy of its source package: only the affected cache entries (`xVal` / `yVal` / `bubbleSize` for XY) and matching workbook cells change (new shared strings when an entry is shared), while `formatCode`, ranges, every other chart byte, and the style/colors/theme-override/workbook relationships stay as imported under independent part names. A data-only edit that cannot be patched safely (workbook reference not a single A1 row or column, formula cell, missing source package) fails with a repair message. Any other chart edit rebuilds from JSON and emits a non-blocking `native-chart-rebuilt` warning listing that object's import diagnostics from `validation/conversion-report.json`; source-only gradients, layouts, text overrides, and extensions can be lost on that path. Optional root `page_plan.json` uses the versioned deck-plan contract above; the no-plan path remains the identity export. Motion fields are compared independently with the import baseline: omitted slide `transition` preserves source motion even when importer defaults say `none`, while changed `defaults.animation` participates in animation replacement. Effect-only changes preserve source `advClick` / `advTm`; explicit advance settings or narration timings replace them. Explicit `-t ` without `--transition-duration` on a source without transitions uses the default duration.
- `svg_final/` may be opened directly or inserted into PowerPoint as an SVG picture. PowerPoint's manual Convert-to-Shape operation is outside the compatibility contract.
- On every SVG-authoring route, each file in `svg_output/` is the complete visible
page-design source. Templates and locks may guide authoring, but finalize/export
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
index a79f31cb..f5f72634 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/pptx_to_svg/chart_to_svg.py
@@ -425,7 +425,7 @@ def _combo_payload(
"series_indices": series_indices,
"type": chart_type,
}
- for key in ("data_labels", "grouping", "line_style"):
+ for key in ("data_labels", "gap_width", "grouping", "line_style", "overlap"):
if plot_payload.get(key) is not None:
plot_entry[key] = plot_payload[key]
plots.append(plot_entry)
@@ -885,8 +885,9 @@ def _series_visual_style(
else:
line_default = None
stroke = _line_color(shape.line, line_default)
- marker_fill = marker.shape.fill if marker.shape.fill_explicit else auto_color
- marker_stroke = _line_color(marker.shape.line, auto_color)
+ marker_default = (stroke or auto_color) if chart_type in {"line", "scatter"} else auto_color
+ marker_fill = marker.shape.fill if marker.shape.fill_explicit else marker_default
+ marker_stroke = _line_color(marker.shape.line, marker_default)
line = shape.line
marker_line = marker.shape.line
return SeriesVisualStyle(
@@ -976,11 +977,14 @@ def _chart_visual_styles(
raise _UnsupportedChart("unsupported-chart-series-style")
styles = _pie_visual_styles(payload, series_nodes[0], palette)
else:
- if any(series.find("c:dPt", C_NS) is not None for series in series_nodes):
+ if chart_type not in {"bar", "column", "line"} and any(
+ series.find("c:dPt", C_NS) is not None for series in series_nodes
+ ):
raise _UnsupportedChart("unsupported-chart-series-style")
styles = []
for index, series in enumerate(series_nodes):
- auto = _automatic_color(palette, index)
+ source_index = _element_val(series.find("c:idx", C_NS))
+ auto = _automatic_color(palette, int(source_index) if source_index and source_index.isdigit() else index)
shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette)
marker = _resolved_marker(series.find("c:marker", C_NS), palette)
styles.append(
@@ -992,7 +996,8 @@ def _chart_visual_styles(
)
)
colors = [
- style.fill or style.stroke or style.marker_fill or _automatic_color(palette, index)
+ (style.stroke if chart_type == "line" else style.fill)
+ or style.fill or style.stroke or style.marker_fill or _automatic_color(palette, index)
for index, style in enumerate(styles)
]
if colors:
@@ -1192,11 +1197,14 @@ def _normalized_chart_visual_styles(
palette,
),
chart_type=chart_type,
- auto_color=_automatic_color(palette, index),
+ auto_color=_automatic_color(
+ palette, int(_element_val(series.find("c:idx", C_NS)) or index),
+ ),
)
)
colors = [
- style.fill
+ (style.stroke if chart_type == "line" else style.fill)
+ or style.fill
or style.stroke
or style.marker_fill
or _automatic_color(palette, index)
@@ -1454,7 +1462,7 @@ def _axis_config_from_xml(
_strict_axis_bool(auto, True)
major_tick = _element_val(axis.find("c:majorTickMark", C_NS))
minor_tick = _element_val(axis.find("c:minorTickMark", C_NS))
- if major_tick not in {None, "none", "out"} or minor_tick not in {None, "none"}:
+ if major_tick not in {None, "none", "out", "in", "cross"} or minor_tick not in {None, "none"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
label_alignment = _element_val(axis.find("c:lblAlgn", C_NS))
label_offset = _element_val(axis.find("c:lblOffset", C_NS))
@@ -1929,20 +1937,24 @@ def _validate_chart_semantics(
raise _UnsupportedChart("unsupported-chart-line-style")
if chart_type in {"bar", "column"}:
- _bounded_plot_integer(
+ gap_width = _bounded_plot_integer(
plot,
"gapWidth",
minimum=0,
maximum=500,
status="unsupported-chart-bar-options",
)
- _bounded_plot_integer(
+ overlap = _bounded_plot_integer(
plot,
"overlap",
minimum=-100,
maximum=100,
status="unsupported-chart-bar-options",
)
+ if gap_width is not None:
+ payload["gap_width"] = gap_width
+ if overlap is not None:
+ payload["overlap"] = overlap
if chart_type == "bubble":
bubble_scale = _element_val(plot.find("c:bubbleScale", C_NS))
@@ -1995,9 +2007,86 @@ def _validate_chart_semantics(
second_size = _element_val(plot.find("c:secondPieSize", C_NS))
if second_size not in {None, "75"}:
raise _UnsupportedChart("unsupported-chart-of-pie-options")
+ _apply_series_formatting(payload, plot, visual_styles, palette)
return visual_styles
+def _apply_series_formatting(
+ payload: dict[str, Any],
+ plot: ET.Element,
+ visual_styles: list[SeriesVisualStyle],
+ palette: ColorPalette | None,
+) -> None:
+ """Project the series paint and marker options the classic writer supports."""
+ chart_type = payload["type"]
+ if chart_type not in {"area", "bar", "column", "line"}:
+ return
+ for series, item, visual in zip(plot.findall("c:ser", C_NS), payload["series"], visual_styles):
+ if chart_type == "area":
+ item["fill_opacity"] = visual.fill_opacity
+ if chart_type in {"area", "line"} and visual.stroke and visual.stroke_width > 0:
+ item["line_width"] = visual.stroke_width
+ if chart_type == "line" and series.find("c:marker/c:size", C_NS) is not None:
+ item["marker_size"] = _round_payload_number(visual.marker_size / 0.75)
+
+ base_color = visual.fill
+ if chart_type == "line":
+ base_color = visual.marker_fill or visual.stroke
+ if payload.get("line_style") == "lineMarker" and base_color != visual.stroke:
+ item["point_colors"] = [base_color] * len(item["values"])
+ points = series.findall("c:dPt", C_NS)
+ if not points or chart_type == "area":
+ continue
+ colors = [base_color] * len(item["values"])
+ sizes = [visual.marker_size] * len(colors)
+ if chart_type == "line" and payload.get("line_style") != "lineMarker":
+ colors = [None] * len(colors)
+ sizes = [None] * len(colors)
+ seen: set[int] = set()
+ for point in points:
+ raw_index = _element_val(point.find("c:idx", C_NS))
+ if raw_index is None or not raw_index.isdigit():
+ raise _UnsupportedChart("unsupported-chart-series-style")
+ index = int(raw_index)
+ if index >= len(colors) or index in seen:
+ raise _UnsupportedChart("unsupported-chart-series-style")
+ seen.add(index)
+ if chart_type == "line":
+ marker_node = point.find("c:marker", C_NS)
+ marker = (
+ _normalized_marker_paint(marker_node, palette)
+ if palette is not None and not palette.strict
+ else _resolved_marker(marker_node, palette)
+ )
+ if marker.symbol == "none":
+ colors[index] = None
+ sizes[index] = None
+ elif marker.symbol == "circle" or (
+ marker.symbol is None and payload.get("line_style") == "lineMarker"
+ ):
+ colors[index] = marker.shape.fill or base_color
+ if marker_node is not None and marker_node.find("c:size", C_NS) is not None:
+ sizes[index] = marker.size
+ else:
+ shape = point.find("c:spPr", C_NS)
+ paint = (
+ _normalized_shape_paint(shape, palette)
+ if palette is not None and not palette.strict
+ else _resolved_shape_paint(shape, palette)
+ )
+ colors[index] = paint.fill or base_color
+ vary_colors = ooxml_bool(_element_val(plot.find("c:varyColors", C_NS)), False)
+ series_color = visual.stroke if chart_type == "line" else visual.fill
+ if (chart_type == "line" or all(colors)) and (vary_colors or any(color != series_color for color in colors)):
+ item["point_colors"] = colors
+ else:
+ item.pop("point_colors", None)
+ if chart_type == "line":
+ visible_sizes = {size for size in sizes if size is not None}
+ if len(visible_sizes) == 1:
+ item["marker_size"] = _round_payload_number(visible_sizes.pop() / 0.75)
+
+
def _apply_plot_data_labels(
payload: dict[str, Any],
plot: ET.Element,
@@ -2091,6 +2180,11 @@ def _apply_chart_metadata(
if chart is None:
return
+ language = _element_val(chart_root.find("c:lang", C_NS))
+ if language:
+ payload["lang"] = language
+ _apply_chart_formatting(payload, chart_root, plot_area, palette)
+
title_element = chart.find("c:title", C_NS)
title_entries = _canonical_title_entries(title_element)
title = _chart_text(title_element)
@@ -2100,7 +2194,7 @@ def _apply_chart_metadata(
if len(title_entries) == 2:
payload["subtitle"] = title_entries[1]
else:
- payload["title"] = title
+ payload["title"] = _styled_chart_title(title_element, palette)
legend = chart.find("c:legend", C_NS)
if legend is not None:
@@ -2111,30 +2205,42 @@ def _apply_chart_metadata(
raise _UnsupportedChart("unsupported-chart-legend-position")
payload["show_legend"] = True
payload["legend_position"] = position
+ legend_style = _source_chart_text_style(legend.find("c:txPr", C_NS), palette)
+ if "font_size" in legend_style:
+ payload["legend_font_size"] = legend_style["font_size"]
+ if palette is not None and any(
+ "font_size" in _source_chart_text_style(entry.find("c:txPr", C_NS), palette)
+ for entry in legend.findall("c:legendEntry", C_NS)
+ ):
+ palette._diagnose(
+ "chart-legend-entry-font-size-omitted",
+ "Legend entry font-size overrides are outside the native chart JSON contract",
+ "keep source entry typography on unchanged or data-only round-trip edits",
+ )
if include_plot_labels:
_apply_plot_data_labels(payload, plot, palette=palette)
- axis_titles: dict[str, str] = {}
+ axis_titles: dict[str, Any] = {}
category_axis_nodes = (
plot_area.findall("c:catAx", C_NS)
+ plot_area.findall("c:dateAx", C_NS)
)
category_titles = [
- text
+ axis.find("c:title", C_NS)
for axis in category_axis_nodes
- if (text := _chart_text(axis.find("c:title", C_NS)))
+ if _chart_text(axis.find("c:title", C_NS))
]
value_titles = [
- text
+ axis.find("c:title", C_NS)
for axis in plot_area.findall("c:valAx", C_NS)
- if (text := _chart_text(axis.find("c:title", C_NS)))
+ if _chart_text(axis.find("c:title", C_NS))
]
if payload["type"] == "combo":
- if len(set(category_titles)) > 1:
+ if len({_chart_text(title) for title in category_titles}) > 1:
raise _UnsupportedChart("unsupported-chart-axis-titles")
if category_titles:
- axis_titles["category"] = category_titles[0]
+ axis_titles["category"] = _styled_chart_title(category_titles[0], palette)
for axis in plot_area.findall("c:valAx", C_NS):
text = _chart_text(axis.find("c:title", C_NS))
if not text:
@@ -2143,35 +2249,152 @@ def _apply_chart_metadata(
key = "secondary_value" if position == "r" else "value"
if key in axis_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
- axis_titles[key] = text
+ axis_titles[key] = _styled_chart_title(axis.find("c:title", C_NS), palette)
elif payload["type"] in {"scatter", "bubble"}:
if category_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
titled_value_axes = [
(
_element_val(axis.find("c:axPos", C_NS)),
- _chart_text(axis.find("c:title", C_NS)),
+ axis.find("c:title", C_NS),
)
for axis in plot_area.findall("c:valAx", C_NS)
]
- for position, text in titled_value_axes:
- if not text:
+ for position, title in titled_value_axes:
+ if not _chart_text(title):
continue
key = "x" if position in {"b", "t"} else "y"
if key in axis_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
- axis_titles[key] = text
+ axis_titles[key] = _styled_chart_title(title, palette)
else:
if len(category_titles) > 1 or len(value_titles) > 1:
raise _UnsupportedChart("unsupported-chart-axis-titles")
if category_titles:
- axis_titles["category"] = category_titles[0]
+ axis_titles["category"] = _styled_chart_title(category_titles[0], palette)
if value_titles:
- axis_titles["value"] = value_titles[0]
+ axis_titles["value"] = _styled_chart_title(value_titles[0], palette)
if axis_titles:
payload["axis_titles"] = axis_titles
+def _styled_chart_title(title: ET.Element, palette: ColorPalette | None) -> str | dict[str, Any]:
+ """Keep uniform title typography through the existing text-entry contract."""
+ style = _source_chart_text_style(title.find("c:txPr", C_NS), palette)
+ style.update(_source_chart_text_style(title.find("c:tx/c:rich", C_NS), palette))
+ style.pop("bold", None)
+ text = _chart_text(title)
+ return {"text": text, **style} if style else text
+
+
+def _chart_fill_color(owner: ET.Element | None, palette: ColorPalette | None) -> str | None:
+ """Resolve a schema-supported flat fill, including theme color transforms."""
+ if owner is None:
+ return None
+ if owner.find("a:noFill", C_NS) is not None:
+ return "none"
+ color_node = find_color_elem(owner.find("a:solidFill", C_NS))
+ if color_node is None:
+ return None
+ color, alpha = resolve_color(color_node, palette)
+ return color if alpha > 0 else "none"
+
+
+def _apply_chart_formatting(
+ payload: dict[str, Any],
+ chart_root: ET.Element,
+ plot_area: ET.Element,
+ palette: ColorPalette | None,
+) -> None:
+ """Project only formatting with a writer in native-data.md's chart schema."""
+ style = payload.setdefault("style", {})
+ base_text = _source_chart_text_style(chart_root.find("c:txPr", C_NS), palette)
+ for source, target in (("font_family", "font_family"), ("font_size", "font_size"),
+ ("color", "text_color")):
+ if source in base_text:
+ style[target] = base_text[source]
+ for owner, key in ((chart_root, "chart_area_fill"), (plot_area, "plot_area_fill")):
+ color = _chart_fill_color(owner.find("c:spPr", C_NS), palette)
+ if color is not None:
+ style[key] = color
+
+ layout = plot_area.find("c:layout/c:manualLayout", C_NS)
+ if layout is not None:
+ modes = {name: _element_val(layout.find(f"c:{name}", C_NS)) for name in (
+ "layoutTarget", "xMode", "yMode", "wMode", "hMode",
+ )}
+ factors = [_axis_number(layout.find(f"c:{name}", C_NS)) for name in ("x", "y", "w", "h")]
+ if (
+ modes["layoutTarget"] == "inner"
+ and modes["xMode"] == modes["yMode"] == "edge"
+ and modes["wMode"] in {None, "factor"} and modes["hMode"] in {None, "factor"}
+ and all(value is not None for value in factors)
+ and factors[0] >= 0 and factors[1] >= 0 and factors[2] > 0 and factors[3] > 0
+ and factors[0] + factors[2] <= 1 and factors[1] + factors[3] <= 1
+ ):
+ x, y, width, height = factors
+ payload["plot_area"] = {
+ "x": payload["x"] + x * payload["width"],
+ "y": payload["y"] + y * payload["height"],
+ "width": width * payload["width"], "height": height * payload["height"],
+ }
+ elif palette is not None:
+ palette._diagnose(
+ "chart-plot-layout-normalized",
+ "Chart manual layout is outside the absolute inner plot-area contract",
+ "keep chart data with automatic plot layout",
+ )
+
+ secondary_ids: set[str] = set()
+ if payload["type"] == "combo":
+ plots = [node for node in plot_area if _local_name(node.tag).endswith("Chart")]
+ for plot, config in zip(plots, payload["plots"]):
+ if config["axis"] == "secondary":
+ secondary_ids.update(node.get("val") for node in plot.findall("c:axId", C_NS))
+ for axis in plot_area:
+ tag = _local_name(axis.tag)
+ if tag not in {"catAx", "dateAx", "valAx"}:
+ continue
+ position = _element_val(axis.find("c:axPos", C_NS))
+ if payload["type"] in {"scatter", "bubble"}:
+ role = "x" if position in {"b", "t"} else "y"
+ else:
+ role = "value" if tag == "valAx" else "category"
+ if _element_val(axis.find("c:axId", C_NS)) in secondary_ids:
+ role = f"secondary_{role}"
+ config = payload.setdefault("axes", {}).setdefault(role, {})
+ if position in {"b", "t", "l", "r"}:
+ config["position"] = {"b": "bottom", "t": "top", "l": "left", "r": "right"}[position]
+ config["visible"] = not _strict_axis_bool(axis.find("c:delete", C_NS), False)
+ config["reverse"] = _element_val(axis.find("c:scaling/c:orientation", C_NS)) == "maxMin"
+ config["major_gridlines"] = axis.find("c:majorGridlines", C_NS) is not None
+ for tag_name, field, aliases in (
+ ("tickLblPos", "label_position", {"nextTo": "next_to", "none": "none", "low": "low", "high": "high"}),
+ ("majorTickMark", "tick_marks", {value: value for value in ("none", "in", "out", "cross")}),
+ ):
+ value = _element_val(axis.find(f"c:{tag_name}", C_NS))
+ if value in aliases:
+ config[field] = aliases[value]
+ if role in {"value", "secondary_value"}:
+ crossing = _element_val(axis.find("c:crossBetween", C_NS))
+ if crossing in {"between", "midCat"}:
+ config["cross_between"] = "mid_category" if crossing == "midCat" else crossing
+ number_format = axis.find("c:numFmt", C_NS)
+ if number_format is not None and number_format.get("formatCode"):
+ config["number_format"] = number_format.get("formatCode")
+ for path, field in (("scaling/c:min", "minimum"), ("scaling/c:max", "maximum"),
+ ("majorUnit", "major_unit")):
+ value = _axis_number(axis.find(f"c:{path}", C_NS))
+ if value is not None and (field != "major_unit" or tag == "valAx"):
+ config[field] = value
+ text_style = _source_chart_text_style(axis.find("c:txPr", C_NS), palette)
+ config.update({key: value for key, value in text_style.items() if key != "bold"})
+ for path, key in (("c:spPr/a:ln", "axis_color"), ("c:majorGridlines/c:spPr/a:ln", "grid_color")):
+ color = _chart_fill_color(axis.find(path, C_NS), palette)
+ if color is not None:
+ style.setdefault(key, color)
+
+
def _chart_text(container: ET.Element | None) -> str:
if container is None:
return ""
@@ -2442,16 +2665,29 @@ def _data_label_flag_payload(
return config
-def _source_data_label_text_style(
- owner: ET.Element,
+def _source_chart_text_style(
+ owner: ET.Element | None,
palette: ColorPalette | None,
) -> dict[str, Any]:
- """Read visible label run properties without requiring canonical OOXML."""
- r_pr = owner.find(".//a:defRPr", C_NS)
- if r_pr is None:
- r_pr = owner.find(".//a:rPr", C_NS)
- if r_pr is None:
+ """Read uniform chart text properties without requiring canonical OOXML."""
+ if owner is None:
return {}
+ properties = [owner.find(path, C_NS) for path in (
+ "a:lstStyle/a:defPPr/a:defRPr", "a:lstStyle/a:lvl1pPr/a:defRPr",
+ "a:p/a:pPr/a:defRPr", "a:p/a:r/a:rPr",
+ )]
+ if all(node is None for node in properties):
+ return {}
+ r_pr = ET.Element(f"{{{NS['a']}}}rPr")
+ for node in properties:
+ if node is None:
+ continue
+ r_pr.attrib.update(node.attrib)
+ for child in node:
+ previous = r_pr.find(child.tag)
+ if previous is not None:
+ r_pr.remove(previous)
+ r_pr.append(child)
style: dict[str, Any] = {}
raw_size = r_pr.attrib.get("sz")
@@ -2529,7 +2765,7 @@ def _point_data_label_payload(
item["number_format"] = num_fmt.attrib["formatCode"]
tx_pr = dlabel.find("c:txPr", C_NS)
if tx_pr is not None:
- item.update(_source_data_label_text_style(tx_pr, palette))
+ item.update(_source_chart_text_style(tx_pr, palette))
tx = dlabel.find("c:tx", C_NS)
if tx is not None:
@@ -2544,7 +2780,7 @@ def _point_data_label_payload(
if not text:
raise _UnsupportedChart("unsupported-chart-point-labels")
item["text"] = text
- item.update(_source_data_label_text_style(rich, palette))
+ item.update(_source_chart_text_style(rich, palette))
return item
@@ -2613,7 +2849,7 @@ def _data_labels_payload(
try:
config.update(_data_label_text_style(tx_pr))
except _UnsupportedChart:
- config.update(_source_data_label_text_style(tx_pr, palette))
+ config.update(_source_chart_text_style(tx_pr, palette))
if point_nodes:
assert point_count is not None
points = [
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json
index 68520fbe..260503ec 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/prompt_audit_manifest.json
@@ -2283,7 +2283,7 @@
{
"path": "skills/ppt-master/templates/spec_lock_reference.md",
"role": "reference",
- "fingerprint": "dfda31146866",
+ "fingerprint": "fac5cb29a117",
"reason": "This reference mirrors the owner field grammar or ownership boundary."
}
]
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
index f94f5010..95e950ce 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_quality/checker.py
@@ -1732,12 +1732,49 @@ class SVGQualityChecker:
result,
included_text_ids,
)
+ self._check_roundtrip_chart_edits(root, baseline_root, source_document, result)
result['passed'] = len(result['errors']) == 0
except Exception as exc:
result['errors'].append(f"Failed to read file: {exc}")
result['passed'] = False
return self._record_result(result)
+ @staticmethod
+ def _check_roundtrip_chart_edits(root, baseline_root, source_document, result: Dict) -> None:
+ """Surface source-only chart losses before the round-trip export."""
+ from authoring_roundtrip import (
+ _layered_ref_index,
+ _parse_svg,
+ chart_rebuild_warning,
+ prepare_imported_chart_edit,
+ )
+ baseline_by_ref = {node.get('data-pptx-source-ref'): node for node in baseline_root.iter()
+ if node.get('data-pptx-source-ref')}
+ candidates = [node for node in root.iter() if node.get('data-pptx-replace-with') == 'chart'
+ and node.get('data-pptx-source-ref') in baseline_by_ref]
+ if not candidates:
+ return
+ sources = _layered_ref_index(_parse_svg(source_document.layered_source_path))
+ project_path = source_document.authoring_path.parent.parent
+ for node in candidates:
+ source_ref = node.get('data-pptx-source-ref')
+ source = sources.get(source_ref)
+ if source is None:
+ continue
+ try:
+ _package, reason = prepare_imported_chart_edit(
+ node, baseline_by_ref[source_ref], source,
+ current_root=root, baseline_root=baseline_root,
+ )
+ except RuntimeError as exc:
+ result['errors'].append(f"{_element_label(node)}: {exc}")
+ continue
+ if reason:
+ result['warnings'].append(
+ f"{_element_label(node)} source_ref={source_ref}: "
+ + chart_rebuild_warning(project_path, source_document.source_slide, source_ref, reason)
+ )
+
@staticmethod
def _check_roundtrip_unsupported_table_edits(
root: ET.Element,
@@ -2292,8 +2329,8 @@ class SVGQualityChecker:
detail = f': {frame_error}' if frame_error else ''
result['warnings'].append(
f'Cannot verify owning frame for edited {text_label}{detail}; '
- 'keep data-pptx-frame on the logical object or place a '
- 'rect frame beside the text'
+ 'set data-pptx-bounds on the authored text group or group '
+ 'the text with its containing rect; preserve existing imported frames'
)
continue
metrics = self._bounds_overflow_metrics(estimated, frame)
@@ -2332,8 +2369,8 @@ class SVGQualityChecker:
)
if frame_is_inferred:
result['warnings'].append(
- f'{finding}; frame inferred from nearest rect sibling; '
- 'add data-pptx-frame to make this blocking'
+ f'{finding}; frame inferred from a containing rect sibling; '
+ 'set data-pptx-bounds on the authored text group to declare its bounds'
)
else:
result['errors'].append(finding)
@@ -2348,6 +2385,8 @@ class SVGQualityChecker:
while current is not None:
if current.get('data-pptx-frame') is not None:
return current.get('data-pptx-source-ref')
+ if current.get('data-pptx-bounds') is not None:
+ return None
current = parent_by_id.get(id(current))
return None
@@ -2411,12 +2450,16 @@ class SVGQualityChecker:
str | None,
bool,
]:
- """Resolve an explicit logical frame or the nearest rect sibling."""
+ """Resolve owning bounds or a containing rect in the text's own group."""
current: ET.Element | None = text_element
while current is not None:
- raw_frame = current.get('data-pptx-frame')
+ attribute = (
+ 'data-pptx-frame' if current.get('data-pptx-frame') is not None
+ else 'data-pptx-bounds'
+ )
+ raw_frame = current.get(attribute)
if raw_frame is not None:
- label = f'{_element_label(current)} data-pptx-frame'
+ label = f'{_element_label(current)} {attribute}'
try:
frame = _parse_positive_bounds(raw_frame)
except ValueError as exc:
@@ -2438,62 +2481,54 @@ class SVGQualityChecker:
if _parse_project_geometry_length is None:
return None, None, 'the SVG length parser is unavailable', False
- current = text_element
+ parent = parent_by_id.get(id(text_element))
+ if parent is None or _local_name(parent) != 'g':
+ return None, None, None, False
+ try:
+ anchor_x = _parse_project_geometry_length(text_element.get('x') or '0', 'x')
+ anchor_y = _parse_project_geometry_length(text_element.get('y') or '0', 'y')
+ except (TypeError, ValueError):
+ return None, None, 'the text anchor cannot be resolved', False
+ anchor = cls._transformed_rect_bounds(
+ text_element, (anchor_x, anchor_y, 0, 0), parent_by_id,
+ )
+ if anchor is None:
+ return None, None, 'the text anchor has an unresolved transform', False
rect_errors: List[str] = []
- while current is not None:
- parent = parent_by_id.get(id(current))
- if parent is None:
- break
- siblings = list(parent)
+ siblings = list(parent)
+ current_index = siblings.index(text_element)
+ rects = sorted(
+ (
+ (abs(index - current_index), index, sibling)
+ for index, sibling in enumerate(siblings)
+ if _local_name(sibling) == 'rect'
+ and sibling.get('data-pptx-role') not in {'decoration', 'background'}
+ ),
+ key=lambda item: (item[0], item[1]),
+ )
+ for _distance, _index, rect in rects:
+ label = _element_label(rect)
try:
- current_index = siblings.index(current)
- except ValueError:
- current_index = 0
- rects = sorted(
- (
- (abs(index - current_index), index, sibling)
- for index, sibling in enumerate(siblings)
- if _local_name(sibling) == 'rect'
- ),
- key=lambda item: (item[0], item[1]),
- )
- for _distance, _index, rect in rects:
- label = _element_label(rect)
- try:
- x = _parse_project_geometry_length(
- rect.get('x') or '0',
- 'x',
- )
- y = _parse_project_geometry_length(
- rect.get('y') or '0',
- 'y',
- )
- width = _parse_project_geometry_length(
- rect.get('width'),
- 'width',
- )
- height = _parse_project_geometry_length(
- rect.get('height'),
- 'height',
- )
- if (
- not all(math.isfinite(value) for value in (x, y, width, height))
- or width <= 0
- or height <= 0
- ):
- raise ValueError('must use finite positive width and height')
- except (TypeError, ValueError) as exc:
- rect_errors.append(f'{label} {exc}')
- continue
- transformed = cls._transformed_rect_bounds(
- rect,
- (x, y, width, height),
- parent_by_id,
- )
- if transformed is not None:
- return label, transformed, None, True
+ x = _parse_project_geometry_length(rect.get('x') or '0', 'x')
+ y = _parse_project_geometry_length(rect.get('y') or '0', 'y')
+ width = _parse_project_geometry_length(rect.get('width'), 'width')
+ height = _parse_project_geometry_length(rect.get('height'), 'height')
+ if (
+ not all(math.isfinite(value) for value in (x, y, width, height))
+ or width <= 0 or height <= 0
+ ):
+ raise ValueError('must use finite positive width and height')
+ except (TypeError, ValueError) as exc:
+ rect_errors.append(f'{label} {exc}')
+ continue
+ transformed = cls._transformed_rect_bounds(rect, (x, y, width, height), parent_by_id)
+ if transformed is None:
rect_errors.append(f'{label} has an unresolved transform')
- current = parent
+ elif (
+ transformed[0] <= anchor[0] <= transformed[2]
+ and transformed[1] <= anchor[1] <= transformed[3]
+ ):
+ return label, transformed, None, True
return (
None,
None,
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py
index cb37a1ff..e055d375 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py
@@ -5,6 +5,8 @@ from __future__ import annotations
import math
from typing import Any
+from language_tags import LanguageTagError, normalize_language_tag
+
from .marker_common import (
_chart_bool,
_clean_hex,
@@ -1628,8 +1630,19 @@ def _xy_chart_data(
}
+def _chart_language(payload: dict[str, Any]) -> str | None:
+ if "lang" not in payload:
+ return None
+ try:
+ return normalize_language_tag(payload["lang"])
+ except LanguageTagError as exc:
+ raise RuntimeError(f"Native PPTX chart lang: {exc}") from exc
+
+
def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
chart_type, alias_grouping, alias_style = _chart_kind(payload)
+ if _chart_language(payload) is not None and chart_type in _CHARTEX_CHART_TYPES:
+ raise RuntimeError("Native PPTX chart lang is supported for classic charts only")
if payload.get("hole_size") is not None and chart_type != "doughnut":
raise RuntimeError(
"Native PPTX chart hole_size is supported for doughnut charts only"
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_patch.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_patch.py
new file mode 100644
index 00000000..bfb2e6d7
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_patch.py
@@ -0,0 +1,464 @@
+#!/usr/bin/env python3
+"""
+PPT Master - Imported Chart Data Patching
+
+Patch classic-chart caches and their embedded workbook without serializing the
+surrounding chart XML. See scripts/docs/conversion.md for the round-trip rule.
+
+Usage:
+ Import patch_chart_package from the round-trip materializer.
+
+Examples:
+ package = patch_chart_package(source_package, baseline, edited)
+
+Dependencies:
+ Standard library and sibling native chart helpers.
+"""
+
+from __future__ import annotations
+
+import base64
+import copy
+import hashlib
+import io
+import math
+import posixpath
+import re
+from dataclasses import dataclass
+from typing import Any
+from xml.etree import ElementTree as ET
+from xml.parsers import expat
+from xml.sax.saxutils import escape
+from zipfile import BadZipFile, ZipFile
+
+from .chart_data import validate_chart_payload
+
+
+NS = {
+ "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
+ "s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+}
+_START_TAG_RE = re.compile(rb'''<([^\s/>]+)(?:[^>"']|"[^"]*"|'[^']*')*>''')
+_RANGE_RE = re.compile(r"(?:'((?:[^']|'')+)'|([^!]+))!\$?([A-Z]+)\$?([1-9][0-9]*)"
+ r"(?::\$?([A-Z]+)\$?([1-9][0-9]*))?")
+
+
+@dataclass
+class _Span:
+ start: int
+ start_end: int
+ close: int
+ end: int
+ name: bytes
+ empty: bool
+
+
+class _XmlPatch:
+ """Address parsed elements while retaining all untouched source bytes."""
+
+ def __init__(self, data: bytes) -> None:
+ self.data = data
+ self.root = ET.fromstring(data)
+ self.edits: list[tuple[int, int, bytes]] = []
+ spans: list[_Span] = []
+ stack: list[_Span] = []
+ parser = expat.ParserCreate()
+
+ def start(_name, _attributes):
+ offset = parser.CurrentByteIndex
+ match = _START_TAG_RE.match(data, offset)
+ if match is None:
+ raise RuntimeError("Cannot locate source XML start tag")
+ empty = match.group().endswith(b"/>")
+ span = _Span(offset, match.end(), match.end() - 2, match.end(), match[1], empty)
+ spans.append(span)
+ stack.append(span)
+
+ def end(_name):
+ span = stack.pop()
+ if not span.empty:
+ span.close = parser.CurrentByteIndex
+ span.end = data.index(b">", span.close) + 1
+
+ parser.StartElementHandler = start
+ parser.EndElementHandler = end
+ parser.Parse(data, True)
+ self.spans = dict(zip(self.root.iter(), spans))
+
+ def replace(self, node: ET.Element, data: bytes) -> None:
+ span = self.spans[node]
+ self.edits.append((span.start, span.end, data))
+
+ def text(self, node: ET.Element, value: Any) -> None:
+ data = escape(str(value), {"\r": "
"}).encode("utf-8")
+ span = self.spans[node]
+ if span.empty:
+ self.replace(node, self.data[span.start:span.close] + b">" + data + b"" + span.name + b">")
+ else:
+ self.edits.append((span.start_end, span.close, data))
+
+ def attributes(self, node: ET.Element, values: dict[str, str | None]) -> None:
+ span = self.spans[node]
+ start = self.data[span.start:span.start_end]
+ for key, value in values.items():
+ pattern = re.compile(rb"\s+" + re.escape(key.encode()) + rb'''\s*=\s*(?:"[^"]*"|'[^']*')''')
+ replacement = b"" if value is None else (
+ b" " + key.encode() + b'="' + escape(value, {'"': """}).encode("utf-8") + b'"'
+ )
+ if pattern.search(start):
+ start = pattern.sub(lambda _match: replacement, start, count=1)
+ elif value is not None:
+ offset = -2 if span.empty else -1
+ start = start[:offset] + replacement + start[offset:]
+ self.edits.append((span.start, span.start_end, start))
+
+ def append(self, node: ET.Element, data: bytes) -> None:
+ span = self.spans[node]
+ if span.empty:
+ self.replace(node, self.data[span.start:span.close] + b">" + data + b"" + span.name + b">")
+ else:
+ self.edits.append((span.close, span.close, data))
+
+ def render(self) -> bytes:
+ result: list[bytes] = []
+ cursor = 0
+ for start, end, data in sorted(self.edits, key=lambda edit: (edit[0], edit[1])):
+ if start < cursor:
+ raise RuntimeError("Overlapping chart XML data edits")
+ result.extend((self.data[cursor:start], data))
+ cursor = end
+ result.append(self.data[cursor:])
+ patched = b"".join(result)
+ ET.fromstring(patched)
+ return patched
+
+
+def _data_shape(payload: dict[str, Any]) -> dict[str, Any]:
+ """Mask only data leaves, retaining field presence, counts and all options."""
+ result = copy.deepcopy(payload)
+ for plot in [result, *result.get("plots", [])]:
+ if isinstance(plot.get("categories"), list):
+ plot["categories"] = [None] * len(plot["categories"])
+ for series in plot.get("series", []):
+ if "name" in series:
+ series["name"] = None
+ for key in ("values", "x", "y", "sizes"):
+ if isinstance(series.get(key), list):
+ series[key] = [None] * len(series[key])
+ return result
+
+
+def chart_edit_is_data_only(baseline: dict[str, Any], edited: dict[str, Any]) -> bool:
+ """Require the same plot/series topology and every non-data JSON field."""
+ for plot in [edited, *edited.get("plots", [])]:
+ for series in plot.get("series", []):
+ if not isinstance(series.get("name", ""), str):
+ return False
+ for key in ("values", "x", "y", "sizes"):
+ if any(value is not None and (type(value) not in {int, float} or not math.isfinite(value))
+ for value in series.get(key, [])):
+ return False
+ return baseline != edited and _data_shape(baseline) == _data_shape(edited)
+
+
+def _range_cells(formula: str) -> tuple[str, list[str]]:
+ match = _RANGE_RE.fullmatch(formula)
+ if match is None:
+ raise RuntimeError(f"Cannot patch chart workbook range {formula!r}; use a single A1 row or column range")
+ quoted, plain, col, row, last_col, last_row = match.groups()
+ sheet = quoted.replace("''", "'") if quoted is not None else plain
+ last_col, last_row = last_col or col, last_row or row
+
+ def number(label):
+ value = 0
+ for char in label:
+ value = value * 26 + ord(char) - ord("A") + 1
+ return value
+
+ def label(value):
+ result = ""
+ while value:
+ value, digit = divmod(value - 1, 26)
+ result = chr(ord("A") + digit) + result
+ return result
+
+ c1, c2, r1, r2 = number(col), number(last_col), int(row), int(last_row)
+ if c2 < c1 or r2 < r1 or (c1 != c2 and r1 != r2):
+ raise RuntimeError(f"Cannot patch non-linear chart workbook range {formula!r}")
+ if c2 > 16384 or r2 > 1048576 or max(c2 - c1, r2 - r1) > 1_000_000:
+ raise RuntimeError(f"Chart workbook range exceeds worksheet bounds: {formula!r}")
+ return sheet, [f"{label(c)}{r}" for r in range(r1, r2 + 1) for c in range(c1, c2 + 1)]
+
+
+def _patch_cache(
+ xml: _XmlPatch, owner: ET.Element, before: list, after: list,
+ cells: dict[tuple[str, str], Any],
+) -> None:
+ cache = next((node for node in owner.iter() if node.tag in {
+ f'{{{NS["c"]}}}{name}' for name in ("strCache", "numCache", "strLit", "numLit")
+ }), None)
+ if cache is None:
+ raise RuntimeError("Imported chart has no patchable data cache; reimport a chart with cached values")
+ formula = owner.find(".//c:f", NS)
+ if formula is not None:
+ sheet, addresses = _range_cells(formula.text or "")
+ if len(addresses) != len(after):
+ raise RuntimeError("Chart cache count differs from its workbook range; repair the source chart")
+ for address, old, value in zip(addresses, before, after):
+ key = (sheet, address)
+ if key in cells and cells[key][1] != value:
+ raise RuntimeError(f"Conflicting chart data for workbook cell {sheet}!{address}")
+ cells[key] = (old, value)
+ if before == after:
+ return
+ if cache.tag in {f'{{{NS["c"]}}}numCache', f'{{{NS["c"]}}}numLit'}:
+ try:
+ valid = all(value is None or math.isfinite(float(value)) for value in after)
+ except (ValueError, TypeError, OverflowError):
+ valid = False
+ if not valid:
+ raise RuntimeError("Source chart uses a numeric cache; retain numeric category labels")
+ count = cache.find("c:ptCount", NS)
+ if count is None:
+ raise RuntimeError("Imported chart cache has no ptCount; repair the source chart")
+ if count.get("val") != str(len(after)):
+ xml.attributes(count, {"val": str(len(after))})
+ points = {int(node.get("idx", "-1")): node for node in cache.findall("c:pt", NS)}
+ prefix = xml.spans[cache].name.rsplit(b":", 1)[0] + b":" if b":" in xml.spans[cache].name else b""
+ additions: dict[int, list[bytes]] = {}
+ for index, (old, value) in enumerate(zip(before, after)):
+ if old == value:
+ continue
+ point = points.get(index)
+ if value is None:
+ if point is not None:
+ xml.replace(point, b"")
+ elif point is not None:
+ leaf = point.find("c:v", NS)
+ if leaf is None:
+ raise RuntimeError("Imported chart point has no value; repair the source chart")
+ xml.text(leaf, value)
+ else:
+ tag = prefix.decode()
+ following = [xml.spans[node].start for idx, node in points.items() if idx > index]
+ anchor = min(following) if following else max(
+ (xml.spans[node].end for node in points.values()), default=xml.spans[count].end,
+ )
+ additions.setdefault(anchor, []).append(
+ f'<{tag}pt idx="{index}"><{tag}v>{escape(str(value))}{tag}v>{tag}pt>'.encode("utf-8")
+ )
+ for anchor, fragments in additions.items():
+ xml.edits.append((anchor, anchor, b"".join(fragments)))
+
+
+def _patch_chart(data: bytes, baseline: dict, edited: dict) -> tuple[bytes, dict]:
+ xml = _XmlPatch(data)
+ plot_area = xml.root.find("c:chart/c:plotArea", NS)
+ plots = [node for node in plot_area if node.find("c:ser", NS) is not None] if plot_area is not None else []
+ old_plots, new_plots = baseline.get("plots", [baseline]), edited.get("plots", [edited])
+ if len(plots) != len(new_plots):
+ raise RuntimeError("Source chart plots do not match the import baseline; reimport the source deck")
+ cells: dict[tuple[str, str], Any] = {}
+ for plot, old_plot, new_plot in zip(plots, old_plots, new_plots):
+ series = plot.findall("c:ser", NS)
+ if len(series) != len(new_plot["series"]):
+ raise RuntimeError("Source chart series do not match the import baseline; reimport the source deck")
+ for node, old, new in zip(series, old_plot["series"], new_plot["series"]):
+ for key, tag in (("values", "val"), ("x", "xVal"), ("y", "yVal"), ("sizes", "bubbleSize")):
+ if key in new:
+ owner = node.find(f"c:{tag}", NS)
+ if owner is None:
+ raise RuntimeError(f"Source chart lacks c:{tag}; reimport the source deck")
+ _patch_cache(xml, owner, old[key], new[key], cells)
+ if "categories" in edited:
+ owner = node.find("c:cat", NS)
+ if owner is None:
+ raise RuntimeError("Source chart lacks categories; reimport the source deck")
+ _patch_cache(xml, owner, old_plot.get("categories", baseline["categories"]),
+ new_plot.get("categories", edited["categories"]), cells)
+ tx = node.find("c:tx", NS)
+ if tx is not None and tx.find("c:strRef", NS) is not None:
+ _patch_cache(xml, tx, [old["name"]], [new["name"]], cells)
+ elif old.get("name") != new.get("name"):
+ leaf = tx.find("c:v", NS) if tx is not None else None
+ if leaf is None:
+ name = xml.spans[node].name.decode()
+ prefix = name.rsplit(":", 1)[0] + ":" if ":" in name else ""
+ value = escape(new["name"], {"\r": "
"})
+ content = f"<{prefix}v>{value}{prefix}v>".encode("utf-8")
+ if tx is not None:
+ xml.append(tx, content)
+ else:
+ order = node.find("c:order", NS)
+ if order is None:
+ raise RuntimeError("Source chart series has no order; repair the source chart")
+ anchor = xml.spans[order].end
+ content = f"<{prefix}tx>".encode() + content + f"{prefix}tx>".encode()
+ xml.edits.append((anchor, anchor, content))
+ else:
+ xml.text(leaf, new["name"])
+ return xml.render(), cells
+
+
+def _resolve(owner: str, target: str) -> str:
+ return posixpath.normpath(posixpath.join(posixpath.dirname(owner), target)).lstrip("/")
+
+
+def _patch_workbook(data: bytes, assignments: dict[tuple[str, str], Any]) -> bytes:
+ with ZipFile(io.BytesIO(data)) as archive:
+ parts = {name: archive.read(name) for name in archive.namelist()}
+ workbook = ET.fromstring(parts["xl/workbook.xml"])
+ relationships = ET.fromstring(parts["xl/_rels/workbook.xml.rels"])
+ targets = {rel.get("Id"): _resolve("xl/workbook.xml", rel.get("Target", "")) for rel in relationships}
+ sheets = {sheet.get("name"): targets[sheet.get(f'{{{NS["r"]}}}id')]
+ for sheet in workbook.findall("s:sheets/s:sheet", NS)}
+ strings_part = next((targets[rel.get("Id")] for rel in relationships
+ if rel.get("Type", "").endswith("/sharedStrings")), None)
+ strings = _XmlPatch(parts[strings_part]) if strings_part else None
+ string_values = ["".join(text.text or "" for text in node.findall("s:t", NS) + node.findall("s:r/s:t", NS))
+ for node in strings.root.findall("s:si", NS)] if strings else []
+ string_indices = {value: index for index, value in enumerate(string_values)}
+ additions: list[bytes] = []
+ sheets_xml = {name: _XmlPatch(parts[part]) for name, part in sheets.items()}
+ shared_count = sum(node.get("t") == "s" for xml in sheets_xml.values() for node in xml.root.iter())
+ old_shared_count = shared_count
+ for (sheet, address), (before, value) in assignments.items():
+ if before == value:
+ continue
+ if sheet not in sheets_xml:
+ raise RuntimeError(f"Chart workbook sheet {sheet!r} is missing")
+ xml = sheets_xml[sheet]
+ node = next((cell for cell in xml.root.findall("s:sheetData/s:row/s:c", NS)
+ if cell.get("r") == address), None)
+ leaf = node.find("s:v", NS) if node is not None else None
+ kind = node.get("t") if node is not None else None
+ old_value = leaf.text if leaf is not None else None
+ if kind == "s" and old_value is not None:
+ old_value = string_values[int(old_value)]
+ elif kind == "inlineStr":
+ old_value = "".join(node.find("s:is", NS).itertext())
+ if old_value == value or (kind in {None, "n"} and old_value is not None
+ and isinstance(value, (int, float)) and float(old_value) == value):
+ continue
+ if node is not None and node.find("s:f", NS) is not None:
+ raise RuntimeError(f"Chart workbook cell {sheet}!{address} is a formula; edit its source inputs")
+ # A fresh cell body preserves style/extension attributes and avoids shared-string alias edits.
+ cell = copy.deepcopy(node) if node is not None else ET.Element(f'{{{NS["s"]}}}c', {"r": address})
+ cell.tail = None
+ for child in list(cell):
+ if child.tag in {f'{{{NS["s"]}}}{tag}' for tag in ("v", "is")}:
+ cell.remove(child)
+ cell.attrib.pop("t", None)
+ if isinstance(value, str):
+ if strings is not None:
+ if value not in string_indices:
+ string_indices[value] = len(string_values) + len(additions)
+ tag = strings.spans[strings.root].name.decode().rsplit(":", 1)
+ prefix = tag[0] + ":" if len(tag) == 2 else ""
+ text_value = escape(value, {"\r": "
"})
+ additions.append((f'<{prefix}si><{prefix}t xml:space="preserve">'
+ f'{text_value}{prefix}t>{prefix}si>').encode("utf-8"))
+ cell.set("t", "s")
+ content = ET.Element(f'{{{NS["s"]}}}v')
+ content.text = str(string_indices[value])
+ cell.insert(0, content)
+ else:
+ cell.set("t", "inlineStr")
+ content = ET.Element(f'{{{NS["s"]}}}is')
+ text = ET.SubElement(content, f'{{{NS["s"]}}}t')
+ text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
+ text.text = value
+ cell.insert(0, content)
+ elif value is not None:
+ content = ET.Element(f'{{{NS["s"]}}}v')
+ content.text = str(value)
+ cell.insert(0, content)
+ shared_count += int(cell.get("t") == "s") - int(kind == "s")
+ replacement = ET.tostring(cell, encoding="utf-8")
+ if node is not None:
+ xml.replace(node, replacement)
+ else:
+ row_num = re.search(r"[0-9]+$", address).group()
+ row = next((row for row in xml.root.findall("s:sheetData/s:row", NS)
+ if row.get("r") == row_num), None)
+ if row is None:
+ raise RuntimeError(f"Chart workbook row {sheet}!{row_num} is missing; repair the source workbook")
+ column = re.match(r"[A-Z]+", address).group()
+ following = [cell for cell in row.findall("s:c", NS)
+ if (len(re.match(r"[A-Z]+", cell.get("r")).group()),
+ re.match(r"[A-Z]+", cell.get("r")).group()) > (len(column), column)]
+ if following:
+ anchor = xml.spans[following[0]].start
+ xml.edits.append((anchor, anchor, replacement))
+ else:
+ xml.append(row, replacement)
+ if not any(xml.edits for xml in sheets_xml.values()):
+ return data
+ for sheet, xml in sheets_xml.items():
+ if xml.edits:
+ parts[sheets[sheet]] = xml.render()
+ if strings is not None and (additions or shared_count != old_shared_count):
+ strings.attributes(strings.root, {"count": str(shared_count),
+ "uniqueCount": str(len(string_values) + len(additions))})
+ if additions:
+ strings.append(strings.root, b"".join(additions))
+ parts[strings_part] = strings.render()
+ output = io.BytesIO()
+ with ZipFile(output, "w") as result:
+ for info in archive.infolist():
+ result.writestr(info, parts[info.filename])
+ result.comment = archive.comment
+ return output.getvalue()
+
+
+def patch_chart_package(source: dict, baseline: dict, edited: dict) -> dict:
+ """Clone a validated source graph and patch only data in its chart/workbook."""
+ from . import _decode_source_chart_package
+
+ validate_chart_payload(edited)
+ decoded = _decode_source_chart_package({"source_package": source})
+ if decoded is None:
+ raise RuntimeError("Imported chart has no source package; reimport the source deck")
+ chart_part, _frame, parts, _types = decoded
+ parts[chart_part], cells = _patch_chart(parts[chart_part], baseline, edited)
+ chart_root = ET.fromstring(parts[chart_part])
+ external = chart_root.find("c:externalData", NS)
+ if cells and external is not None:
+ rels_name = posixpath.join(posixpath.dirname(chart_part), "_rels", posixpath.basename(chart_part) + ".rels")
+ rels = ET.fromstring(parts[rels_name])
+ rel = next((rel for rel in rels if rel.get("Id") == external.get(f'{{{NS["r"]}}}id')), None)
+ if rel is None:
+ raise RuntimeError("Chart workbook relationship is missing; repair the source chart")
+ workbook_part = _resolve(chart_part, rel.get("Target", ""))
+ try:
+ parts[workbook_part] = _patch_workbook(parts[workbook_part], cells)
+ except (BadZipFile, KeyError, ValueError, ET.ParseError) as exc:
+ raise RuntimeError(f"Cannot patch embedded chart workbook: {exc}; repair the source workbook") from exc
+
+ # Content-addressed copies keep other pages sharing the source graph immutable.
+ digest = hashlib.sha256()
+ for name, data in sorted(parts.items()):
+ digest.update(name.encode() + b"\0" + hashlib.sha256(data).digest())
+ suffix = "_data_" + digest.hexdigest()[:20]
+ names = {name: posixpath.splitext(name)[0] + suffix + posixpath.splitext(name)[1]
+ for name in parts if not name.endswith(".rels")}
+ for name in parts:
+ if not name.endswith(".rels"):
+ continue
+ parent, leaf = name.split("/_rels/", 1)
+ owner = parent + "/" + leaf[:-5]
+ new_owner = names[owner]
+ names[name] = posixpath.join(posixpath.dirname(new_owner), "_rels", posixpath.basename(new_owner) + ".rels")
+ rels = _XmlPatch(parts[name])
+ for rel in rels.root:
+ target = names[_resolve(owner, rel.get("Target", ""))]
+ rels.attributes(rel, {"Target": posixpath.relpath(target, posixpath.dirname(new_owner))})
+ parts[name] = rels.render()
+ result = copy.deepcopy(source)
+ result["chart_part"] = names[chart_part]
+ for item in result["parts"]:
+ data = parts[item["name"]]
+ item.update(name=names[item["name"]], payload=base64.b64encode(data).decode("ascii"),
+ sha256=hashlib.sha256(data).hexdigest())
+ return result
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_style.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_style.py
index 116a360e..86698685 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_style.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_style.py
@@ -347,16 +347,37 @@ def _major_gridlines_xml(color: str | None) -> str:
return f'{_chart_line_sp_pr_xml(color, width=6350)}'
+_THEME_FONT_TOKEN = re.compile(r"^\+(mn|mj)-(lt|ea|cs)$")
+
+
def _font_face_xml(font_face: str | None, language: str | None = None) -> str:
if not font_face:
return ""
- fonts = parse_font_family(font_face, language)
- latin_font = _xml_escape(fonts["latin"])
- ea_font = _xml_escape(fonts["ea"])
+ # Imported charts keep theme font tokens such as ``+mn-lt, +mn-ea``;
+ # each token owns its own slot and named faces fill the rest.
+ tokens: dict[str, str] = {}
+ named: list[str] = []
+ for entry in font_face.split(","):
+ face = entry.strip().strip("'\"")
+ match = _THEME_FONT_TOKEN.match(face)
+ if match:
+ tokens.setdefault(match.group(2), face)
+ elif face:
+ named.append(face)
+ fonts = parse_font_family(", ".join(named), language) if named or not tokens else {}
+ latin_font = tokens.get("lt") or fonts.get("latin")
+ if latin_font is None:
+ latin_font = tokens.get("ea", "").replace("-ea", "-lt") or "Segoe UI"
+ ea_font = tokens.get("ea") or fonts.get("ea")
+ if ea_font is None:
+ ea_font = latin_font.replace("-lt", "-ea")
+ cs_font = tokens.get("cs") or (
+ latin_font.replace("-lt", "-cs") if _THEME_FONT_TOKEN.match(latin_font) else latin_font
+ )
return (
- f''
- f''
- f''
+ f''
+ f''
+ f''
)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py
index ee97c6b5..c8d4462d 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/chart_xml.py
@@ -18,6 +18,7 @@ from .chart_data import (
_DEFAULT_CHART_COLORS,
_category_axis_is_date,
_category_axis_reversed,
+ _chart_language,
_chart_list,
_chart_plot_area_layout,
_data_label_position,
@@ -913,6 +914,20 @@ def _axis_major_gridlines_xml(
return _major_gridlines_xml(color) if enabled else ""
+def _axis_tx_pr_xml(
+ config: dict[str, Any],
+ font_size: int,
+ chart_style: dict[str, str | None],
+) -> str:
+ """Apply per-axis tick-label overrides to the shared chart text defaults."""
+ return _chart_tx_pr_xml(
+ _font_size_hpt(config["font_size"]) if config.get("font_size") else font_size,
+ config.get("color") or chart_style.get("text_color"),
+ font_face=config.get("font_family") or chart_style.get("font_face"),
+ language=chart_style.get("primary_language"),
+ )
+
+
def _axis_pair_xml(
cat_ax_id: str,
val_ax_id: str,
@@ -951,16 +966,6 @@ def _axis_pair_xml(
val_number_format = _axis_number_format_xml(value, default_value_format)
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
- def axis_tx_pr(config: dict[str, Any]) -> str:
- # Tick-label text per axis: the payload's colour/font/size win over
- # the chart-wide text style (category labels dark, value ticks grey).
- return _chart_tx_pr_xml(
- _font_size_hpt(config["font_size"]) if config.get("font_size") else axis_font_size,
- config.get("color") or chart_style.get("text_color"),
- font_face=config.get("font_family") or chart_style.get("font_face"),
- language=primary_language,
- )
-
cat_title_xml = "" if secondary else _axis_title_xml(
_first_present(axis_titles.get("category"), axis_titles.get("x")),
font_size=axis_title_font_size,
@@ -1034,7 +1039,7 @@ def _axis_pair_xml(
f"{cat_gridlines}{cat_title_xml}{cat_number_format}"
f''
f''
- f"{axis_sp_pr}{axis_tx_pr(category)}"
+ f"{axis_sp_pr}{_axis_tx_pr_xml(category, axis_font_size, chart_style)}"
f'{category_tail}'
f""
""
@@ -1043,7 +1048,7 @@ def _axis_pair_xml(
f"{val_gridlines}{val_title_xml}{val_number_format}"
f''
f''
- f"{axis_sp_pr}{axis_tx_pr(value)}"
+ f"{axis_sp_pr}{_axis_tx_pr_xml(value, axis_font_size, chart_style)}"
f''
f"{cross_between}{major_unit}"
""
@@ -1498,12 +1503,6 @@ def _xy_axis_xml(
x_axis = normalized_axes.get("x", {})
y_axis = normalized_axes.get("y", {})
axis_sp_pr = _chart_line_sp_pr_xml(chart_style.get("axis_color"))
- axis_tx_pr = _chart_tx_pr_xml(
- axis_font_size,
- chart_style.get("text_color"),
- font_face=chart_style.get("font_face"),
- language=primary_language,
- )
x_title_xml = _axis_title_xml(
_first_present(axis_titles.get("x"), axis_titles.get("category")),
font_size=axis_title_font_size,
@@ -1546,9 +1545,9 @@ def _xy_axis_xml(
f'{_axis_scaling_xml(config)}'
f''
f"{gridlines}{title_xml}{number_format}"
- ''
+ f''
f''
- f"{axis_sp_pr}{axis_tx_pr}"
+ f"{axis_sp_pr}{_axis_tx_pr_xml(config, axis_font_size, chart_style)}"
f''
f'{major_unit}'
""
@@ -1669,6 +1668,7 @@ def _chart_xml(
inherited_styles: dict[str, str] | None = None,
primary_language: str | None = None,
) -> bytes:
+ primary_language = _chart_language(payload) or primary_language
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
colors = (
[_clean_hex(color, "#4472C4") for color in style.get("colors", [])]
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py
index 5b9e8d49..dde9dfc5 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/native_objects/table.py
@@ -442,14 +442,16 @@ def _merge_covered_cell_is_blank(value: Any) -> bool:
return True
if not isinstance(value, dict):
return False
- if set(value) - {"merge_continuation", "text"}:
+ # The semantic-table schema has already validated formatting fields.
+ # Covered cells may retain them, but cannot carry a text body.
+ if "paragraphs" in value:
return False
if (
"merge_continuation" in value
and value["merge_continuation"] is not True
):
return False
- return value.get("text") in {None, ""}
+ return value.get("text") in (None, "")
def _resolve_table_merge_layout(
@@ -1840,52 +1842,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
cell_data = _cell_payload(cell)
merge_region = merge_layout.get((row_idx, col_idx))
merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx)
- if merge_region is not None and (
- row_idx != merge_region.row or col_idx != merge_region.col
- ):
- continuation_attrs = ""
- continuation_border_xml = ""
- continuation_fill_xml = ""
- if cell_data.get("merge_continuation") is True:
- continuation_attrs = (
- _table_padding_attrs(cell_data, style)
- + _table_cell_extra_attrs(cell_data)
- )
- continuation_border_xml = _table_border_xml(
- cell_data,
- style,
- ctx.theme_color_spec,
- )
- continuation_fill = (
- _clean_hex(cell_data.get("fill"), "#FFFFFF")
- if cell_data.get("fill") is not None
- else None
- )
- continuation_fill_xml = _table_fill_xml(
- continuation_fill,
- _table_fill_opacity(cell_data),
- ctx.theme_color_spec,
- )
- cells_xml.append(
- f''
- ''
- f''
- f'{continuation_border_xml}{continuation_fill_xml}'
- ''
- ''
- )
- continue
-
if preserve_source_style:
fill = (
_clean_hex(cell_data.get("fill"), "#FFFFFF")
if cell_data.get("fill") is not None else None
)
- color = (
- _clean_hex(cell_data.get("color"), "#000000")
- if cell_data.get("color") is not None else None
- )
- align = str(cell_data.get("align") or "l")
else:
fill = _hex_or_none(cell_data.get("fill"))
if fill is None:
@@ -1898,6 +1859,40 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
fill = header_fill if is_header else (
band_fill if banded else body_fill
)
+ border_xml = _table_border_xml(cell_data, style, ctx.theme_color_spec)
+ fill_xml = (
+ ""
+ if fill is None and not preserve_source_style
+ else _table_fill_xml(fill, _table_fill_opacity(cell_data), ctx.theme_color_spec)
+ )
+ anchor_attr = ""
+ if not preserve_source_style or "valign" in cell_data or "valign" in style:
+ anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
+ if merge_region is not None and (
+ row_idx != merge_region.row or col_idx != merge_region.col
+ ):
+ continuation_attrs = (
+ anchor_attr
+ + _table_padding_attrs(cell_data, style)
+ + _table_cell_extra_attrs(cell_data)
+ )
+ cells_xml.append(
+ f''
+ ''
+ f''
+ f'{border_xml}{fill_xml}'
+ ''
+ ''
+ )
+ continue
+
+ if preserve_source_style:
+ color = (
+ _clean_hex(cell_data.get("color"), "#000000")
+ if cell_data.get("color") is not None else None
+ )
+ align = str(cell_data.get("align") or "l")
+ else:
color = _clean_hex(
cell_data.get("color"),
header_text if is_header else body_text,
@@ -2012,28 +2007,11 @@ def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str
f"{paragraph_props}{text_run_xml}"
)
paragraphs_xml = "".join(paragraph_parts)
- anchor_keys = {"valign"}
- anchor_attr = ""
- if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style):
- anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
tc_pr_attrs = (
f'{anchor_attr}'
f'{_table_padding_attrs(cell_data, style, row_height=row_heights[row_idx], paragraphs_xml=paragraphs_xml)}'
f'{_table_cell_extra_attrs(cell_data)}'
)
- border_xml = _table_border_xml(
- cell_data,
- style,
- ctx.theme_color_spec,
- )
- if fill is None and not preserve_source_style:
- fill_xml = ""
- else:
- fill_xml = _table_fill_xml(
- fill,
- _table_fill_opacity(cell_data),
- ctx.theme_color_spec,
- )
cells_xml.append(
f""
""
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py
index c17252d1..be69bb90 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/svg_to_pptx/pptx_package/cli.py
@@ -91,6 +91,7 @@ from ..native_objects import (
native_replacement_kind,
native_replacement_status,
)
+from ..native_objects.marker_attributes import native_json_is_authoritative
from ..native_objects.marker_status import native_marker_release_block_reason
from ..drawingml.theme_colors import (
ThemeColorError,
@@ -1769,11 +1770,12 @@ def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]
return fallbacks
-def _native_object_projection_findings(
+def _native_object_preflight_findings(
svg_files: list[Path],
-) -> list[tuple[str, str, str]]:
- """Return SVG-first Chart/Table details that native metadata would discard."""
- findings: list[tuple[str, str, str]] = []
+) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
+ """Separate JSON-first payload failures from SVG-first projection findings."""
+ payload_findings: list[tuple[str, str, str]] = []
+ projection_findings: list[tuple[str, str, str]] = []
for svg_path in svg_files:
try:
root = ET.parse(svg_path).getroot()
@@ -1803,12 +1805,15 @@ def _native_object_projection_findings(
document_root=root,
)
except RuntimeError as exc:
+ if native_json_is_authoritative(elem):
+ payload_findings.append((svg_path.name, marker_id, str(exc)))
+ continue
warnings = [f"projection validation failed: {exc}"]
- findings.extend(
+ projection_findings.extend(
(svg_path.name, marker_id, warning)
for warning in warnings
)
- return findings
+ return payload_findings, projection_findings
def _release_blocked_graphics(
@@ -3229,7 +3234,20 @@ Recorded narration:
)
if args.native_objects:
- projection_findings = _native_object_projection_findings(native_files)
+ payload_findings, projection_findings = _native_object_preflight_findings(native_files)
+ if payload_findings:
+ print(
+ "Error: --native-charts-and-tables stopped because JSON-first "
+ "native payload validation failed:",
+ file=sys.stderr,
+ )
+ for filename, marker_id, finding in payload_findings:
+ print(f" {filename}: {marker_id}: {finding}", file=sys.stderr)
+ print(
+ "Correct the reported JSON payload fields in the object's "
+ "metadata and retry the native export.",
+ file=sys.stderr,
+ )
if projection_findings:
print(
"Error: --native-charts-and-tables stopped because visible "
@@ -3247,6 +3265,7 @@ Recorded narration:
"Native-ready=no.",
file=sys.stderr,
)
+ if payload_findings or projection_findings:
return 1
print(
"Warning: --native-charts-and-tables replaces shape-based SVG fallbacks "
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_import_formatting.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_import_formatting.py
new file mode 100644
index 00000000..a11ec461
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_import_formatting.py
@@ -0,0 +1,438 @@
+#!/usr/bin/env python3
+"""PPT Master - Chart Import Formatting Regression Tests
+
+Exercise chart data edits and owning text frames with small native fixtures.
+
+Usage:
+ python3 -m unittest tests.test_chart_import_formatting
+
+Examples:
+ Run the module from the scripts directory.
+
+Dependencies:
+ python-pptx and the local importer, converter, and checker.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from xml.etree import ElementTree as ET
+from zipfile import ZipFile
+
+from pptx import Presentation
+
+from pptx_to_svg.chart_to_svg import extract_native_chart_payload
+from pptx_to_svg.color_resolver import ColorPalette
+from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
+from pptx_to_svg.emu_units import Xfrm
+from pptx_to_svg.ooxml_loader import OoxmlPackage, PartRef
+from svg_quality.checker import SVGQualityChecker
+from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
+
+
+NS = {
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
+ "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ "s": "http://www.w3.org/2000/svg",
+}
+
+
+def _text_style(size: int = 1050, color: str = '') -> str:
+ return (
+ ''
+ f'{color}'
+ ''
+ ''
+ )
+
+
+def _source_chart() -> ET.Element:
+ value_style = _text_style(1200, '')
+ return ET.fromstring(
+ f''
+ ''
+ ''
+ ''
+ ''
+ ''
+ 'Budget'
+ 'OtherEquipment'
+ 'General'
+ '10'
+ '20'
+ ''
+ ''
+ ''
+ f'{_text_style()}'
+ ''
+ ''
+ ''
+ f'{value_style}'
+ ''
+ ''
+ f'{_text_style(1350)}{_text_style(900)}'
+ )
+
+
+class ChartImportFormattingTests(unittest.TestCase):
+ def _import(self, source: ET.Element) -> dict:
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "source.pptx"
+ with ZipFile(path, "w") as archive:
+ archive.writestr("ppt/charts/chart1.xml", ET.tostring(source))
+ archive.writestr(
+ "ppt/presentation.xml",
+ ''
+ '',
+ )
+ slide = PartRef("ppt/slides/slide1.xml", ET.Element("slide"), {
+ "rId1": {"target": "ppt/charts/chart1.xml"},
+ })
+ graphic = ET.fromstring(
+ f''
+ )
+ palette = ColorPalette(None, None)
+ palette.scheme["accent1"] = "347BFE"
+ with OoxmlPackage(path) as package:
+ result = extract_native_chart_payload(
+ graphic, Xfrm(100, 80, 600, 400), slide, package, palette,
+ )
+ self.assertIsNone(result.native_status)
+ self.assertIsNotNone(result.native_payload)
+ return result.native_payload
+
+ def _export(self, payload: dict, *, primary_language: str | None = None) -> ET.Element:
+ root = ET.Element(f'{{{NS["s"]}}}svg', {"viewBox": "0 0 1280 720"})
+ marker = ET.SubElement(root, f'{{{NS["s"]}}}g', {
+ "id": "chart", "data-pptx-replace-with": "chart", "data-pptx-native-authority": "json",
+ })
+ ET.SubElement(marker, f'{{{NS["s"]}}}metadata', {"type": "application/json"}).text = json.dumps(payload)
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "chart.svg"
+ path.write_bytes(ET.tostring(root))
+ _, _, _, _, parts, _ = convert_svg_to_slide_shapes(
+ path, resource_root=Path(temporary), native_objects=True, primary_language=primary_language,
+ )
+ charts = [content for name, content in parts.items() if name.startswith("ppt/charts/chart")
+ and name.endswith(".xml") and "/_rels/" not in name]
+ self.assertEqual(len(charts), 1)
+ return ET.fromstring(charts[0])
+
+ def test_data_edit_preserves_spacing_layout_language_and_axis_typography(self) -> None:
+ payload = self._import(_source_chart())
+ self.assertEqual(payload.get("gap_width"), 40)
+ self.assertEqual(payload.get("overlap"), -27)
+ self.assertEqual(payload.get("plot_area"), {"x": 160, "y": 140, "width": 480, "height": 260})
+ self.assertEqual(payload.get("lang"), "zh-CN")
+ payload["series"][0]["values"] = [60, 140]
+ payload["categories"][0] = "Reserve"
+ chart = self._export(payload, primary_language="en-US")
+ self.assertEqual(chart.find("c:lang", NS).get("val"), "zh-CN")
+ self.assertEqual(chart.find(".//c:gapWidth", NS).get("val"), "40")
+ self.assertEqual(chart.find(".//c:overlap", NS).get("val"), "-27")
+ layout = chart.find(".//c:plotArea/c:layout/c:manualLayout", NS)
+ self.assertIsNotNone(layout)
+ for key, expected in (("x", 0.1), ("y", 0.15), ("w", 0.8), ("h", 0.65)):
+ self.assertAlmostEqual(float(layout.find(f"c:{key}", NS).get("val")), expected)
+ for tag, size, color, tick in (("catAx", "1050", "347BFE", "in"),
+ ("valAx", "1200", "123456", "cross")):
+ axis = chart.find(f".//c:{tag}", NS)
+ props = axis.find("c:txPr/a:p/a:pPr/a:defRPr", NS)
+ self.assertEqual(props.get("sz"), size)
+ self.assertEqual(props.find("a:latin", NS).get("typeface"), "Microsoft YaHei")
+ self.assertEqual(props.find("a:solidFill/a:srgbClr", NS).get("val"), color)
+ self.assertEqual(axis.find("c:majorTickMark", NS).get("val"), tick)
+ self.assertEqual(chart.find(".//c:legend/c:txPr//a:defRPr", NS).get("sz"), "1350")
+
+ def test_axis_typography_is_projected_even_when_legacy_axis_gate_passes(self) -> None:
+ payload = self._import(_source_chart())
+ self.assertEqual(payload.get("axes", {}).get("category", {}).get("font_size"), 14)
+ self.assertEqual(payload.get("axes", {}).get("value", {}).get("font_size"), 16)
+
+ def test_identical_point_fills_do_not_enable_vary_colors(self) -> None:
+ source = _source_chart()
+ plot = source.find(".//c:barChart", NS)
+ ET.SubElement(plot, f'{{{NS["c"]}}}varyColors', {"val": "0"})
+ series = plot.find("c:ser", NS)
+ paint = ET.fromstring(
+ f''
+ ''
+ )
+ series.append(paint)
+ for index in range(2):
+ point = ET.SubElement(series, f'{{{NS["c"]}}}dPt')
+ ET.SubElement(point, f'{{{NS["c"]}}}idx', {"val": str(index)})
+ point.append(ET.fromstring(ET.tostring(paint)))
+ payload = self._import(source)
+ self.assertNotIn("point_colors", payload["series"][0])
+ self.assertEqual(self._export(payload).find(".//c:varyColors", NS).get("val"), "0")
+
+ def test_line_and_marker_colors_keep_distinct_source_paints(self) -> None:
+ source = self._export({
+ "type": "line", "line_style": "lineMarker", "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "series": [{"name": "Line", "values": [1, 2]}],
+ "style": {"colors": ["#112233"]},
+ })
+ series = source.find(".//c:ser", NS)
+ series.find("c:marker/c:spPr/a:solidFill/a:srgbClr", NS).set("val", "FFAA00")
+ series.append(ET.fromstring(
+ f''
+ ''
+ ''
+ ))
+ payload = self._import(source)
+ self.assertEqual(payload["style"]["colors"], ["#112233"])
+ self.assertEqual(payload["series"][0]["point_colors"], ["#00AAFF", "#FFAA00"])
+ rebuilt = self._export(payload).find(".//c:ser", NS)
+ self.assertEqual(rebuilt.find("c:spPr/a:ln/a:solidFill/a:srgbClr", NS).get("val"), "112233")
+ self.assertEqual([node.get("val") for node in rebuilt.findall(
+ "c:dPt/c:marker/c:spPr/a:solidFill/a:srgbClr", NS,
+ )], ["00AAFF", "FFAA00"])
+
+ def test_implicit_markers_follow_source_line_color(self) -> None:
+ source = self._export({
+ "type": "line", "line_style": "lineMarker", "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "series": [{"name": "Line", "values": [1, 2]}],
+ "style": {"colors": ["#112233"]},
+ })
+ marker = source.find(".//c:ser/c:marker", NS)
+ marker.remove(marker.find("c:spPr", NS))
+ payload = self._import(source)
+ self.assertEqual(payload["style"]["colors"], ["#112233"])
+ self.assertNotIn("point_colors", payload["series"][0])
+
+ def test_combo_colors_follow_plot_order(self) -> None:
+ source = self._export({
+ "type": "combo", "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "style": {"colors": ["#112233", "#445566"]},
+ "plots": [
+ {"type": "column", "series_indices": [1], "series": [{"name": "Bars", "values": [1, 2]}]},
+ {"type": "line", "series_indices": [0], "line_style": "lineMarker",
+ "series": [{"name": "Line", "values": [2, 3]}]},
+ ],
+ })
+ payload = self._import(source)
+ self.assertEqual(payload["style"]["colors"], ["#112233", "#445566"])
+ rebuilt = self._export(payload)
+ for plot in ("barChart", "lineChart"):
+ path = f".//c:{plot}/c:ser/c:spPr/a:solidFill/a:srgbClr"
+ self.assertEqual(rebuilt.find(path, NS).get("val"), source.find(path, NS).get("val"))
+
+ def test_chart_fill_title_and_inherited_axis_style(self) -> None:
+ source = _source_chart()
+ for axis in source.findall(".//c:catAx", NS) + source.findall(".//c:valAx", NS):
+ axis.remove(axis.find("c:txPr", NS))
+ for owner, color in ((source, "EEEEEE"), (source.find(".//c:plotArea", NS), "ABCDEF")):
+ owner.append(ET.fromstring(
+ f''
+ f''
+ ))
+ title = ET.SubElement(source.find("c:chart", NS), f'{{{NS["c"]}}}title')
+ title.append(ET.fromstring(
+ f''
+ ''
+ ''
+ 'Budget'
+ ))
+ chart = self._export(self._import(source))
+ self.assertEqual(chart.find("c:spPr/a:solidFill/a:srgbClr", NS).get("val"), "EEEEEE")
+ self.assertEqual(chart.find(".//c:plotArea/c:spPr/a:solidFill/a:srgbClr", NS).get("val"), "ABCDEF")
+ self.assertEqual(chart.find(".//c:catAx/c:txPr//a:defRPr", NS).get("sz"), "900")
+ props = chart.find(".//c:title//a:rPr", NS)
+ self.assertEqual(props.get("sz"), "2100")
+ self.assertEqual(props.find("a:solidFill/a:srgbClr", NS).get("val"), "112233")
+
+ def test_combo_plot_spacing_and_series_formatting_survive(self) -> None:
+ payload = {
+ "type": "combo", "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "plots": [
+ {"type": "column", "gap_width": 0, "overlap": 0, "series": [{"name": "Bars", "values": [1, 2]}]},
+ {"type": "line", "line_style": "lineMarker", "series": [
+ {"name": "Line", "values": [2, 3], "line_width": 4, "marker_size": 12},
+ ]},
+ {"type": "area", "series": [
+ {"name": "Area", "values": [3, 4], "fill_opacity": 0.25, "line_width": 2},
+ ]},
+ ],
+ }
+ imported = self._import(self._export(payload))
+ plots = {plot["type"]: plot for plot in imported["plots"]}
+ self.assertEqual(plots["column"].get("gap_width"), 0)
+ self.assertEqual(plots["column"].get("overlap"), 0)
+ self.assertEqual(plots["line"]["series"][0].get("line_width"), 4)
+ self.assertEqual(plots["line"]["series"][0].get("marker_size"), 12)
+ self.assertEqual(plots["area"]["series"][0].get("fill_opacity"), 0.25)
+ self.assertEqual(plots["area"]["series"][0].get("line_width"), 2)
+
+ def test_equal_axis_title_text_keeps_each_axes_own_style(self) -> None:
+ source = _source_chart()
+ for tag, size, color in (("catAx", "1500", "123456"), ("valAx", "1800", "654321")):
+ axis = source.find(f".//c:{tag}", NS)
+ axis.append(ET.fromstring(
+ f''
+ ''
+ ''
+ f''
+ 'Shared title'
+ ))
+ chart = self._export(self._import(source))
+ for tag, size, color in (("catAx", "1500", "123456"), ("valAx", "1800", "654321")):
+ props = chart.find(f".//c:{tag}/c:title//a:rPr", NS)
+ self.assertEqual(props.get("sz"), size)
+ self.assertEqual(props.find("a:solidFill/a:srgbClr", NS).get("val"), color)
+ self.assertEqual(props.find("a:latin", NS).get("typeface"), "Arial")
+
+ def test_secondary_axis_style_stays_on_secondary_axis(self) -> None:
+ payload = {
+ "type": "combo", "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "plots": [
+ {"type": "column", "series": [{"name": "Bars", "values": [1, 2]}]},
+ {"type": "line", "axis": "secondary", "series": [{"name": "Line", "values": [3, 4]}]},
+ ],
+ "axes": {"secondary_value": {"font_family": "Arial", "font_size": 20, "color": "#123456"}},
+ }
+ imported = self._import(self._export(payload))
+ self.assertEqual(imported["axes"]["secondary_value"]["font_size"], 20)
+ self.assertEqual(imported["axes"]["secondary_value"]["color"], "#123456")
+ imported["plots"][0]["series"][0]["values"][0] = 10
+ chart = self._export(imported)
+ axes = chart.findall(".//c:valAx", NS)
+ right = next(axis for axis in axes if axis.find("c:axPos", NS).get("val") == "r")
+ self.assertEqual(right.find("c:txPr//a:defRPr", NS).get("sz"), "1500")
+
+ def test_point_colors_survive_for_columns_and_line_markers(self) -> None:
+ for chart_type, point_colors in (("column", ["#123456", "#ABCDEF"]),
+ ("line", [None, "#ABCDEF"])):
+ with self.subTest(chart_type=chart_type):
+ payload = {
+ "type": chart_type, "x": 100, "y": 80, "width": 600, "height": 400,
+ "categories": ["A", "B"], "series": [{
+ "name": "S", "values": [1, 2], "point_colors": point_colors,
+ }],
+ }
+ if chart_type == "line":
+ payload["series"][0]["marker_size"] = 12
+ imported = self._import(self._export(payload))
+ self.assertEqual(imported["series"][0].get("point_colors"), point_colors)
+ if chart_type == "line":
+ self.assertEqual(imported["series"][0].get("marker_size"), 12)
+
+ def test_scatter_axis_text_overrides_reach_writer(self) -> None:
+ payload = {
+ "type": "scatter", "x": 100, "y": 80, "width": 600, "height": 400,
+ "series": [{"name": "S", "x": [1, 2], "y": [2, 3]}],
+ "axes": {"x": {"font_family": "Arial", "font_size": 20,
+ "color": "#123456", "tick_marks": "cross"}},
+ }
+ chart = self._export(payload)
+ props = chart.find(".//c:valAx/c:txPr//a:defRPr", NS)
+ self.assertEqual(props.get("sz"), "1500")
+ self.assertEqual(chart.find(".//c:valAx/c:majorTickMark", NS).get("val"), "cross")
+ imported = self._import(chart)
+ self.assertEqual(imported["axes"]["x"].get("font_size"), 20)
+
+ def test_language_default_and_invalid_value(self) -> None:
+ payload = {"type": "column", "x": 0, "y": 0, "width": 400, "height": 300,
+ "categories": ["A"], "series": [{"name": "S", "values": [1]}]}
+ self.assertEqual(self._export(payload).find("c:lang", NS).get("val"), "en-US")
+ self.assertEqual(self._export(payload, primary_language="fr-FR").find("c:lang", NS).get("val"), "fr-FR")
+ for invalid in ("", 42, 'en-US"/>'):
+ with self.subTest(invalid=invalid), self.assertRaises(RuntimeError):
+ self._export({**payload, "lang": invalid})
+ with self.assertRaisesRegex(RuntimeError, "classic charts only"):
+ self._export({**payload, "type": "treemap", "values": [1], "levels": [["A"]], "lang": "zh-CN"})
+
+
+class RoundtripFrameInferenceTests(unittest.TestCase):
+ def _check(self, body: str) -> dict:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "source.pptx"
+ presentation = Presentation()
+ presentation.slides.add_slide(presentation.slide_layouts[6])
+ presentation.save(source)
+ workspace = root / "workspace"
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ convert_pptx_to_svg(source, workspace, ConvertOptions(inheritance_mode="both", roundtrip=True))
+ page = workspace / "authoring-svg-flat/slide_01.svg"
+ svg = ET.parse(page).getroot()
+ additions = ET.fromstring(f'')
+ svg.extend(additions)
+ page.write_bytes(ET.tostring(svg))
+ results = SVGQualityChecker().check_roundtrip_workspace(str(workspace))
+ self.assertEqual(len(results), 1)
+ return results[0]
+
+ def test_group_bounds_prevent_unrelated_root_decoration_frame(self) -> None:
+ result = self._check(
+ ''
+ ''
+ 'Caption'
+ )
+ findings = result["errors"] + result["warnings"]
+ self.assertFalse(any("owning frame" in item for item in findings), findings)
+
+ def test_no_frame_does_not_borrow_ancestor_decoration_or_request_metadata(self) -> None:
+ result = self._check(
+ ''
+ 'Caption'
+ )
+ findings = result["errors"] + result["warnings"]
+ self.assertFalse(any("exceeds owning frame" in item for item in findings), findings)
+ self.assertTrue(any("Cannot verify owning frame" in item for item in findings), findings)
+ self.assertFalse(any("add data-pptx-frame" in item or "keep data-pptx-frame" in item for item in findings))
+
+ def test_group_bounds_still_report_real_overflow(self) -> None:
+ result = self._check(
+ ''
+ 'Long caption'
+ )
+ self.assertTrue(any("exceeds owning frame" in item for item in result["errors"]), result)
+
+ def test_direct_rect_frame_requires_text_anchor_inside(self) -> None:
+ result = self._check(
+ ''
+ ''
+ 'Caption'
+ )
+ self.assertFalse(any("owning frame" in item for item in result["errors"] + result["warnings"]), result)
+
+ def test_real_sibling_frame_overflow_stays_advisory(self) -> None:
+ result = self._check(
+ ''
+ 'Long caption text'
+ )
+ self.assertFalse(result["errors"], result)
+ self.assertTrue(any('exceeds owning frame ' in item for item in result["warnings"]), result)
+ self.assertFalse(any("add data-pptx-frame" in item for item in result["warnings"]), result)
+
+
+class ChartThemeFontTokenTests(unittest.TestCase):
+ def test_theme_tokens_keep_their_own_slots(self) -> None:
+ from svg_to_pptx.native_objects.chart_style import _font_face_xml
+
+ self.assertEqual(
+ _font_face_xml("+mn-lt, +mn-ea"),
+ '',
+ )
+ self.assertEqual(
+ _font_face_xml("+mn-ea"),
+ '',
+ )
+ self.assertIn('', _font_face_xml("Arial, +mj-ea"))
+ self.assertIn('', _font_face_xml("Arial, +mj-ea"))
+
+ def test_named_stacks_are_unchanged(self) -> None:
+ from svg_to_pptx.native_objects.chart_style import _font_face_xml
+
+ self.assertEqual(
+ _font_face_xml("Arial, Microsoft YaHei"),
+ '',
+ )
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_inplace.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_inplace.py
new file mode 100644
index 00000000..1a38cabf
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_chart_inplace.py
@@ -0,0 +1,347 @@
+#!/usr/bin/env python3
+"""
+PPT Master - Imported Chart In-place Data Regression Tests
+
+Exercise public import, checker and round-trip export on synthetic native charts
+with source-only formatting and embedded workbook data.
+
+Usage:
+ python3 -m unittest tests.test_chart_inplace
+
+Examples:
+ Run the module from the scripts directory.
+
+Dependencies:
+ python-pptx and the local importer, checker and exporter.
+"""
+
+from __future__ import annotations
+
+import copy
+import io
+import json
+import posixpath
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from xml.etree import ElementTree as ET
+from zipfile import ZipFile
+
+from pptx import Presentation
+from pptx.chart.data import CategoryChartData
+from pptx.enum.chart import XL_CHART_TYPE
+from pptx.util import Inches
+
+from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
+from svg_quality.checker import SVGQualityChecker
+from svg_to_pptx.pptx_package.cli import main as export_main
+
+
+NS = {
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
+ "c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
+ "p": "http://schemas.openxmlformats.org/presentationml/2006/main",
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ "s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "svg": "http://www.w3.org/2000/svg",
+}
+REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
+CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
+
+
+class ChartInplaceTests(unittest.TestCase):
+ def setUp(self) -> None:
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.workspace = self.root / "workspace"
+
+ def _import(self, *, values=(12, 34), shared_slide: bool = False, named: bool = True) -> None:
+ presentation = Presentation()
+ slide = presentation.slides.add_slide(presentation.slide_layouts[6])
+ data = CategoryChartData()
+ data.categories = ["First", "Second"]
+ data.add_series("Revenue", values)
+ frame = slide.shapes.add_chart(
+ XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(1), Inches(1), Inches(6), Inches(4), data,
+ )
+ if shared_slide:
+ second = presentation.slides.add_slide(presentation.slide_layouts[6])
+ clone = copy.deepcopy(frame._element)
+ rel_id = second.part.relate_to(frame.chart.part, NS["r"] + "/chart")
+ clone.find(".//c:chart", NS).set(f'{{{NS["r"]}}}id', rel_id)
+ second.shapes._spTree.insert_element_before(clone, "p:extLst")
+ original = self.root / "original.pptx"
+ presentation.save(original)
+ with ZipFile(original) as archive:
+ parts = {name: archive.read(name) for name in archive.namelist()}
+ chart = ET.fromstring(parts["ppt/charts/chart1.xml"])
+ series = chart.find(".//c:ser", NS)
+ point = ET.fromstring(
+ f''
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ series.insert(3, point)
+ series.insert(3, copy.deepcopy(point.find("c:spPr", NS)))
+ legend = ET.fromstring(
+ f''
+ ''
+ ''
+ ''
+ ''
+ )
+ chart.find("c:chart", NS).append(legend)
+ chart.insert(0, ET.Element(f'{{{NS["c"]}}}style', {"val": "10"}))
+ extension = ET.SubElement(ET.SubElement(chart, f'{{{NS["c"]}}}extLst'), f'{{{NS["c"]}}}ext',
+ {"uri": "{synthetic-preserved-extension}"})
+ ET.SubElement(extension, "{urn:chart-test}opaque", {"keep": "exact"}).text = "extension data"
+ for value in chart.findall(".//c:formatCode", NS):
+ value.text = "#,##0.00"
+ if not named:
+ series.remove(series.find("c:tx", NS))
+ # Serialize once before import: these are the exact bytes export must retain.
+ parts["ppt/charts/chart1.xml"] = ET.tostring(chart, encoding="utf-8", xml_declaration=True)
+ relationships = ET.fromstring(parts["ppt/charts/_rels/chart1.xml.rels"])
+ types = ET.fromstring(parts["[Content_Types].xml"])
+ for rid, filename, kind, content_type, content in (
+ ("rStyle", "style1.xml", "http://schemas.microsoft.com/office/2011/relationships/chartStyle",
+ "application/vnd.ms-office.chartstyle+xml",
+ ''),
+ ("rColors", "colors1.xml", "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle",
+ "application/vnd.ms-office.chartcolorstyle+xml",
+ ''),
+ ("rTheme", "../theme/themeOverride1.xml", NS["r"] + "/themeOverride",
+ "application/vnd.openxmlformats-officedocument.themeOverride+xml",
+ f''),
+ ):
+ name = posixpath.normpath(posixpath.join("ppt/charts", filename))
+ parts[name] = content.encode()
+ ET.SubElement(relationships, f"{{{REL_NS}}}Relationship", {"Id": rid, "Type": kind, "Target": filename})
+ ET.SubElement(types, f"{{{CT_NS}}}Override", {"PartName": "/" + name, "ContentType": content_type})
+ parts["ppt/charts/_rels/chart1.xml.rels"] = ET.tostring(relationships)
+ parts["[Content_Types].xml"] = ET.tostring(types)
+ self.source = parts
+ source = self.root / "source.pptx"
+ with ZipFile(source, "w") as archive:
+ for name, content in parts.items():
+ archive.writestr(name, content)
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ convert_pptx_to_svg(source, self.workspace, ConvertOptions(inheritance_mode="both", roundtrip=True))
+ self.path = self.workspace / "authoring-svg-flat" / "slide_01.svg"
+ self.svg = ET.parse(self.path).getroot()
+ self.marker = next(node for node in self.svg.iter() if node.get("data-pptx-replace-with") == "chart")
+ self.metadata = self.marker.find('svg:metadata[@type="application/json"]', NS)
+ self.payload = json.loads(self.metadata.text)
+
+ def _save(self) -> None:
+ if json.loads(self.metadata.text) != self.payload:
+ self.metadata.text = json.dumps(self.payload, ensure_ascii=False)
+ self.path.write_bytes(ET.tostring(self.svg))
+
+ def _check(self) -> list:
+ self._save()
+ checker = SVGQualityChecker()
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ results = checker.check_roundtrip_workspace(str(self.workspace))
+ self.assertTrue(results)
+ self.assertEqual(results[0]["errors"], [])
+ return results[0]["warnings"]
+
+ def _export(self) -> dict[str, bytes]:
+ self._save()
+ log = io.StringIO()
+ target = self.root / "result.pptx"
+ with redirect_stdout(log), redirect_stderr(log):
+ status = export_main([str(self.workspace), "--roundtrip", "--native-charts-and-tables", "-o", str(target)])
+ self.assertEqual(status, 0, log.getvalue())
+ self.log = log.getvalue()
+ with ZipFile(target) as archive:
+ return {name: archive.read(name) for name in archive.namelist()}
+
+ def _live_chart(self, parts: dict[str, bytes], slide: int = 1) -> tuple[str, ET.Element]:
+ page = ET.fromstring(parts[f"ppt/slides/slide{slide}.xml"])
+ rel_id = page.find(".//c:chart", NS).get(f'{{{NS["r"]}}}id')
+ rels = ET.fromstring(parts[f"ppt/slides/_rels/slide{slide}.xml.rels"])
+ target = next(rel.get("Target") for rel in rels if rel.get("Id") == rel_id)
+ name = posixpath.normpath(posixpath.join("ppt/slides", target))
+ return name, ET.fromstring(parts[name])
+
+ def _workbook(self, parts: dict[str, bytes], chart_part: str) -> dict[str, bytes]:
+ rels_name = posixpath.join(posixpath.dirname(chart_part), "_rels", posixpath.basename(chart_part) + ".rels")
+ rels = ET.fromstring(parts[rels_name])
+ target = next(rel.get("Target") for rel in rels if rel.get("Type").endswith("/package"))
+ name = posixpath.normpath(posixpath.join(posixpath.dirname(chart_part), target))
+ with ZipFile(io.BytesIO(parts[name])) as workbook:
+ return {name: workbook.read(name) for name in workbook.namelist()}
+
+ @staticmethod
+ def _cells(workbook: dict[str, bytes]) -> dict:
+ strings = ET.fromstring(workbook["xl/sharedStrings.xml"])
+ values = ["".join(node.itertext()) for node in strings.findall("s:si", NS)]
+ cells = {}
+ for cell in ET.fromstring(workbook["xl/worksheets/sheet1.xml"]).findall(".//s:c", NS):
+ value = cell.find("s:v", NS)
+ text = value.text if value is not None else None
+ cells[cell.get("r")] = values[int(text)] if cell.get("t") == "s" else text
+ return cells
+
+ def test_data_only_preserves_chart_bytes_and_dependency_graph(self) -> None:
+ self._import(shared_slide=True)
+ self.payload["series"][0].update(name="Income & costs", values=[21, 43])
+ self.payload["categories"] = ["新类别", "Second"]
+ self.assertEqual(self._check(), [])
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ # All differences are in existing cache text; namespaces, gradient, legend,
+ # c:style and extLst are not reserialized.
+ expected = self.source["ppt/charts/chart1.xml"]
+ for old, new in ((b">12<", b">21<"), (b">34<", b">43<"), (b">First<", ">新类别<".encode()),
+ (b">Revenue<", b">Income & costs<")):
+ expected = expected.replace(old, new)
+ self.assertEqual(parts[name], expected)
+ self.assertNotEqual(name, "ppt/charts/chart1.xml")
+ rels_name = posixpath.join(posixpath.dirname(name), "_rels", posixpath.basename(name) + ".rels")
+ for rel in ET.fromstring(parts[rels_name]):
+ target = posixpath.normpath(posixpath.join(posixpath.dirname(name), rel.get("Target")))
+ self.assertIn(target, parts)
+ if rel.get("Id") in {"rStyle", "rColors", "rTheme"}:
+ old = next(item.get("Target") for item in ET.fromstring(self.source["ppt/charts/_rels/chart1.xml.rels"])
+ if item.get("Id") == rel.get("Id"))
+ self.assertEqual(parts[target], self.source[posixpath.normpath(posixpath.join("ppt/charts", old))])
+ workbook = self._workbook(parts, name)
+ self.assertEqual(self._cells(workbook), {"B1": "Income & costs", "A2": "新类别", "B2": "21",
+ "A3": "Second", "B3": "43"})
+ self.assertEqual(chart.find(".//c:formatCode", NS).text, "#,##0.00")
+ self.assertEqual(self._live_chart(parts, 2)[0], "ppt/charts/chart1.xml")
+ self.assertEqual(parts["ppt/charts/chart1.xml"], self.source["ppt/charts/chart1.xml"])
+ original_workbook = self._workbook(self.source, "ppt/charts/chart1.xml")
+ for part, data in original_workbook.items():
+ if part not in {"xl/worksheets/sheet1.xml", "xl/sharedStrings.xml"}:
+ self.assertEqual(workbook[part], data)
+
+ def test_series_change_rebuilds_and_warns_with_import_diagnostics(self) -> None:
+ self._import()
+ self.payload["series"].append({"name": "Added", "values": [7, 8]})
+ warning = "\n".join(self._check())
+ self.assertIn("native-chart-rebuilt", warning)
+ self.assertIn("chart-series-style-normalized", warning)
+ self.assertIn("chart-legend-entry-font-size-omitted", warning)
+ self.assertIn(self.marker.get("data-pptx-source-ref"), warning)
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ self.assertNotIn("_data_", name)
+ self.assertEqual(len(chart.findall(".//c:ser", NS)), 2)
+ self.assertIn("native-chart-rebuilt", self.log)
+
+ def test_non_data_field_change_does_not_patch(self) -> None:
+ self._import()
+ self.payload["style"]["colors"] = ["#ABCDEF"]
+ self.payload["series"][0]["values"] = [21, 43]
+ self.assertIn("native-chart-rebuilt", "\n".join(self._check()))
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ self.assertNotIn("_data_", name)
+ self.assertIsNone(chart.find(".//a:gradFill", NS))
+
+ def test_unchanged_chart_is_exact_passthrough(self) -> None:
+ self._import()
+ # Semantically unchanged authoring must restore the original package.
+ parts = self._export()
+ name, _chart = self._live_chart(parts)
+ self.assertEqual(name, "ppt/charts/chart1.xml")
+ self.assertEqual(parts[name], self.source[name])
+ self.assertEqual(self._workbook(parts, name), self._workbook(self.source, name))
+ self.assertNotIn("native-chart-rebuilt", self.log)
+
+ def test_json_whitespace_change_keeps_exact_source_chart(self) -> None:
+ self._import()
+ self.metadata.text = json.dumps(self.payload, ensure_ascii=False, indent=2)
+ parts = self._export()
+ name, _chart = self._live_chart(parts)
+ self.assertEqual(name, "ppt/charts/chart1.xml")
+ self.assertEqual(parts[name], self.source[name])
+ self.assertNotIn("native-chart-rebuilt", self.log)
+
+ def test_point_count_change_rebuilds_with_warning(self) -> None:
+ self._import()
+ self.payload["categories"].append("Third")
+ self.payload["series"][0]["values"].append(56)
+ self.payload["series"][0].pop("point_colors", None)
+ self.assertIn("native-chart-rebuilt", "\n".join(self._check()))
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ self.assertNotIn("_data_", name)
+ self.assertEqual(chart.find(".//c:val/c:numRef/c:numCache/c:ptCount", NS).get("val"), "3")
+
+ def test_name_edit_adds_missing_series_text_without_rebuilding(self) -> None:
+ self._import(named=False)
+ self.payload["series"][0]["name"] = "Named now"
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ self.assertIn("_data_", name)
+ self.assertEqual(chart.find(".//c:ser/c:tx/c:v", NS).text, "Named now")
+ self.assertIsNotNone(chart.find(".//c:dPt/c:spPr/a:gradFill", NS))
+
+ def test_ancestor_move_with_data_edit_uses_rebuild(self) -> None:
+ self._import()
+ parent = next(node for node in self.svg.iter() if self.marker in list(node))
+ wrapper = ET.Element(f'{{{NS["svg"]}}}g', {"transform": "translate(20 10)"})
+ parent.insert(list(parent).index(self.marker), wrapper)
+ parent.remove(self.marker)
+ wrapper.append(self.marker)
+ self.payload["series"][0]["values"] = [21, 43]
+ self.assertIn("native-chart-rebuilt", "\n".join(self._check()))
+
+ def test_null_gap_updates_cache_and_blank_workbook_cell(self) -> None:
+ self._import()
+ self.payload["series"][0]["values"] = [None, 43]
+ self.assertEqual(self._check(), [])
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ cache = chart.find(".//c:val/c:numRef/c:numCache", NS)
+ self.assertEqual(cache.find("c:ptCount", NS).get("val"), "2")
+ self.assertEqual([point.get("idx") for point in cache.findall("c:pt", NS)], ["1"])
+ self.assertIsNone(self._cells(self._workbook(parts, name))["B2"])
+
+ def test_fill_imported_gap_updates_missing_cell(self) -> None:
+ self._import(values=(None, 34))
+ self.payload["series"][0]["values"] = [21, 34]
+ parts = self._export()
+ name, chart = self._live_chart(parts)
+ self.assertEqual({point.get("idx"): point.find("c:v", NS).text
+ for point in chart.findall(".//c:val/c:numRef/c:numCache/c:pt", NS)},
+ {"0": "21", "1": "34"})
+ self.assertEqual(self._cells(self._workbook(parts, name))["B2"], "21")
+
+ def test_shared_string_edit_does_not_change_unrelated_cell(self) -> None:
+ self._import()
+ # Reuse an existing shared string through a data-only edit, then change
+ # the category in a fresh import; the unchanged series name must survive.
+ self.payload["categories"][0] = "Revenue"
+ parts = self._export()
+ chart_part, _ = self._live_chart(parts)
+ cells = self._cells(self._workbook(parts, chart_part))
+ self.assertEqual(cells["B1"], "Revenue")
+ self.assertEqual(cells["A2"], "Revenue")
+ # Use the exported PPTX as the next round's immutable source.
+ next_workspace = self.root / "next"
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+ convert_pptx_to_svg(self.root / "result.pptx", next_workspace,
+ ConvertOptions(inheritance_mode="both", roundtrip=True))
+ self.workspace = next_workspace
+ self.path = next_workspace / "authoring-svg-flat" / "slide_01.svg"
+ self.svg = ET.parse(self.path).getroot()
+ self.marker = next(node for node in self.svg.iter() if node.get("data-pptx-replace-with") == "chart")
+ self.metadata = self.marker.find('svg:metadata[@type="application/json"]', NS)
+ self.payload = json.loads(self.metadata.text)
+ self.payload["categories"][0] = "Changed"
+ parts = self._export()
+ name, _ = self._live_chart(parts)
+ cells = self._cells(self._workbook(parts, name))
+ self.assertEqual(cells["B1"], "Revenue")
+ self.assertEqual(cells["A2"], "Changed")
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_table_parity.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_table_parity.py
index bb3a145e..dbf9f7e7 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_table_parity.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_chart_table_parity.py
@@ -28,7 +28,7 @@ from svg_to_pptx.native_objects.table import ( # noqa: E402
)
from semantic_table import expand_semantic_table_payload # noqa: E402
from svg_to_pptx.pptx_package.cli import ( # noqa: E402
- _native_object_projection_findings,
+ _native_object_preflight_findings,
)
from svg_quality.checker import SVGQualityChecker # noqa: E402
@@ -111,6 +111,7 @@ class NativeTableMergeTests(unittest.TestCase):
{},
{"text": ""},
{"merge_continuation": True},
+ {"fill": "#F1F4F8"},
):
with self.subTest(covered_cell=covered_cell):
_payload, rows, col_count, merge_layout = (
@@ -122,7 +123,6 @@ class NativeTableMergeTests(unittest.TestCase):
for covered_cell in (
{"text": "authored"},
{"paragraphs": [{"text": ""}]},
- {"fill": "#F1F4F8"},
):
with self.subTest(covered_cell=covered_cell):
with self.assertRaisesRegex(RuntimeError, "must be blank"):
@@ -357,7 +357,8 @@ class NativeProjectionCheckerTests(unittest.TestCase):
for filename in self.EXPECTED_FINDINGS
]
- findings = _native_object_projection_findings(fixture_paths)
+ payload_findings, findings = _native_object_preflight_findings(fixture_paths)
+ self.assertFalse(payload_findings)
findings_by_page: dict[str, str] = {}
for filename, _marker_id, warning in findings:
findings_by_page.setdefault(filename, "")
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_table_merges.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_table_merges.py
new file mode 100644
index 00000000..006a8d57
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_native_table_merges.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""PPT Master - Native Merged Table Regression Tests
+
+Exercise covered-cell formatting and authority-aware errors through the public
+SVG converter and export CLI using small inline fixtures.
+
+Usage:
+ python3 -m unittest tests.test_native_table_merges
+
+Examples:
+ Run the module from the scripts directory.
+
+Dependencies:
+ Standard library and the local SVG-to-PPTX converter.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
+from svg_to_pptx.pptx_package.cli import main
+
+
+NS = {
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
+ "s": "http://www.w3.org/2000/svg",
+}
+
+
+class NativeTableMergeTests(unittest.TestCase):
+ def setUp(self) -> None:
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.source = self.root / "input"
+ self.source.mkdir()
+ self.svg_path = self.source / "slide_01.svg"
+
+ @staticmethod
+ def _payload(covered: object, **extra: object) -> dict:
+ return {
+ "schema": "ppt-master.semantic-table.v2",
+ "x": 20, "y": 20, "width": 400, "height": 160,
+ "header_rows": 0,
+ "rows": [
+ [{"text": "Owner", "row_span": 2, "col_span": 2}, covered],
+ [covered, covered],
+ ],
+ **extra,
+ }
+
+ def _write_svg(
+ self, payload: dict, *, imported: bool = False, json_first: bool = True,
+ ) -> ET.Element:
+ root = ET.Element(f'{{{NS["s"]}}}svg', {
+ "viewBox": "0 0 1280 720", "lang": "en-US",
+ })
+ marker = ET.SubElement(root, f'{{{NS["s"]}}}g', {
+ "id": "shape-60", "data-pptx-replace-with": "table",
+ })
+ if json_first:
+ marker.set("data-pptx-native-authority", "json")
+ if imported:
+ marker.set("data-pptx-import-source", "pptx")
+ ET.SubElement(marker, f'{{{NS["s"]}}}metadata', {
+ "type": "application/json",
+ }).text = json.dumps(payload)
+ self.svg_path.write_bytes(ET.tostring(root))
+ return root
+
+ def _export(self, payload: dict, *, imported: bool = False) -> list[list[ET.Element]]:
+ self._write_svg(payload, imported=imported)
+ xml, *_rest = convert_svg_to_slide_shapes(
+ self.svg_path, resource_root=self.root, native_objects=True,
+ )
+ table = ET.fromstring(xml).find(".//a:tbl", NS)
+ self.assertIsNotNone(table)
+ return [row.findall("a:tc", NS) for row in table.findall("a:tr", NS)]
+
+ def _run_cli(self) -> tuple[int, str]:
+ stderr = io.StringIO()
+ with redirect_stdout(io.StringIO()), redirect_stderr(stderr):
+ result = main([
+ str(self.root), "--source", "input", "--native-charts-and-tables",
+ "-o", str(self.root / "output.pptx"),
+ ])
+ return result, stderr.getvalue()
+
+ def test_covered_formatting_reaches_tcpr_with_or_without_continuation_marker(self) -> None:
+ formatting = {
+ "fill": "#35778F", "fill_opacity": 0.65,
+ "borders": {
+ "left": {"style": "solid", "color": "#FFFFFF", "width": 3},
+ "right": {"style": "none"},
+ "diagonal_down": {"style": "solid", "color": "#112233", "width": 2},
+ },
+ "padding": {"left": 4, "right": 5, "top": 6, "bottom": 7},
+ "valign": "bottom", "anchor_center": True, "horizontal_overflow": "clip",
+ "color": "#112233", "font_size": 16, "bold": True, "align": "r", "lang": "en-US",
+ }
+ for imported in (False, True):
+ for named_style in (False, True):
+ for continuation in (False, True):
+ with self.subTest(imported=imported, named_style=named_style, continuation=continuation):
+ covered = {"text": ""}
+ if continuation:
+ covered["merge_continuation"] = True
+ if named_style:
+ covered["cell_style"] = "covered"
+ else:
+ covered.update(formatting)
+ payload = self._payload(
+ covered,
+ defaults={"cell": {"fill": "#000000", "padding": {"left": 1}}},
+ cell_styles={"covered": formatting} if named_style else {},
+ )
+ rows = self._export(payload, imported=imported)
+ self.assertEqual(rows[0][0].attrib, {"rowSpan": "2", "gridSpan": "2"})
+ for row, col, attrs in (
+ (0, 1, {"rowSpan": "2", "hMerge": "1"}),
+ (1, 0, {"gridSpan": "2", "vMerge": "1"}),
+ (1, 1, {"hMerge": "1", "vMerge": "1"}),
+ ):
+ cell = rows[row][col]
+ self.assertEqual(cell.attrib, attrs)
+ self.assertFalse(cell.findall("a:txBody//a:t", NS))
+ props = cell.find("a:tcPr", NS)
+ self.assertEqual(props.attrib, {
+ "anchor": "b", "anchorCtr": "1", "horzOverflow": "clip",
+ "marL": "38100", "marR": "47625", "marT": "57150", "marB": "66675",
+ })
+ fill = props.find("a:solidFill/a:srgbClr", NS)
+ self.assertEqual(fill.get("val"), "35778F")
+ self.assertEqual(fill.find("a:alpha", NS).get("val"), "65000")
+ self.assertEqual(props.find("a:lnL", NS).get("w"), "28575")
+ self.assertEqual(props.find("a:lnL/a:solidFill/a:srgbClr", NS).get("val"), "FFFFFF")
+ self.assertIsNotNone(props.find("a:lnR/a:noFill", NS))
+ self.assertEqual(props.find("a:lnTlToBr", NS).get("w"), "19050")
+
+ def test_blank_forms_keep_defaults_and_authored_merge_topology(self) -> None:
+ for covered in (None, "", {}, {"text": None}, {"text": ""}, {"merge_continuation": True}):
+ with self.subTest(covered=covered):
+ rows = self._export(self._payload(covered, defaults={"cell": {"fill": "#ABCDEF"}}))
+ self.assertEqual([node.text for node in rows[0][0].findall(".//a:t", NS)], ["Owner"])
+ for cell in (rows[0][1], rows[1][0], rows[1][1]):
+ self.assertEqual(cell.find("a:tcPr/a:solidFill/a:srgbClr", NS).get("val"), "ABCDEF")
+ self.assertFalse(cell.findall(".//a:t", NS))
+
+ def test_covered_text_and_paragraphs_still_fail(self) -> None:
+ for covered in (
+ {"text": "hidden", "fill": "#35778F", "merge_continuation": True},
+ {"text": " "}, {"text": 0}, {"text": []},
+ {"paragraphs": [{"text": "hidden"}]},
+ {"paragraphs": [{"runs": [{"text": "hidden"}]}]},
+ {"paragraphs": [""]}, {"merge_continuation": False},
+ ):
+ with self.subTest(covered=covered):
+ with self.assertRaisesRegex(RuntimeError, "merge-covered cell R1C2 must be blank"):
+ self._export(self._payload(covered))
+
+ def test_covered_formatting_keeps_schema_and_merge_validation(self) -> None:
+ for covered, message in (
+ ({"unknown": "value"}, "unsupported field"),
+ ({"cell_style": "missing"}, "unknown cell_style"),
+ ({"fill_opacity": 2}, "fill_opacity"),
+ ({"borders": {"left": {"style": "solid", "color": "#FFFFFF", "width": 0}}}, "width"),
+ ({"col_span": 1}, "overlaps"),
+ ):
+ with self.subTest(covered=covered):
+ with self.assertRaisesRegex(RuntimeError, message):
+ self._export(self._payload(covered))
+
+ def test_json_first_validation_errors_do_not_recommend_svg_fallback(self) -> None:
+ for imported in (False, True):
+ with self.subTest(imported=imported):
+ self._write_svg(self._payload({"text": "hidden"}), imported=imported)
+ result, stderr = self._run_cli()
+ self.assertEqual(result, 1, stderr)
+ self.assertIn("JSON-first native payload validation failed", stderr)
+ self.assertIn("slide_01.svg: shape-60:", stderr)
+ self.assertIn("merge-covered cell R1C2 must be blank", stderr)
+ self.assertIn("Correct the reported JSON payload", stderr)
+ for incorrect in ("SVG-first", "projection validation", "remove the active", "Native-ready=no"):
+ self.assertNotIn(incorrect, stderr)
+
+ def test_svg_first_validation_keeps_projection_guidance(self) -> None:
+ self._write_svg(self._payload({"text": "hidden"}), json_first=False)
+ result, stderr = self._run_cli()
+ self.assertEqual(result, 1, stderr)
+ self.assertIn("SVG-first fallback", stderr)
+ self.assertIn("projection validation failed", stderr)
+ self.assertIn("Native-ready=no", stderr)
+ self.assertNotIn("JSON-first native payload validation failed", stderr)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md
index 715774b9..9d0e57fd 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/templates/spec_lock_reference.md
@@ -20,7 +20,7 @@ After Generate Step 4 Gate 1, read the completed Design Spec and current page/re
| `visual_style` | `visual_style` | Preset or `custom` |
| `colors` | Stable semantic color roles | Core identity and recurring roles only, including `secondary_text` and `divider`; contextual paints need no row; `image_rendering` only for AI images |
| `typography` | `font_family`, `body`, `title` | Core family/size anchors; new locks also write `title_family` and `body_family`; sizes are unitless px |
-| `icons` | `library`, `inventory` | `library` is the primary bundled style or `none`; `simple-icons/*` may be prepared alone or alongside it; `inventory` indexes the curated synced pool, not page usage or every usable project icon; `stroke_width` conditional |
+| `icons` | `library`, `inventory` | `library` is the primary bundled style or `none`; `simple-icons/*` may be prepared alone or alongside it; `inventory` indexes the curated synced pool, not page usage or every usable project icon, and is `none` when no icon is prepared; `stroke_width` conditional |
| `page_rhythm` | One `P` row per page | `anchor`, `dense`, `breathing` |
| `pptx_structure` | `mode` | `flat`, `structured` |
| `forbidden` | Literal list items | The technical baseline rows stay untagged; every other row is a prohibition the user stated in their own words (request, chat, `image_notes`), quoted verbatim and ending with `(user)`; nothing else enters — general standards stay in their owning reference, a template's rules stay in its installed spec, and a confirmed `visual_style_behavior` binds as identity prose without becoming a lock row |
diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json
index 6219e2dc..7fd7a7e9 100644
--- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
- "commit": "3d98900e9b73c45f035f8109e9d41ba6a51685e7",
+ "commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
- "syncedAt": "2026-10-23T16:00:00Z"
+ "syncedAt": "2026-10-24T16:00:00Z"
}