Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-10-25 00:02:43 +08:00
1 parent e224150d81
commit e1981742ba
24 files changed
+2054 -219

No files matched your search

+8 -8
View File
@@ -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",
@@ -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"
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-10-23T16:02:55Z"
"syncedAt": "2026-10-24T16:02:42Z"
}
@@ -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"
}
+3 -3
View File
@@ -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
<img src="docs/assets/alipay-qr.jpg" alt="Alipay QR Code" width="220" />
> 🎁 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).
@@ -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"
}
@@ -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],
)
@@ -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.
@@ -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 `<text>` 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 <directory>` 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 <effect>` 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 <effect>` 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
@@ -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 = [
@@ -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."
}
]
@@ -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,
@@ -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"
@@ -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": "&#13;"}).encode("utf-8")
span = self.spans[node]
if span.empty:
self.replace(node, self.data[span.start:span.close] + b">" + data + b"</" + span.name + b">")
else:
self.edits.append((span.start_end, span.close, data))
def attributes(self, node: ET.Element, values: dict[str, str | None]) -> None:
span = self.spans[node]
start = self.data[span.start:span.start_end]
for key, value in values.items():
pattern = re.compile(rb"\s+" + re.escape(key.encode()) + rb'''\s*=\s*(?:"[^"]*"|'[^']*')''')
replacement = b"" if value is None else (
b" " + key.encode() + b'="' + escape(value, {'"': "&quot;"}).encode("utf-8") + b'"'
)
if pattern.search(start):
start = pattern.sub(lambda _match: replacement, start, count=1)
elif value is not None:
offset = -2 if span.empty else -1
start = start[:offset] + replacement + start[offset:]
self.edits.append((span.start, span.start_end, start))
def append(self, node: ET.Element, data: bytes) -> None:
span = self.spans[node]
if span.empty:
self.replace(node, self.data[span.start:span.close] + b">" + data + b"</" + span.name + b">")
else:
self.edits.append((span.close, span.close, data))
def render(self) -> bytes:
result: list[bytes] = []
cursor = 0
for start, end, data in sorted(self.edits, key=lambda edit: (edit[0], edit[1])):
if start < cursor:
raise RuntimeError("Overlapping chart XML data edits")
result.extend((self.data[cursor:start], data))
cursor = end
result.append(self.data[cursor:])
patched = b"".join(result)
ET.fromstring(patched)
return patched
def _data_shape(payload: dict[str, Any]) -> dict[str, Any]:
"""Mask only data leaves, retaining field presence, counts and all options."""
result = copy.deepcopy(payload)
for plot in [result, *result.get("plots", [])]:
if isinstance(plot.get("categories"), list):
plot["categories"] = [None] * len(plot["categories"])
for series in plot.get("series", []):
if "name" in series:
series["name"] = None
for key in ("values", "x", "y", "sizes"):
if isinstance(series.get(key), list):
series[key] = [None] * len(series[key])
return result
def chart_edit_is_data_only(baseline: dict[str, Any], edited: dict[str, Any]) -> bool:
"""Require the same plot/series topology and every non-data JSON field."""
for plot in [edited, *edited.get("plots", [])]:
for series in plot.get("series", []):
if not isinstance(series.get("name", ""), str):
return False
for key in ("values", "x", "y", "sizes"):
if any(value is not None and (type(value) not in {int, float} or not math.isfinite(value))
for value in series.get(key, [])):
return False
return baseline != edited and _data_shape(baseline) == _data_shape(edited)
def _range_cells(formula: str) -> tuple[str, list[str]]:
match = _RANGE_RE.fullmatch(formula)
if match is None:
raise RuntimeError(f"Cannot patch chart workbook range {formula!r}; use a single A1 row or column range")
quoted, plain, col, row, last_col, last_row = match.groups()
sheet = quoted.replace("''", "'") if quoted is not None else plain
last_col, last_row = last_col or col, last_row or row
def number(label):
value = 0
for char in label:
value = value * 26 + ord(char) - ord("A") + 1
return value
def label(value):
result = ""
while value:
value, digit = divmod(value - 1, 26)
result = chr(ord("A") + digit) + result
return result
c1, c2, r1, r2 = number(col), number(last_col), int(row), int(last_row)
if c2 < c1 or r2 < r1 or (c1 != c2 and r1 != r2):
raise RuntimeError(f"Cannot patch non-linear chart workbook range {formula!r}")
if c2 > 16384 or r2 > 1048576 or max(c2 - c1, r2 - r1) > 1_000_000:
raise RuntimeError(f"Chart workbook range exceeds worksheet bounds: {formula!r}")
return sheet, [f"{label(c)}{r}" for r in range(r1, r2 + 1) for c in range(c1, c2 + 1)]
def _patch_cache(
xml: _XmlPatch, owner: ET.Element, before: list, after: list,
cells: dict[tuple[str, str], Any],
) -> None:
cache = next((node for node in owner.iter() if node.tag in {
f'{{{NS["c"]}}}{name}' for name in ("strCache", "numCache", "strLit", "numLit")
}), None)
if cache is None:
raise RuntimeError("Imported chart has no patchable data cache; reimport a chart with cached values")
formula = owner.find(".//c:f", NS)
if formula is not None:
sheet, addresses = _range_cells(formula.text or "")
if len(addresses) != len(after):
raise RuntimeError("Chart cache count differs from its workbook range; repair the source chart")
for address, old, value in zip(addresses, before, after):
key = (sheet, address)
if key in cells and cells[key][1] != value:
raise RuntimeError(f"Conflicting chart data for workbook cell {sheet}!{address}")
cells[key] = (old, value)
if before == after:
return
if cache.tag in {f'{{{NS["c"]}}}numCache', f'{{{NS["c"]}}}numLit'}:
try:
valid = all(value is None or math.isfinite(float(value)) for value in after)
except (ValueError, TypeError, OverflowError):
valid = False
if not valid:
raise RuntimeError("Source chart uses a numeric cache; retain numeric category labels")
count = cache.find("c:ptCount", NS)
if count is None:
raise RuntimeError("Imported chart cache has no ptCount; repair the source chart")
if count.get("val") != str(len(after)):
xml.attributes(count, {"val": str(len(after))})
points = {int(node.get("idx", "-1")): node for node in cache.findall("c:pt", NS)}
prefix = xml.spans[cache].name.rsplit(b":", 1)[0] + b":" if b":" in xml.spans[cache].name else b""
additions: dict[int, list[bytes]] = {}
for index, (old, value) in enumerate(zip(before, after)):
if old == value:
continue
point = points.get(index)
if value is None:
if point is not None:
xml.replace(point, b"")
elif point is not None:
leaf = point.find("c:v", NS)
if leaf is None:
raise RuntimeError("Imported chart point has no value; repair the source chart")
xml.text(leaf, value)
else:
tag = prefix.decode()
following = [xml.spans[node].start for idx, node in points.items() if idx > index]
anchor = min(following) if following else max(
(xml.spans[node].end for node in points.values()), default=xml.spans[count].end,
)
additions.setdefault(anchor, []).append(
f'<{tag}pt idx="{index}"><{tag}v>{escape(str(value))}</{tag}v></{tag}pt>'.encode("utf-8")
)
for anchor, fragments in additions.items():
xml.edits.append((anchor, anchor, b"".join(fragments)))
def _patch_chart(data: bytes, baseline: dict, edited: dict) -> tuple[bytes, dict]:
xml = _XmlPatch(data)
plot_area = xml.root.find("c:chart/c:plotArea", NS)
plots = [node for node in plot_area if node.find("c:ser", NS) is not None] if plot_area is not None else []
old_plots, new_plots = baseline.get("plots", [baseline]), edited.get("plots", [edited])
if len(plots) != len(new_plots):
raise RuntimeError("Source chart plots do not match the import baseline; reimport the source deck")
cells: dict[tuple[str, str], Any] = {}
for plot, old_plot, new_plot in zip(plots, old_plots, new_plots):
series = plot.findall("c:ser", NS)
if len(series) != len(new_plot["series"]):
raise RuntimeError("Source chart series do not match the import baseline; reimport the source deck")
for node, old, new in zip(series, old_plot["series"], new_plot["series"]):
for key, tag in (("values", "val"), ("x", "xVal"), ("y", "yVal"), ("sizes", "bubbleSize")):
if key in new:
owner = node.find(f"c:{tag}", NS)
if owner is None:
raise RuntimeError(f"Source chart lacks c:{tag}; reimport the source deck")
_patch_cache(xml, owner, old[key], new[key], cells)
if "categories" in edited:
owner = node.find("c:cat", NS)
if owner is None:
raise RuntimeError("Source chart lacks categories; reimport the source deck")
_patch_cache(xml, owner, old_plot.get("categories", baseline["categories"]),
new_plot.get("categories", edited["categories"]), cells)
tx = node.find("c:tx", NS)
if tx is not None and tx.find("c:strRef", NS) is not None:
_patch_cache(xml, tx, [old["name"]], [new["name"]], cells)
elif old.get("name") != new.get("name"):
leaf = tx.find("c:v", NS) if tx is not None else None
if leaf is None:
name = xml.spans[node].name.decode()
prefix = name.rsplit(":", 1)[0] + ":" if ":" in name else ""
value = escape(new["name"], {"\r": "&#13;"})
content = f"<{prefix}v>{value}</{prefix}v>".encode("utf-8")
if tx is not None:
xml.append(tx, content)
else:
order = node.find("c:order", NS)
if order is None:
raise RuntimeError("Source chart series has no order; repair the source chart")
anchor = xml.spans[order].end
content = f"<{prefix}tx>".encode() + content + f"</{prefix}tx>".encode()
xml.edits.append((anchor, anchor, content))
else:
xml.text(leaf, new["name"])
return xml.render(), cells
def _resolve(owner: str, target: str) -> str:
return posixpath.normpath(posixpath.join(posixpath.dirname(owner), target)).lstrip("/")
def _patch_workbook(data: bytes, assignments: dict[tuple[str, str], Any]) -> bytes:
with ZipFile(io.BytesIO(data)) as archive:
parts = {name: archive.read(name) for name in archive.namelist()}
workbook = ET.fromstring(parts["xl/workbook.xml"])
relationships = ET.fromstring(parts["xl/_rels/workbook.xml.rels"])
targets = {rel.get("Id"): _resolve("xl/workbook.xml", rel.get("Target", "")) for rel in relationships}
sheets = {sheet.get("name"): targets[sheet.get(f'{{{NS["r"]}}}id')]
for sheet in workbook.findall("s:sheets/s:sheet", NS)}
strings_part = next((targets[rel.get("Id")] for rel in relationships
if rel.get("Type", "").endswith("/sharedStrings")), None)
strings = _XmlPatch(parts[strings_part]) if strings_part else None
string_values = ["".join(text.text or "" for text in node.findall("s:t", NS) + node.findall("s:r/s:t", NS))
for node in strings.root.findall("s:si", NS)] if strings else []
string_indices = {value: index for index, value in enumerate(string_values)}
additions: list[bytes] = []
sheets_xml = {name: _XmlPatch(parts[part]) for name, part in sheets.items()}
shared_count = sum(node.get("t") == "s" for xml in sheets_xml.values() for node in xml.root.iter())
old_shared_count = shared_count
for (sheet, address), (before, value) in assignments.items():
if before == value:
continue
if sheet not in sheets_xml:
raise RuntimeError(f"Chart workbook sheet {sheet!r} is missing")
xml = sheets_xml[sheet]
node = next((cell for cell in xml.root.findall("s:sheetData/s:row/s:c", NS)
if cell.get("r") == address), None)
leaf = node.find("s:v", NS) if node is not None else None
kind = node.get("t") if node is not None else None
old_value = leaf.text if leaf is not None else None
if kind == "s" and old_value is not None:
old_value = string_values[int(old_value)]
elif kind == "inlineStr":
old_value = "".join(node.find("s:is", NS).itertext())
if old_value == value or (kind in {None, "n"} and old_value is not None
and isinstance(value, (int, float)) and float(old_value) == value):
continue
if node is not None and node.find("s:f", NS) is not None:
raise RuntimeError(f"Chart workbook cell {sheet}!{address} is a formula; edit its source inputs")
# A fresh cell body preserves style/extension attributes and avoids shared-string alias edits.
cell = copy.deepcopy(node) if node is not None else ET.Element(f'{{{NS["s"]}}}c', {"r": address})
cell.tail = None
for child in list(cell):
if child.tag in {f'{{{NS["s"]}}}{tag}' for tag in ("v", "is")}:
cell.remove(child)
cell.attrib.pop("t", None)
if isinstance(value, str):
if strings is not None:
if value not in string_indices:
string_indices[value] = len(string_values) + len(additions)
tag = strings.spans[strings.root].name.decode().rsplit(":", 1)
prefix = tag[0] + ":" if len(tag) == 2 else ""
text_value = escape(value, {"\r": "&#13;"})
additions.append((f'<{prefix}si><{prefix}t xml:space="preserve">'
f'{text_value}</{prefix}t></{prefix}si>').encode("utf-8"))
cell.set("t", "s")
content = ET.Element(f'{{{NS["s"]}}}v')
content.text = str(string_indices[value])
cell.insert(0, content)
else:
cell.set("t", "inlineStr")
content = ET.Element(f'{{{NS["s"]}}}is')
text = ET.SubElement(content, f'{{{NS["s"]}}}t')
text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
text.text = value
cell.insert(0, content)
elif value is not None:
content = ET.Element(f'{{{NS["s"]}}}v')
content.text = str(value)
cell.insert(0, content)
shared_count += int(cell.get("t") == "s") - int(kind == "s")
replacement = ET.tostring(cell, encoding="utf-8")
if node is not None:
xml.replace(node, replacement)
else:
row_num = re.search(r"[0-9]+$", address).group()
row = next((row for row in xml.root.findall("s:sheetData/s:row", NS)
if row.get("r") == row_num), None)
if row is None:
raise RuntimeError(f"Chart workbook row {sheet}!{row_num} is missing; repair the source workbook")
column = re.match(r"[A-Z]+", address).group()
following = [cell for cell in row.findall("s:c", NS)
if (len(re.match(r"[A-Z]+", cell.get("r")).group()),
re.match(r"[A-Z]+", cell.get("r")).group()) > (len(column), column)]
if following:
anchor = xml.spans[following[0]].start
xml.edits.append((anchor, anchor, replacement))
else:
xml.append(row, replacement)
if not any(xml.edits for xml in sheets_xml.values()):
return data
for sheet, xml in sheets_xml.items():
if xml.edits:
parts[sheets[sheet]] = xml.render()
if strings is not None and (additions or shared_count != old_shared_count):
strings.attributes(strings.root, {"count": str(shared_count),
"uniqueCount": str(len(string_values) + len(additions))})
if additions:
strings.append(strings.root, b"".join(additions))
parts[strings_part] = strings.render()
output = io.BytesIO()
with ZipFile(output, "w") as result:
for info in archive.infolist():
result.writestr(info, parts[info.filename])
result.comment = archive.comment
return output.getvalue()
def patch_chart_package(source: dict, baseline: dict, edited: dict) -> dict:
"""Clone a validated source graph and patch only data in its chart/workbook."""
from . import _decode_source_chart_package
validate_chart_payload(edited)
decoded = _decode_source_chart_package({"source_package": source})
if decoded is None:
raise RuntimeError("Imported chart has no source package; reimport the source deck")
chart_part, _frame, parts, _types = decoded
parts[chart_part], cells = _patch_chart(parts[chart_part], baseline, edited)
chart_root = ET.fromstring(parts[chart_part])
external = chart_root.find("c:externalData", NS)
if cells and external is not None:
rels_name = posixpath.join(posixpath.dirname(chart_part), "_rels", posixpath.basename(chart_part) + ".rels")
rels = ET.fromstring(parts[rels_name])
rel = next((rel for rel in rels if rel.get("Id") == external.get(f'{{{NS["r"]}}}id')), None)
if rel is None:
raise RuntimeError("Chart workbook relationship is missing; repair the source chart")
workbook_part = _resolve(chart_part, rel.get("Target", ""))
try:
parts[workbook_part] = _patch_workbook(parts[workbook_part], cells)
except (BadZipFile, KeyError, ValueError, ET.ParseError) as exc:
raise RuntimeError(f"Cannot patch embedded chart workbook: {exc}; repair the source workbook") from exc
# Content-addressed copies keep other pages sharing the source graph immutable.
digest = hashlib.sha256()
for name, data in sorted(parts.items()):
digest.update(name.encode() + b"\0" + hashlib.sha256(data).digest())
suffix = "_data_" + digest.hexdigest()[:20]
names = {name: posixpath.splitext(name)[0] + suffix + posixpath.splitext(name)[1]
for name in parts if not name.endswith(".rels")}
for name in parts:
if not name.endswith(".rels"):
continue
parent, leaf = name.split("/_rels/", 1)
owner = parent + "/" + leaf[:-5]
new_owner = names[owner]
names[name] = posixpath.join(posixpath.dirname(new_owner), "_rels", posixpath.basename(new_owner) + ".rels")
rels = _XmlPatch(parts[name])
for rel in rels.root:
target = names[_resolve(owner, rel.get("Target", ""))]
rels.attributes(rel, {"Target": posixpath.relpath(target, posixpath.dirname(new_owner))})
parts[name] = rels.render()
result = copy.deepcopy(source)
result["chart_part"] = names[chart_part]
for item in result["parts"]:
data = parts[item["name"]]
item.update(name=names[item["name"]], payload=base64.b64encode(data).decode("ascii"),
sha256=hashlib.sha256(data).hexdigest())
return result
@@ -347,16 +347,37 @@ def _major_gridlines_xml(color: str | None) -> str:
return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
_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'<a:latin typeface="{latin_font}"/>'
f'<a:ea typeface="{ea_font}"/>'
f'<a:cs typeface="{latin_font}"/>'
f'<a:latin typeface="{_xml_escape(latin_font)}"/>'
f'<a:ea typeface="{_xml_escape(ea_font)}"/>'
f'<a:cs typeface="{_xml_escape(cs_font)}"/>'
)
@@ -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'<c:majorTickMark val="{cat_tick_marks}"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{cat_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr(category)}"
f"{axis_sp_pr}{_axis_tx_pr_xml(category, axis_font_size, chart_style)}"
f'<c:crossAx val="{val_ax_id}"/><c:crosses val="{category_crosses}"/>{category_tail}'
f"</c:{category_tag}>"
"<c:valAx>"
@@ -1043,7 +1048,7 @@ def _axis_pair_xml(
f"{val_gridlines}{val_title_xml}{val_number_format}"
f'<c:majorTickMark val="{val_tick_marks}"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{val_tick_label_pos}"/>'
f"{axis_sp_pr}{axis_tx_pr(value)}"
f"{axis_sp_pr}{_axis_tx_pr_xml(value, axis_font_size, chart_style)}"
f'<c:crossAx val="{cat_ax_id}"/><c:crosses val="{value_crosses}"/>'
f"{cross_between}{major_unit}"
"</c:valAx>"
@@ -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'<c:axId val="{axis_id}"/>{_axis_scaling_xml(config)}'
f'<c:delete val="{delete}"/><c:axPos val="{position}"/>'
f"{gridlines}{title_xml}{number_format}"
'<c:majorTickMark val="out"/><c:minorTickMark val="none"/>'
f'<c:majorTickMark val="{config.get("tick_marks", "none")}"/><c:minorTickMark val="none"/>'
f'<c:tickLblPos val="{tick_label_position}"/>'
f"{axis_sp_pr}{axis_tx_pr}"
f"{axis_sp_pr}{_axis_tx_pr_xml(config, axis_font_size, chart_style)}"
f'<c:crossAx val="{cross_axis_id}"/><c:crosses val="autoZero"/>'
f'<c:crossBetween val="midCat"/>{major_unit}'
"</c:valAx>"
@@ -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", [])]
@@ -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'<a:tc{merge_attrs}>'
'<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>'
f'<a:tcPr{continuation_attrs}>'
f'{continuation_border_xml}{continuation_fill_xml}'
'</a:tcPr>'
'</a:tc>'
)
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 = (
"<a:noFill/>"
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'<a:tc{merge_attrs}>'
'<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>'
f'<a:tcPr{continuation_attrs}>'
f'{border_xml}{fill_xml}'
'</a:tcPr>'
'</a:tc>'
)
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"<a:p>{paragraph_props}{text_run_xml}</a:p>"
)
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 = "<a:noFill/>"
else:
fill_xml = _table_fill_xml(
fill,
_table_fill_opacity(cell_data),
ctx.theme_color_spec,
)
cells_xml.append(
f"<a:tc{merge_attrs}>"
"<a:txBody><a:bodyPr/><a:lstStyle/>"
@@ -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 "
@@ -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 = '<a:schemeClr val="accent1"/>') -> str:
return (
'<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>'
f'<a:defRPr lang="zh-CN" sz="{size}"><a:solidFill>{color}</a:solidFill>'
'<a:latin typeface="微软雅黑"/><a:ea typeface="微软雅黑"/>'
'</a:defRPr></a:pPr><a:endParaRPr lang="zh-CN"/></a:p></c:txPr>'
)
def _source_chart() -> ET.Element:
value_style = _text_style(1200, '<a:srgbClr val="123456"/>')
return ET.fromstring(
f'<c:chartSpace xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}">'
'<c:lang val="zh-CN"/><c:chart><c:plotArea><c:layout><c:manualLayout>'
'<c:layoutTarget val="inner"/><c:xMode val="edge"/><c:yMode val="edge"/>'
'<c:x val="0.1"/><c:y val="0.15"/><c:w val="0.8"/><c:h val="0.65"/>'
'</c:manualLayout></c:layout><c:barChart><c:barDir val="col"/>'
'<c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:order val="0"/>'
'<c:tx><c:v>Budget</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/>'
'<c:pt idx="0"><c:v>Other</c:v></c:pt><c:pt idx="1"><c:v>Equipment</c:v></c:pt>'
'</c:strLit></c:cat><c:val><c:numLit><c:formatCode>General</c:formatCode>'
'<c:ptCount val="2"/><c:pt idx="0"><c:v>10</c:v></c:pt>'
'<c:pt idx="1"><c:v>20</c:v></c:pt></c:numLit></c:val></c:ser>'
'<c:gapWidth val="40"/><c:overlap val="-27"/><c:axId val="1"/><c:axId val="2"/>'
'</c:barChart><c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/>'
'</c:scaling><c:axPos val="b"/><c:majorTickMark val="in"/><c:tickLblPos val="nextTo"/>'
f'{_text_style()}<c:crossAx val="2"/><c:crosses val="autoZero"/></c:catAx>'
'<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling>'
'<c:axPos val="l"/><c:majorGridlines/><c:majorTickMark val="cross"/>'
'<c:tickLblPos val="nextTo"/>'
f'{value_style}'
'<c:crossAx val="1"/><c:crosses val="autoZero"/><c:crossBetween val="between"/>'
'</c:valAx></c:plotArea><c:legend><c:legendPos val="t"/>'
f'{_text_style(1350)}</c:legend></c:chart>{_text_style(900)}</c:chartSpace>'
)
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",
'<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:sldIdLst/></p:presentation>',
)
slide = PartRef("ppt/slides/slide1.xml", ET.Element("slide"), {
"rId1": {"target": "ppt/charts/chart1.xml"},
})
graphic = ET.fromstring(
f'<a:graphicData xmlns:a="{NS["a"]}" xmlns:c="{NS["c"]}" '
f'xmlns:r="{NS["r"]}" uri="{NS["c"]}"><c:chart r:id="rId1"/></a:graphicData>'
)
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'<c:spPr xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}">'
'<a:solidFill><a:srgbClr val="123456"/></a:solidFill></c:spPr>'
)
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'<c:dPt xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}"><c:idx val="0"/>'
'<c:marker><c:spPr><a:solidFill><a:srgbClr val="00AAFF"/></a:solidFill>'
'</c:spPr></c:marker></c:dPt>'
))
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'<c:spPr xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}">'
f'<a:solidFill><a:srgbClr val="{color}"/></a:solidFill></c:spPr>'
))
title = ET.SubElement(source.find("c:chart", NS), f'{{{NS["c"]}}}title')
title.append(ET.fromstring(
f'<c:tx xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}"><c:rich>'
'<a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr sz="2100">'
'<a:solidFill><a:srgbClr val="112233"/></a:solidFill>'
'<a:latin typeface="Arial"/></a:rPr><a:t>Budget</a:t></a:r></a:p></c:rich></c:tx>'
))
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'<c:title xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}"><c:tx><c:rich>'
'<a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="900">'
'<a:latin typeface="Arial"/></a:defRPr></a:pPr>'
f'<a:r><a:rPr sz="{size}"><a:solidFill><a:srgbClr val="{color}"/>'
'</a:solidFill></a:rPr><a:t>Shared title</a:t></a:r></a:p></c:rich></c:tx></c: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"/><injected/>'):
with self.subTest(invalid=invalid), self.assertRaises(RuntimeError):
self._export({**payload, "lang": invalid})
with self.assertRaisesRegex(RuntimeError, "classic charts only"):
self._export({**payload, "type": "treemap", "values": [1], "levels": [["A"]], "lang": "zh-CN"})
class RoundtripFrameInferenceTests(unittest.TestCase):
def _check(self, body: str) -> dict:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source.pptx"
presentation = Presentation()
presentation.slides.add_slide(presentation.slide_layouts[6])
presentation.save(source)
workspace = root / "workspace"
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
convert_pptx_to_svg(source, workspace, ConvertOptions(inheritance_mode="both", roundtrip=True))
page = workspace / "authoring-svg-flat/slide_01.svg"
svg = ET.parse(page).getroot()
additions = ET.fromstring(f'<svg xmlns="{NS["s"]}">{body}</svg>')
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(
'<rect id="accent" x="10" y="10" width="5" height="50"/>'
'<g id="caption" data-pptx-bounds="100 100 400 66" transform="translate(20 10)">'
'<text x="300" y="140" text-anchor="middle" font-family="Arial" font-size="24">Caption</text></g>'
)
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(
'<rect id="accent" x="10" y="10" width="5" height="50"/>'
'<g id="caption"><g><text x="300" y="140" font-family="Arial" font-size="24">Caption</text></g></g>'
)
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(
'<g id="caption" data-pptx-bounds="100 100 10 66">'
'<text x="100" y="140" font-family="Arial" font-size="24">Long caption</text></g>'
)
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(
'<g id="caption"><rect id="accent" x="10" y="10" width="5" height="50"/>'
'<rect id="frame" x="100" y="100" width="400" height="66"/>'
'<text x="300" y="140" text-anchor="middle" font-family="Arial" font-size="24">Caption</text></g>'
)
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(
'<g id="caption"><rect id="frame" x="100" y="100" width="80" height="66"/>'
'<text x="110" y="140" font-family="Arial" font-size="24">Long caption text</text></g>'
)
self.assertFalse(result["errors"], result)
self.assertTrue(any('exceeds owning frame <rect id="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"),
'<a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/>',
)
self.assertEqual(
_font_face_xml("+mn-ea"),
'<a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/>',
)
self.assertIn('<a:ea typeface="+mj-ea"/>', _font_face_xml("Arial, +mj-ea"))
self.assertIn('<a:latin typeface="Arial"/>', _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"),
'<a:latin typeface="Arial"/><a:ea typeface="Microsoft YaHei"/><a:cs typeface="Arial"/>',
)
@@ -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'<c:dPt xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}"><c:idx val="0"/>'
'<c:spPr><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="112233"/>'
'</a:gs><a:gs pos="100000"><a:srgbClr val="445566"/></a:gs></a:gsLst>'
'<a:lin ang="5400000" scaled="1"/></a:gradFill>'
'<a:ln w="19050"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:ln>'
'</c:spPr></c:dPt>'
)
series.insert(3, point)
series.insert(3, copy.deepcopy(point.find("c:spPr", NS)))
legend = ET.fromstring(
f'<c:legend xmlns:c="{NS["c"]}" xmlns:a="{NS["a"]}"><c:legendPos val="t"/>'
'<c:legendEntry><c:idx val="0"/><c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>'
'<a:defRPr sz="2200"/></a:pPr><a:endParaRPr/></a:p></c:txPr></c:legendEntry>'
'<c:layout><c:manualLayout><c:x val="0.2"/><c:y val="0.1"/>'
'<c:w val="0.7"/><c:h val="0.12"/></c:manualLayout></c:layout></c:legend>'
)
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",
'<cs:chartStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" id="10"/>'),
("rColors", "colors1.xml", "http://schemas.microsoft.com/office/2011/relationships/chartColorStyle",
"application/vnd.ms-office.chartcolorstyle+xml",
'<cs:colorStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" id="10"/>'),
("rTheme", "../theme/themeOverride1.xml", NS["r"] + "/themeOverride",
"application/vnd.openxmlformats-officedocument.themeOverride+xml",
f'<a:themeOverride xmlns:a="{NS["a"]}"/>'),
):
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 &amp; 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")
@@ -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, "")
@@ -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)
@@ -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<NN>` 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 |
@@ -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"
}