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:
1 parent
e224150d81
commit
e1981742ba
24 files changed
+2054
-219
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+272
-36
@@ -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 = [
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+13
@@ -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"
|
||||
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Imported Chart Data Patching
|
||||
|
||||
Patch classic-chart caches and their embedded workbook without serializing the
|
||||
surrounding chart XML. See scripts/docs/conversion.md for the round-trip rule.
|
||||
|
||||
Usage:
|
||||
Import patch_chart_package from the round-trip materializer.
|
||||
|
||||
Examples:
|
||||
package = patch_chart_package(source_package, baseline, edited)
|
||||
|
||||
Dependencies:
|
||||
Standard library and sibling native chart helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import posixpath
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.parsers import expat
|
||||
from xml.sax.saxutils import escape
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from .chart_data import validate_chart_payload
|
||||
|
||||
|
||||
NS = {
|
||||
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
||||
"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
_START_TAG_RE = re.compile(rb'''<([^\s/>]+)(?:[^>"']|"[^"]*"|'[^']*')*>''')
|
||||
_RANGE_RE = re.compile(r"(?:'((?:[^']|'')+)'|([^!]+))!\$?([A-Z]+)\$?([1-9][0-9]*)"
|
||||
r"(?::\$?([A-Z]+)\$?([1-9][0-9]*))?")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Span:
|
||||
start: int
|
||||
start_end: int
|
||||
close: int
|
||||
end: int
|
||||
name: bytes
|
||||
empty: bool
|
||||
|
||||
|
||||
class _XmlPatch:
|
||||
"""Address parsed elements while retaining all untouched source bytes."""
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.data = data
|
||||
self.root = ET.fromstring(data)
|
||||
self.edits: list[tuple[int, int, bytes]] = []
|
||||
spans: list[_Span] = []
|
||||
stack: list[_Span] = []
|
||||
parser = expat.ParserCreate()
|
||||
|
||||
def start(_name, _attributes):
|
||||
offset = parser.CurrentByteIndex
|
||||
match = _START_TAG_RE.match(data, offset)
|
||||
if match is None:
|
||||
raise RuntimeError("Cannot locate source XML start tag")
|
||||
empty = match.group().endswith(b"/>")
|
||||
span = _Span(offset, match.end(), match.end() - 2, match.end(), match[1], empty)
|
||||
spans.append(span)
|
||||
stack.append(span)
|
||||
|
||||
def end(_name):
|
||||
span = stack.pop()
|
||||
if not span.empty:
|
||||
span.close = parser.CurrentByteIndex
|
||||
span.end = data.index(b">", span.close) + 1
|
||||
|
||||
parser.StartElementHandler = start
|
||||
parser.EndElementHandler = end
|
||||
parser.Parse(data, True)
|
||||
self.spans = dict(zip(self.root.iter(), spans))
|
||||
|
||||
def replace(self, node: ET.Element, data: bytes) -> None:
|
||||
span = self.spans[node]
|
||||
self.edits.append((span.start, span.end, data))
|
||||
|
||||
def text(self, node: ET.Element, value: Any) -> None:
|
||||
data = escape(str(value), {"\r": " "}).encode("utf-8")
|
||||
span = self.spans[node]
|
||||
if span.empty:
|
||||
self.replace(node, self.data[span.start:span.close] + b">" + data + b"</" + span.name + b">")
|
||||
else:
|
||||
self.edits.append((span.start_end, span.close, data))
|
||||
|
||||
def attributes(self, node: ET.Element, values: dict[str, str | None]) -> None:
|
||||
span = self.spans[node]
|
||||
start = self.data[span.start:span.start_end]
|
||||
for key, value in values.items():
|
||||
pattern = re.compile(rb"\s+" + re.escape(key.encode()) + rb'''\s*=\s*(?:"[^"]*"|'[^']*')''')
|
||||
replacement = b"" if value is None else (
|
||||
b" " + key.encode() + b'="' + escape(value, {'"': """}).encode("utf-8") + b'"'
|
||||
)
|
||||
if pattern.search(start):
|
||||
start = pattern.sub(lambda _match: replacement, start, count=1)
|
||||
elif value is not None:
|
||||
offset = -2 if span.empty else -1
|
||||
start = start[:offset] + replacement + start[offset:]
|
||||
self.edits.append((span.start, span.start_end, start))
|
||||
|
||||
def append(self, node: ET.Element, data: bytes) -> None:
|
||||
span = self.spans[node]
|
||||
if span.empty:
|
||||
self.replace(node, self.data[span.start:span.close] + b">" + data + b"</" + span.name + b">")
|
||||
else:
|
||||
self.edits.append((span.close, span.close, data))
|
||||
|
||||
def render(self) -> bytes:
|
||||
result: list[bytes] = []
|
||||
cursor = 0
|
||||
for start, end, data in sorted(self.edits, key=lambda edit: (edit[0], edit[1])):
|
||||
if start < cursor:
|
||||
raise RuntimeError("Overlapping chart XML data edits")
|
||||
result.extend((self.data[cursor:start], data))
|
||||
cursor = end
|
||||
result.append(self.data[cursor:])
|
||||
patched = b"".join(result)
|
||||
ET.fromstring(patched)
|
||||
return patched
|
||||
|
||||
|
||||
def _data_shape(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Mask only data leaves, retaining field presence, counts and all options."""
|
||||
result = copy.deepcopy(payload)
|
||||
for plot in [result, *result.get("plots", [])]:
|
||||
if isinstance(plot.get("categories"), list):
|
||||
plot["categories"] = [None] * len(plot["categories"])
|
||||
for series in plot.get("series", []):
|
||||
if "name" in series:
|
||||
series["name"] = None
|
||||
for key in ("values", "x", "y", "sizes"):
|
||||
if isinstance(series.get(key), list):
|
||||
series[key] = [None] * len(series[key])
|
||||
return result
|
||||
|
||||
|
||||
def chart_edit_is_data_only(baseline: dict[str, Any], edited: dict[str, Any]) -> bool:
|
||||
"""Require the same plot/series topology and every non-data JSON field."""
|
||||
for plot in [edited, *edited.get("plots", [])]:
|
||||
for series in plot.get("series", []):
|
||||
if not isinstance(series.get("name", ""), str):
|
||||
return False
|
||||
for key in ("values", "x", "y", "sizes"):
|
||||
if any(value is not None and (type(value) not in {int, float} or not math.isfinite(value))
|
||||
for value in series.get(key, [])):
|
||||
return False
|
||||
return baseline != edited and _data_shape(baseline) == _data_shape(edited)
|
||||
|
||||
|
||||
def _range_cells(formula: str) -> tuple[str, list[str]]:
|
||||
match = _RANGE_RE.fullmatch(formula)
|
||||
if match is None:
|
||||
raise RuntimeError(f"Cannot patch chart workbook range {formula!r}; use a single A1 row or column range")
|
||||
quoted, plain, col, row, last_col, last_row = match.groups()
|
||||
sheet = quoted.replace("''", "'") if quoted is not None else plain
|
||||
last_col, last_row = last_col or col, last_row or row
|
||||
|
||||
def number(label):
|
||||
value = 0
|
||||
for char in label:
|
||||
value = value * 26 + ord(char) - ord("A") + 1
|
||||
return value
|
||||
|
||||
def label(value):
|
||||
result = ""
|
||||
while value:
|
||||
value, digit = divmod(value - 1, 26)
|
||||
result = chr(ord("A") + digit) + result
|
||||
return result
|
||||
|
||||
c1, c2, r1, r2 = number(col), number(last_col), int(row), int(last_row)
|
||||
if c2 < c1 or r2 < r1 or (c1 != c2 and r1 != r2):
|
||||
raise RuntimeError(f"Cannot patch non-linear chart workbook range {formula!r}")
|
||||
if c2 > 16384 or r2 > 1048576 or max(c2 - c1, r2 - r1) > 1_000_000:
|
||||
raise RuntimeError(f"Chart workbook range exceeds worksheet bounds: {formula!r}")
|
||||
return sheet, [f"{label(c)}{r}" for r in range(r1, r2 + 1) for c in range(c1, c2 + 1)]
|
||||
|
||||
|
||||
def _patch_cache(
|
||||
xml: _XmlPatch, owner: ET.Element, before: list, after: list,
|
||||
cells: dict[tuple[str, str], Any],
|
||||
) -> None:
|
||||
cache = next((node for node in owner.iter() if node.tag in {
|
||||
f'{{{NS["c"]}}}{name}' for name in ("strCache", "numCache", "strLit", "numLit")
|
||||
}), None)
|
||||
if cache is None:
|
||||
raise RuntimeError("Imported chart has no patchable data cache; reimport a chart with cached values")
|
||||
formula = owner.find(".//c:f", NS)
|
||||
if formula is not None:
|
||||
sheet, addresses = _range_cells(formula.text or "")
|
||||
if len(addresses) != len(after):
|
||||
raise RuntimeError("Chart cache count differs from its workbook range; repair the source chart")
|
||||
for address, old, value in zip(addresses, before, after):
|
||||
key = (sheet, address)
|
||||
if key in cells and cells[key][1] != value:
|
||||
raise RuntimeError(f"Conflicting chart data for workbook cell {sheet}!{address}")
|
||||
cells[key] = (old, value)
|
||||
if before == after:
|
||||
return
|
||||
if cache.tag in {f'{{{NS["c"]}}}numCache', f'{{{NS["c"]}}}numLit'}:
|
||||
try:
|
||||
valid = all(value is None or math.isfinite(float(value)) for value in after)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
valid = False
|
||||
if not valid:
|
||||
raise RuntimeError("Source chart uses a numeric cache; retain numeric category labels")
|
||||
count = cache.find("c:ptCount", NS)
|
||||
if count is None:
|
||||
raise RuntimeError("Imported chart cache has no ptCount; repair the source chart")
|
||||
if count.get("val") != str(len(after)):
|
||||
xml.attributes(count, {"val": str(len(after))})
|
||||
points = {int(node.get("idx", "-1")): node for node in cache.findall("c:pt", NS)}
|
||||
prefix = xml.spans[cache].name.rsplit(b":", 1)[0] + b":" if b":" in xml.spans[cache].name else b""
|
||||
additions: dict[int, list[bytes]] = {}
|
||||
for index, (old, value) in enumerate(zip(before, after)):
|
||||
if old == value:
|
||||
continue
|
||||
point = points.get(index)
|
||||
if value is None:
|
||||
if point is not None:
|
||||
xml.replace(point, b"")
|
||||
elif point is not None:
|
||||
leaf = point.find("c:v", NS)
|
||||
if leaf is None:
|
||||
raise RuntimeError("Imported chart point has no value; repair the source chart")
|
||||
xml.text(leaf, value)
|
||||
else:
|
||||
tag = prefix.decode()
|
||||
following = [xml.spans[node].start for idx, node in points.items() if idx > index]
|
||||
anchor = min(following) if following else max(
|
||||
(xml.spans[node].end for node in points.values()), default=xml.spans[count].end,
|
||||
)
|
||||
additions.setdefault(anchor, []).append(
|
||||
f'<{tag}pt idx="{index}"><{tag}v>{escape(str(value))}</{tag}v></{tag}pt>'.encode("utf-8")
|
||||
)
|
||||
for anchor, fragments in additions.items():
|
||||
xml.edits.append((anchor, anchor, b"".join(fragments)))
|
||||
|
||||
|
||||
def _patch_chart(data: bytes, baseline: dict, edited: dict) -> tuple[bytes, dict]:
|
||||
xml = _XmlPatch(data)
|
||||
plot_area = xml.root.find("c:chart/c:plotArea", NS)
|
||||
plots = [node for node in plot_area if node.find("c:ser", NS) is not None] if plot_area is not None else []
|
||||
old_plots, new_plots = baseline.get("plots", [baseline]), edited.get("plots", [edited])
|
||||
if len(plots) != len(new_plots):
|
||||
raise RuntimeError("Source chart plots do not match the import baseline; reimport the source deck")
|
||||
cells: dict[tuple[str, str], Any] = {}
|
||||
for plot, old_plot, new_plot in zip(plots, old_plots, new_plots):
|
||||
series = plot.findall("c:ser", NS)
|
||||
if len(series) != len(new_plot["series"]):
|
||||
raise RuntimeError("Source chart series do not match the import baseline; reimport the source deck")
|
||||
for node, old, new in zip(series, old_plot["series"], new_plot["series"]):
|
||||
for key, tag in (("values", "val"), ("x", "xVal"), ("y", "yVal"), ("sizes", "bubbleSize")):
|
||||
if key in new:
|
||||
owner = node.find(f"c:{tag}", NS)
|
||||
if owner is None:
|
||||
raise RuntimeError(f"Source chart lacks c:{tag}; reimport the source deck")
|
||||
_patch_cache(xml, owner, old[key], new[key], cells)
|
||||
if "categories" in edited:
|
||||
owner = node.find("c:cat", NS)
|
||||
if owner is None:
|
||||
raise RuntimeError("Source chart lacks categories; reimport the source deck")
|
||||
_patch_cache(xml, owner, old_plot.get("categories", baseline["categories"]),
|
||||
new_plot.get("categories", edited["categories"]), cells)
|
||||
tx = node.find("c:tx", NS)
|
||||
if tx is not None and tx.find("c:strRef", NS) is not None:
|
||||
_patch_cache(xml, tx, [old["name"]], [new["name"]], cells)
|
||||
elif old.get("name") != new.get("name"):
|
||||
leaf = tx.find("c:v", NS) if tx is not None else None
|
||||
if leaf is None:
|
||||
name = xml.spans[node].name.decode()
|
||||
prefix = name.rsplit(":", 1)[0] + ":" if ":" in name else ""
|
||||
value = escape(new["name"], {"\r": " "})
|
||||
content = f"<{prefix}v>{value}</{prefix}v>".encode("utf-8")
|
||||
if tx is not None:
|
||||
xml.append(tx, content)
|
||||
else:
|
||||
order = node.find("c:order", NS)
|
||||
if order is None:
|
||||
raise RuntimeError("Source chart series has no order; repair the source chart")
|
||||
anchor = xml.spans[order].end
|
||||
content = f"<{prefix}tx>".encode() + content + f"</{prefix}tx>".encode()
|
||||
xml.edits.append((anchor, anchor, content))
|
||||
else:
|
||||
xml.text(leaf, new["name"])
|
||||
return xml.render(), cells
|
||||
|
||||
|
||||
def _resolve(owner: str, target: str) -> str:
|
||||
return posixpath.normpath(posixpath.join(posixpath.dirname(owner), target)).lstrip("/")
|
||||
|
||||
|
||||
def _patch_workbook(data: bytes, assignments: dict[tuple[str, str], Any]) -> bytes:
|
||||
with ZipFile(io.BytesIO(data)) as archive:
|
||||
parts = {name: archive.read(name) for name in archive.namelist()}
|
||||
workbook = ET.fromstring(parts["xl/workbook.xml"])
|
||||
relationships = ET.fromstring(parts["xl/_rels/workbook.xml.rels"])
|
||||
targets = {rel.get("Id"): _resolve("xl/workbook.xml", rel.get("Target", "")) for rel in relationships}
|
||||
sheets = {sheet.get("name"): targets[sheet.get(f'{{{NS["r"]}}}id')]
|
||||
for sheet in workbook.findall("s:sheets/s:sheet", NS)}
|
||||
strings_part = next((targets[rel.get("Id")] for rel in relationships
|
||||
if rel.get("Type", "").endswith("/sharedStrings")), None)
|
||||
strings = _XmlPatch(parts[strings_part]) if strings_part else None
|
||||
string_values = ["".join(text.text or "" for text in node.findall("s:t", NS) + node.findall("s:r/s:t", NS))
|
||||
for node in strings.root.findall("s:si", NS)] if strings else []
|
||||
string_indices = {value: index for index, value in enumerate(string_values)}
|
||||
additions: list[bytes] = []
|
||||
sheets_xml = {name: _XmlPatch(parts[part]) for name, part in sheets.items()}
|
||||
shared_count = sum(node.get("t") == "s" for xml in sheets_xml.values() for node in xml.root.iter())
|
||||
old_shared_count = shared_count
|
||||
for (sheet, address), (before, value) in assignments.items():
|
||||
if before == value:
|
||||
continue
|
||||
if sheet not in sheets_xml:
|
||||
raise RuntimeError(f"Chart workbook sheet {sheet!r} is missing")
|
||||
xml = sheets_xml[sheet]
|
||||
node = next((cell for cell in xml.root.findall("s:sheetData/s:row/s:c", NS)
|
||||
if cell.get("r") == address), None)
|
||||
leaf = node.find("s:v", NS) if node is not None else None
|
||||
kind = node.get("t") if node is not None else None
|
||||
old_value = leaf.text if leaf is not None else None
|
||||
if kind == "s" and old_value is not None:
|
||||
old_value = string_values[int(old_value)]
|
||||
elif kind == "inlineStr":
|
||||
old_value = "".join(node.find("s:is", NS).itertext())
|
||||
if old_value == value or (kind in {None, "n"} and old_value is not None
|
||||
and isinstance(value, (int, float)) and float(old_value) == value):
|
||||
continue
|
||||
if node is not None and node.find("s:f", NS) is not None:
|
||||
raise RuntimeError(f"Chart workbook cell {sheet}!{address} is a formula; edit its source inputs")
|
||||
# A fresh cell body preserves style/extension attributes and avoids shared-string alias edits.
|
||||
cell = copy.deepcopy(node) if node is not None else ET.Element(f'{{{NS["s"]}}}c', {"r": address})
|
||||
cell.tail = None
|
||||
for child in list(cell):
|
||||
if child.tag in {f'{{{NS["s"]}}}{tag}' for tag in ("v", "is")}:
|
||||
cell.remove(child)
|
||||
cell.attrib.pop("t", None)
|
||||
if isinstance(value, str):
|
||||
if strings is not None:
|
||||
if value not in string_indices:
|
||||
string_indices[value] = len(string_values) + len(additions)
|
||||
tag = strings.spans[strings.root].name.decode().rsplit(":", 1)
|
||||
prefix = tag[0] + ":" if len(tag) == 2 else ""
|
||||
text_value = escape(value, {"\r": " "})
|
||||
additions.append((f'<{prefix}si><{prefix}t xml:space="preserve">'
|
||||
f'{text_value}</{prefix}t></{prefix}si>').encode("utf-8"))
|
||||
cell.set("t", "s")
|
||||
content = ET.Element(f'{{{NS["s"]}}}v')
|
||||
content.text = str(string_indices[value])
|
||||
cell.insert(0, content)
|
||||
else:
|
||||
cell.set("t", "inlineStr")
|
||||
content = ET.Element(f'{{{NS["s"]}}}is')
|
||||
text = ET.SubElement(content, f'{{{NS["s"]}}}t')
|
||||
text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
||||
text.text = value
|
||||
cell.insert(0, content)
|
||||
elif value is not None:
|
||||
content = ET.Element(f'{{{NS["s"]}}}v')
|
||||
content.text = str(value)
|
||||
cell.insert(0, content)
|
||||
shared_count += int(cell.get("t") == "s") - int(kind == "s")
|
||||
replacement = ET.tostring(cell, encoding="utf-8")
|
||||
if node is not None:
|
||||
xml.replace(node, replacement)
|
||||
else:
|
||||
row_num = re.search(r"[0-9]+$", address).group()
|
||||
row = next((row for row in xml.root.findall("s:sheetData/s:row", NS)
|
||||
if row.get("r") == row_num), None)
|
||||
if row is None:
|
||||
raise RuntimeError(f"Chart workbook row {sheet}!{row_num} is missing; repair the source workbook")
|
||||
column = re.match(r"[A-Z]+", address).group()
|
||||
following = [cell for cell in row.findall("s:c", NS)
|
||||
if (len(re.match(r"[A-Z]+", cell.get("r")).group()),
|
||||
re.match(r"[A-Z]+", cell.get("r")).group()) > (len(column), column)]
|
||||
if following:
|
||||
anchor = xml.spans[following[0]].start
|
||||
xml.edits.append((anchor, anchor, replacement))
|
||||
else:
|
||||
xml.append(row, replacement)
|
||||
if not any(xml.edits for xml in sheets_xml.values()):
|
||||
return data
|
||||
for sheet, xml in sheets_xml.items():
|
||||
if xml.edits:
|
||||
parts[sheets[sheet]] = xml.render()
|
||||
if strings is not None and (additions or shared_count != old_shared_count):
|
||||
strings.attributes(strings.root, {"count": str(shared_count),
|
||||
"uniqueCount": str(len(string_values) + len(additions))})
|
||||
if additions:
|
||||
strings.append(strings.root, b"".join(additions))
|
||||
parts[strings_part] = strings.render()
|
||||
output = io.BytesIO()
|
||||
with ZipFile(output, "w") as result:
|
||||
for info in archive.infolist():
|
||||
result.writestr(info, parts[info.filename])
|
||||
result.comment = archive.comment
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def patch_chart_package(source: dict, baseline: dict, edited: dict) -> dict:
|
||||
"""Clone a validated source graph and patch only data in its chart/workbook."""
|
||||
from . import _decode_source_chart_package
|
||||
|
||||
validate_chart_payload(edited)
|
||||
decoded = _decode_source_chart_package({"source_package": source})
|
||||
if decoded is None:
|
||||
raise RuntimeError("Imported chart has no source package; reimport the source deck")
|
||||
chart_part, _frame, parts, _types = decoded
|
||||
parts[chart_part], cells = _patch_chart(parts[chart_part], baseline, edited)
|
||||
chart_root = ET.fromstring(parts[chart_part])
|
||||
external = chart_root.find("c:externalData", NS)
|
||||
if cells and external is not None:
|
||||
rels_name = posixpath.join(posixpath.dirname(chart_part), "_rels", posixpath.basename(chart_part) + ".rels")
|
||||
rels = ET.fromstring(parts[rels_name])
|
||||
rel = next((rel for rel in rels if rel.get("Id") == external.get(f'{{{NS["r"]}}}id')), None)
|
||||
if rel is None:
|
||||
raise RuntimeError("Chart workbook relationship is missing; repair the source chart")
|
||||
workbook_part = _resolve(chart_part, rel.get("Target", ""))
|
||||
try:
|
||||
parts[workbook_part] = _patch_workbook(parts[workbook_part], cells)
|
||||
except (BadZipFile, KeyError, ValueError, ET.ParseError) as exc:
|
||||
raise RuntimeError(f"Cannot patch embedded chart workbook: {exc}; repair the source workbook") from exc
|
||||
|
||||
# Content-addressed copies keep other pages sharing the source graph immutable.
|
||||
digest = hashlib.sha256()
|
||||
for name, data in sorted(parts.items()):
|
||||
digest.update(name.encode() + b"\0" + hashlib.sha256(data).digest())
|
||||
suffix = "_data_" + digest.hexdigest()[:20]
|
||||
names = {name: posixpath.splitext(name)[0] + suffix + posixpath.splitext(name)[1]
|
||||
for name in parts if not name.endswith(".rels")}
|
||||
for name in parts:
|
||||
if not name.endswith(".rels"):
|
||||
continue
|
||||
parent, leaf = name.split("/_rels/", 1)
|
||||
owner = parent + "/" + leaf[:-5]
|
||||
new_owner = names[owner]
|
||||
names[name] = posixpath.join(posixpath.dirname(new_owner), "_rels", posixpath.basename(new_owner) + ".rels")
|
||||
rels = _XmlPatch(parts[name])
|
||||
for rel in rels.root:
|
||||
target = names[_resolve(owner, rel.get("Target", ""))]
|
||||
rels.attributes(rel, {"Target": posixpath.relpath(target, posixpath.dirname(new_owner))})
|
||||
parts[name] = rels.render()
|
||||
result = copy.deepcopy(source)
|
||||
result["chart_part"] = names[chart_part]
|
||||
for item in result["parts"]:
|
||||
data = parts[item["name"]]
|
||||
item.update(name=names[item["name"]], payload=base64.b64encode(data).decode("ascii"),
|
||||
sha256=hashlib.sha256(data).hexdigest())
|
||||
return result
|
||||
+27
-6
@@ -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)}"/>'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+20
-20
@@ -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", [])]
|
||||
|
||||
+38
-60
@@ -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/>"
|
||||
|
||||
+26
-7
@@ -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 "
|
||||
|
||||
+438
@@ -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"/>',
|
||||
)
|
||||
+347
@@ -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 & 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")
|
||||
+4
-3
@@ -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, "")
|
||||
|
||||
+200
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user