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 Alipay QR Code -> 🎁 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"") + 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"") + 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))}'.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}".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"".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}').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'{body}') + 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" }