From 204f7f1a3860d480cf664f956e97ae5d41d62719 Mon Sep 17 00:00:00 2001 From: Evert Koks Date: Sun, 11 Oct 2026 09:26:58 +0200 Subject: [PATCH 1/2] Chart formatting through the library and edit_chart; golden p15 slide.add_chart takes radar_filled and bubble, category_order, gridlines and bubble_scale; Chart finds a series and a point by name and label as series_named and category_index do; edit_chart's handler runs the new formatting actions and add's scale and gridlines. Golden p15: a maturity gap chart and an effort-against-impact bubble matrix labelled R1-R6, 10 calls, 28/28. The every-definition guard rises to 11,200 (11,113), the core unchanged. Needs ooxml-edit 0.15.0; CI pins its branch temporarily (r3). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCxhY1fvWQPiWCwz7SYUVh --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 10 + docs/FEATURES.md | 2 +- docs/USAGE.md | 25 ++ pyproject.toml | 6 +- src/pptx_agent/edit/chart.py | 12 + src/pptx_agent/edit/creating.py | 19 +- src/pptx_agent/tools/SUPPORTED.md | 2 +- src/pptx_agent/tools/charts.py | 64 ++++- tests/golden/grading/TASK_p15.md | 13 ++ tests/golden/grading/check_p15.py | 138 +++++++++++ tests/golden/transcripts/p15.json | 372 ++++++++++++++++++++++++++++++ tests/golden_inputs.py | 48 +++- tests/test_chart_style.py | 187 +++++++++++++++ tests/test_golden.py | 8 +- tests/test_tools.py | 9 +- 16 files changed, 890 insertions(+), 29 deletions(-) create mode 100644 tests/golden/grading/TASK_p15.md create mode 100644 tests/golden/grading/check_p15.py create mode 100644 tests/golden/transcripts/p15.json create mode 100644 tests/test_chart_style.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a61e41..4b15c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,9 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install "ooxml-common @ git+https://github.com/uvrt/ooxml-common@main" - python -m pip install "ooxml-edit @ git+https://github.com/uvrt/ooxml-edit@main" + # TEMPORARY (r3): ooxml-edit's r3-chart-formatting branch (chart formatting, 0.15.0); + # back to @main once uvrt/ooxml-edit merges it. + python -m pip install "ooxml-edit @ git+https://github.com/uvrt/ooxml-edit@r3-chart-formatting" python -m pip install "pptx2svg[png] @ git+https://github.com/uvrt/pptx2svg@main" # The fonts a render draws with, as the README installs them: without them a runner # with no CJK font leaves the fixtures' Japanese out of every PNG (and says so). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6ef00..3c5ec60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ snapshot commit; the development history before it is summarised here. ## Unreleased +- Chart formatting (production feedback round 3, G1-G4; ooxml-edit 0.15.0): a chart's axis + scale (a 1-5 maturity axis by 1, categories top-down for a ranked bar chart), gridlines, + series and point colours, lines and markers, a column series drawn as a line (target + markers over actual columns), chart text, and points labelled with their own text -- + optionally from the workbook, as PowerPoint's Value From Cells -- through `Chart` and + `edit_chart`'s new actions; `slide.add_chart` takes `radar_filled` and `bubble`, + `category_order`, `gridlines` and `bubble_scale`. A series and a point are found by name + and label as `series_named` and `category_index` find them. The every-definition guard + rises to 11,200 (11,113 estimated, +646), the core unchanged. Golden p15: a maturity gap + chart and an effort-against-impact bubble matrix labelled R1-R6, 10 calls. - The outline lists each shape once: `slide.shapes` lists a group and then its members, and `to_outline` (and `ppt_read_slides` `outline`, `outline_blocks`, `find_text`, `replace_text`) walked the groups as well, so every grouped shape came twice. A group's diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e9e7bea..d652e9f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -13,7 +13,7 @@ Moved from the README. How to do each of these, with an example the tests run, i | Speaker notes: read, write, find, address (`/notes`) | ✅ | | Fills and outlines in theme colours; the theme's colours and fonts, resolved and set; which colours do which job, and their tints | ✅ | | Tables: cell text and formatting, rows, columns, merges, cells by label; rows as drawn, grown to fit their text, and how many fit on the slide (to split a long table) | ✅ | -| Charts: values, categories, series, titles, legend, with cache and embedded workbook together; series and points by label | ✅ | +| Charts: values, categories, series, titles, legend, with cache and embedded workbook together; series and points by label; axis scales, gridlines, colours, markers, combination charts, chart text, point labels; filled radar and bubble charts | ✅ | | SmartArt: node text, adding and removing nodes | ✅ | | Shapes, pictures, connectors that stay attached, groups, z-order | ✅ | | Comments: PowerPoint's threaded comments on a slide or a shape -- read, add, reply, resolve, reopen, edit, delete | ✅ | diff --git a/docs/USAGE.md b/docs/USAGE.md index 94c28d4..fd6e588 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -283,6 +283,31 @@ Series names and category labels are as strict as table labels (`LabelError`). A is always a position; to address a numeric category by label use `chart.category_index(2025)`. +**The look.** Axis scales, gridlines, colours, markers, a column series drawn as a line, +the chart's text and each point's own label, each one undo step; colours as shapes take +them (`tx2`, `accent1 lumMod=60% lumOff=40%`, `1F4E79`, `None` for none): + +```python +chart.set_axis_scale("value", minimum=0, maximum=5, major_unit=1) # a 1-5 maturity scale +chart.set_gridlines("value", major=False) +chart.format_series("Current", fill="tx2") +chart.format_point("Current", "Procure", fill="accent2") # highlight one column +chart.set_series_type("Target", "line") # targets over the columns +chart.format_series("Target", line=None, marker="diamond", marker_size=10, marker_fill="accent6") +chart.set_text_style(size=10) # all the chart's text + +ranked = slide.add_chart("bar", ["Worst", "Middle", "Best"], [{"name": "Score", "values": [1.8, 2.4, 3.1]}], + left, top, width, height, category_order="reversed") # reads top-down +matrix = slide.add_chart("bubble", effort, [{"name": "Recommendations", "values": impact, "size": priority}], + left, top, width, height, legend=None) +deck.shape(matrix.id).chart.set_point_labels("Recommendations", ["R1", "R2", "R3"], position="right", + cells=True) # as Value From Cells +``` + +Reversing an axis crosses the other at its maximum, as PowerPoint needs to keep it in place. +PowerPoint cannot combine horizontal bars with a line, so `set_series_type` refuses a bar +chart. + ### SmartArt ```python diff --git a/pyproject.toml b/pyproject.toml index 60e8cb8..0f32ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,8 +46,10 @@ classifiers = [ # edit (the goldens' bytes repeat), and list_documents gone from the shared tools. # 0.13.0: Toolbox(font_dirs=...) and DocumentFormat.configure; ooxml-common 0.8.0 resolves # them (office.user_font_dirs, OOXML_FONT_DIRS). 0.14.0: Call.unlocked (render and check -# on a snapshot, the lock let go) and slide ids in their slides. -dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.14.0", "markdown-it-py>=3.0", +# on a snapshot, the lock let go) and slide ids in their slides. 0.15.0: chart formatting +# (axis scales, colours, markers, combination charts, chart text, point labels, bubble and +# filled radar charts) and edit_chart's actions for it. +dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.15.0", "markdown-it-py>=3.0", "mdit-py-plugins>=0.4"] [project.optional-dependencies] diff --git a/src/pptx_agent/edit/chart.py b/src/pptx_agent/edit/chart.py index b0216dc..4b1ab20 100644 --- a/src/pptx_agent/edit/chart.py +++ b/src/pptx_agent/edit/chart.py @@ -309,6 +309,18 @@ def category_index(self, label) -> int: return find_label(label, enumerate(categories), what="category", where=f"{self.address} (chart)") + def _series_position(self, which, series) -> int: + """A series by name is found as :meth:`series_named` finds it (strictly).""" + if isinstance(which, str): + return self.series_named(which).index + return super()._series_position(which, series) + + def _point(self, root, ser, which) -> int: + """A point by category label is found as :meth:`category_index` finds it.""" + if isinstance(which, str): + return self.category_index(which) + return super()._point(root, ser, which) + def set_category(self, index, label) -> "Chart": """Relabel a category -- by position, or by its current label -- in every series' cache and in the workbook:: diff --git a/src/pptx_agent/edit/creating.py b/src/pptx_agent/edit/creating.py index e3916ef..73a3e5c 100644 --- a/src/pptx_agent/edit/creating.py +++ b/src/pptx_agent/edit/creating.py @@ -220,19 +220,25 @@ def build(identifier: int, local: str) -> Element: def add_chart(self, chart_type: str, categories, series, left: int, top: int, width: int, height: int, *, title: str | None = None, axis_titles: Mapping | None = None, legend: str | None = "default", number_format: str | None = None, - name: str | None = None) -> "Shape": + name: str | None = None, category_order: str = "normal", + gridlines: bool = True, bubble_scale: int | None = None) -> "Shape": """A new chart from data, in a graphic frame at ``left``/``top`` (EMU), as PowerPoint inserts one: the chart part and an embedded workbook that holds the same numbers, so Edit Data opens them (ooxml-edit's :func:`~ooxml_edit.charts.add_chart`). ``chart_type`` is ``column``, ``stacked_column``, ``bar``, ``stacked_bar``, ``line``, - ``pie``, ``scatter`` or ``radar``; ``categories`` the category labels (a scatter chart's x - values); ``series`` ``[{"name": ..., "values": [...]}]``, one value per category - (``None`` is a blank); data that cannot be charted raises + ``pie``, ``scatter``, ``radar``, ``radar_filled`` or ``bubble``; ``categories`` the + category labels (a scatter or bubble chart's x values); ``series`` ``[{"name": ..., + "values": [...]}]``, one value per category (``None`` is a blank) -- a column chart's + series may add ``"type": "line"`` (target markers over actual columns, with + :meth:`Chart.format_series`), a bubble chart's give ``"size"``; data that cannot be charted raises :class:`~pptx_agent.ChartDataError` before anything changes. ``title``, ``axis_titles`` (``{"category", "value"}``), ``legend`` (``bottom``, ``right``, ``top``, ``left``, ``top_right``, ``None``, or ``default``: PowerPoint's, at the bottom, a radar chart's at the top) and - ``number_format`` (an Excel format code for the values) are optional. It looks as + ``number_format`` (an Excel format code for the values) are optional; + ``category_order="reversed"`` lists a bar chart's categories top-down (the data and + the workbook keep their order), ``gridlines=False`` draws none, ``bubble_scale`` sizes + the bubbles in percent of PowerPoint's (100). It looks as PowerPoint's new chart of that type looks, in the deck's theme (measured: accents in order, 18.62 pt title, 11.97 pt labels, legend at the bottom -- a radar's at the top --, no fill). Edit it with :attr:`Shape.chart`. One undo step. @@ -258,7 +264,8 @@ def build(identifier: int, local: str) -> Element: categories, series, title=title, axis_titles=dict(axis_titles or {}), legend=legend, number_format=number_format, look=POWERPOINT_LOOK, - lang=language)) + lang=language, category_order=category_order, + gridlines=gridlines, bubble_scale=bubble_scale)) return authoring.chart_frame(identifier, label, local, left, top, width, height, made[-1].graphic()) diff --git a/src/pptx_agent/tools/SUPPORTED.md b/src/pptx_agent/tools/SUPPORTED.md index b0e796e..339599c 100644 --- a/src/pptx_agent/tools/SUPPORTED.md +++ b/src/pptx_agent/tools/SUPPORTED.md @@ -30,7 +30,7 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). | Z-order, group, ungroup, duplicate, delete | `ppt_arrange` | | | Pictures: insert, replace | `ppt_add_picture` | from an image blob; replacing keeps the frame, height or width | | Tables: new; cells, rows, columns, merges, widths, heights, fill, borders; style and Table Design flags | `ppt_add_table`, `ppt_edit_table` (`set_style`, `set_flags`) | cells by row and column or by their labels; a style by name or id, the deck's own (a template's) or any of PowerPoint's 74 built-in styles; header row, total row, first and last column, banded rows and columns | -| Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines, as PowerPoint inserts one; not the filled or marker radar); the embedded workbook changes with the chart | +| Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width, axis scales, gridlines, series and point colours and markers, a column series as a line, chart text, point labels | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines), filled radar, bubble, as PowerPoint inserts each; point labels from the workbook as PowerPoint's Value From Cells; the embedded workbook changes with the chart | | SmartArt: node text, adding and removing nodes | `edit_smartart` | diagrams already in the deck; PowerPoint lays the diagram out again on opening | | The theme's colours and fonts | `ppt_set_theme` | every theme reference in the deck follows | | Document properties | `set_properties` | title, author, language, subject | diff --git a/src/pptx_agent/tools/charts.py b/src/pptx_agent/tools/charts.py index ba76109..3eb4dd6 100644 --- a/src/pptx_agent/tools/charts.py +++ b/src/pptx_agent/tools/charts.py @@ -5,6 +5,7 @@ from typing import Any +from ooxml_edit.charts.actions import STYLE_ACTIONS, apply_style_action, chart_style_json from ooxml_edit.tools import Result, ToolError, shared from .common import KIND, box_emu, library_errors, shape as shape_at, slide as slide_at @@ -49,6 +50,19 @@ def _category(chart, which: str | None): return chart.category_index(which) +def _point(chart, which: str) -> int: + """A point by category label, or position from 0 (a scatter chart's x values are + labels too).""" + labels = [str(c) for c in chart.categories] + if which in labels: + hits = [k for k, label in enumerate(labels) if label == which] + if len(hits) == 1: + return hits[0] + if which.isdigit() and int(which) < len(labels): + return int(which) + return _category(chart, which) + + def chart_json(frame, chart) -> dict[str, Any]: data = {"address": frame.id, "type": chart.chart_type, "title": chart.title, "categories": list(chart.categories), @@ -61,7 +75,7 @@ def chart_json(frame, chart) -> dict[str, Any]: def _add(call, target, chart_type, categories, data, box, width, values, number_format, text, - position, ref) -> Result: + position, ref, axis=None, scale=None, gridlines=None) -> Result: """``action: "add"``: a new chart on the slide ``target``, in ``box``.""" if width is not None: raise ToolError("invalid_arguments", "width is for documents; on a slide give box", @@ -69,25 +83,41 @@ def _add(call, target, chart_type, categories, data, box, width, values, number_ for name, given in (("chart_type", chart_type), ("data", data), ("box", box)): if given is None: raise ToolError("invalid_arguments", f"add needs {name}", field=name) - if chart_type == "scatter": + if chart_type in ("scatter", "bubble"): if values is None or categories is not None: - raise ToolError("invalid_arguments", "a scatter chart takes its x values in values, " - "not categories", field="values") + raise ToolError("invalid_arguments", f"a {chart_type} chart takes its x values in " + "values, not categories", field="values") labels = list(values) else: if categories is None: raise ToolError("invalid_arguments", "add needs categories", field="categories") if values is not None: - raise ToolError("invalid_arguments", "values are a scatter chart's x values; give " - "each series' values in data", field="values") + raise ToolError("invalid_arguments", "values are a scatter or bubble chart's x " + "values; give each series' values in data", field="values") labels = list(categories) if len(labels) * max(len(data), 1) > 10_000: raise ToolError("limit", "at most 10,000 chart values", field="data") host = slide_at(call.document, target, field="target") x, y, w, h = box_emu(box) + if position not in (None, "none", "right", "left", "top", "bottom"): + raise ToolError("invalid_arguments", f"{position} is a label position; a legend is " + "right, left, top, bottom or none", field="position") + if scale is not None and axis is None: + raise ToolError("invalid_arguments", "add with scale needs axis", field="axis") legend = None if position == "none" else (position or "default") - frame = host.add_chart(chart_type, labels, [dict(entry) for entry in data], x, y, w, h, - title=text, legend=legend, number_format=number_format) + reverse = (scale or {}).get("reverse") and axis == "category" + rest = {k: v for k, v in (scale or {}).items() if not (reverse and k == "reverse")} + with call.document.batch(): # the chart and its scale: one undo step + frame = host.add_chart(chart_type, labels, [dict(entry) for entry in data], x, y, w, h, + title=text, legend=legend, number_format=number_format, + category_order="reversed" if reverse else "normal", + gridlines=gridlines != "none") + chart = call.document.shape(frame.id).chart + if rest: + apply_style_action(chart, "set_axis_scale", axis=axis, scale=rest) + if gridlines not in (None, "major", "none"): + apply_style_action(chart, "set_gridlines", axis="value" if chart_type not in + ("scatter", "bubble") else "y", gridlines=gridlines) if ref: call.define_ref(ref, frame.id) call.touch(host.slide_id) @@ -99,6 +129,7 @@ def _read(frame, chart) -> Result: data = chart_json(frame, chart) data["legend"] = chart.has_legend data["axes"] = {axis: chart.axis_title(axis) for axis in chart.axes} + data.update(chart_style_json(chart)) data["workbook"] = chart.workbook_values() return Result(summary=f"Chart {frame.id}: {len(data['series'])} series, " f"{len(data['categories'])} categories", data=data) @@ -108,13 +139,26 @@ def _read(frame, chart) -> Result: @library_errors def edit_chart(call, doc, target, action, chart_type=None, categories=None, data=None, box=None, width=None, number_format=None, series=None, category=None, values=None, - value=None, text=None, axis=None, position=None, ref=None): + value=None, text=None, axis=None, position=None, ref=None, scale=None, + gridlines=None, style=None, part=None, labels=None, cells=None, secondary=None): if action == "add": return _add(call, target, chart_type, categories, data, box, width, values, - number_format, text, position, ref) + number_format, text, position, ref, axis, scale, gridlines) frame, chart = _chart(call.document, target) if action == "read": return _read(frame, chart) + if action in STYLE_ACTIONS: + chosen = None if series is None else _series(chart, series).index + point = None if category is None or chosen is None else _point(chart, category) + apply_style_action(chart, action, series=chosen, category=point, axis=axis, + scale=scale, gridlines=gridlines, style=style, part=part, + labels=labels, cells=cells, position=position, + chart_type=chart_type, secondary=secondary) + call.touch(frame._slide.slide_id) + fresh = call.document.shape(frame.id).chart + data = chart_json(frame, fresh) + data.update(chart_style_json(fresh)) + return Result(summary=f"{action} on chart {frame.id}", changed=[frame.id], data=data) def need(name, given): if given is None: diff --git a/tests/golden/grading/TASK_p15.md b/tests/golden/grading/TASK_p15.md new file mode 100644 index 0000000..c58bc4e --- /dev/null +++ b/tests/golden/grading/TASK_p15.md @@ -0,0 +1,13 @@ +# Task: chart the maturity gap and the recommendations + +`input/maturity-assessment.pptx` is a four-slide maturity assessment of warehouse operations. The panel's scores are in `input/maturity-scores.csv` (one row per capability: the current score and the target, on a scale from 1 to 5), and its recommendations in `input/recommendations.csv` (an id, a name, and the effort, impact and priority each was rated). + +Add two slides after "Approach" ("Next steps" moves to slide 6): + +- **Slide 4, the maturity gap:** the current scores as columns in the deck's second text colour (`tx2`), with the lowest-scoring capability highlighted in an accent colour; the targets as diamond markers on the same axes, with no line between them. The value axis runs from 0 to 5 in steps of 1, with no gridlines; all the chart's text is 10 pt. The capabilities in the CSV's order, the CSV's values exactly, and Edit Data agrees with what the chart draws. +- **Slide 5, effort against impact:** a bubble chart with each recommendation at its effort (x) and impact (y), sized by its priority, and labelled with its id (R1 to R6) beside the bubble. Both axes from 0 to 5, titled "Effort" and "Impact". +- Each slide has **a one-line takeaway title** (a finding, not a label). +- The charts fill the content area below their titles; nothing overlaps or leaves the slide. +- The other slides do not change. + +Save the result as `work/maturity-assessment.pptx`. diff --git a/tests/golden/grading/check_p15.py b/tests/golden/grading/check_p15.py new file mode 100644 index 0000000..e03ac05 --- /dev/null +++ b/tests/golden/grading/check_p15.py @@ -0,0 +1,138 @@ +"""P15: a maturity gap chart (columns in tx2 with one point highlighted, targets as diamond +markers, a 0-5 axis by 1, no gridlines, 10 pt text) and an effort-against-impact bubble +matrix labelled R1-R6, from two CSV files.""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # common.py beside it +from common import INPUTS, main, norm, title_of # noqa: E402 + +from pptx_agent import Document # noqa: E402 + +FOLDER = Path(os.environ.get("GOLDEN_INPUTS", str(INPUTS))) / "p15-maturity-charts" / "input" +C = "{http://schemas.openxmlformats.org/drawingml/2006/chart}" +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +EMU = 12700 + + +def rows(name: str) -> list[list[str]]: + text = (FOLDER / name).read_text(encoding="utf-8") + return [line.split(",") for line in text.split("\n") if line] + + +def takeaway(c, slide, label: str, words) -> None: + title = next((sh for sh in slide.shapes if sh.placeholder + and sh.placeholder[0] in ("title", "ctrTitle")), None) + text = title.text if title is not None else "" + fit = title.text_fit() if title is not None else None + c.check(f"{label}: a one-line title that fits", bool(norm(text)) and "\n" not in text + and fit is not None and tuple(fit.lines) == (1,) and not fit.overflows, (text, fit)) + c.check(f"{label}: a takeaway, not a label", any(w.lower() in text.lower() for w in words) + and norm(text).lower() not in {"maturity gap", "maturity scores", "effort vs impact", + "effort versus impact", "recommendations"}, text) + return title + + +def frame_of(c, deck, slide, label: str, number: int, title): + frames = [s for s in slide.shapes if s.kind == "graphic_frame" and s.chart is not None] + c.check(f"{label}: one chart", len(frames) == 1, len(frames)) + if not frames: + return None + frame = frames[0] + left, top, width, height = slide.content_area + page_w, page_h = deck.slide_size + c.check(f"{label}: below the title, on the slide, filling the content area", + frame.top >= title.top + title.height - EMU and frame.top + frame.height <= page_h + and frame.left + frame.width <= page_w and frame.left >= 0 + and frame.width * frame.height >= 0.6 * width * height, + (frame.left, frame.top, frame.width, frame.height)) + c.check(f"{label}: nothing overlaps or leaves the slide", not deck.overflows(slides=[number]), + deck.overflows(slides=[number])) + return frame + + +def body(c, out: Path): + scores = rows("maturity-scores.csv")[1:] + capabilities = [r[0] for r in scores] + current = [float(r[1]) for r in scores] + target = [float(r[2]) for r in scores] + recs = rows("recommendations.csv")[1:] + deck = Document.open(out) + src = Document.open(FOLDER / "maturity-assessment.pptx") + c.check("validate() clean", not deck.validate(), [str(p) for p in deck.validate()]) + titles = [title_of(s) for s in deck.slides] + c.check("6 slides, the new ones 4th and 5th", len(titles) == 6 and titles[2] == "Approach" + and titles[5] == "Next steps", titles) + if len(titles) != 6: + return + + # -- the maturity gap ------------------------------------------------------------------ + slide = deck.slides[3] + title = takeaway(c, slide, "gap", ["target", "gap", "below", "procure", "behind", "short"]) + frame = frame_of(c, deck, slide, "gap", 4, title) + if frame is not None: + chart = frame.chart + root = chart._root() + c.check("gap: columns and a line plot on the same axes", chart.chart_types == ["bar", "line"] + and [n.get("val") for n in root.find(f".//{C}barChart").findall(f"{C}axId")] + == [n.get("val") for n in root.find(f".//{C}lineChart").findall(f"{C}axId")], + chart.chart_types) + c.check("gap: capabilities in order", list(chart.categories) == capabilities, chart.categories) + got = {s.name: [float(v) for v in s.values] for s in chart.series} + c.check("gap: CSV values exactly", got == {"Current": current, "Target": target}, got) + book = chart.workbook_values() + c.check("gap: Edit Data holds the drawn values", book["categories"] == capabilities + and {s["name"]: s["values"] for s in book["series"]} == got, book) + scale = chart.axis_scale("value") + c.check("gap: value axis 0-5 by 1, no gridlines", (scale["minimum"], scale["maximum"], + scale["major_unit"]) == (0, 5, 1) and not scale["major_gridlines"] + and not scale["minor_gridlines"], scale) + look = chart.series_format("Current") + lowest = current.index(min(current)) + point = look.get("points", {}).get(lowest, {}) + c.check("gap: current in tx2, the lowest highlighted in an accent", look.get("fill") == "tx2" + and str(point.get("fill", "")).startswith("accent") + and set(look.get("points", {})) == {lowest}, look) + marks = chart.series_format("Target") + c.check("gap: targets as diamond markers with no line", marks.get("marker") == "diamond" + and marks.get("line") is None and "line" in marks, marks) + sizes = {n.get("sz") for n in root.iter(f"{A}defRPr", f"{A}rPr") if n.get("sz")} + c.check("gap: all text 10 pt", sizes == {"1000"}, sizes) + + # -- effort against impact --------------------------------------------------------------- + slide = deck.slides[4] + title = takeaway(c, slide, "matrix", ["quick", "win", "R1", "R4", "low effort", "first", + "impact"]) + frame = frame_of(c, deck, slide, "matrix", 5, title) + if frame is not None: + chart = frame.chart + root = chart._root() + c.check("matrix: a bubble chart", chart.chart_types == ["bubble"], chart.chart_types) + ser = root.find(f".//{C}bubbleChart/{C}ser") + + def numbers(tag): + return [float(p.find(f"{C}v").text) for p in ser.find(tag).iter(f"{C}pt")] + c.check("matrix: effort, impact and priority exactly", + numbers(f"{C}xVal") == [float(r[2]) for r in recs] + and numbers(f"{C}yVal") == [float(r[3]) for r in recs] + and numbers(f"{C}bubbleSize") == [float(r[4]) for r in recs], + (numbers(f"{C}xVal"), numbers(f"{C}yVal"))) + labels = chart.point_labels(0) + c.check("matrix: labelled R1-R6", labels == [r[0] for r in recs], labels) + place = {n.get("val") for n in ser.iter(f"{C}dLblPos")} + c.check("matrix: labels beside the bubbles", place and place <= {"r", "l"}, place) + scales = chart.scales + c.check("matrix: both axes 0-5", all((scales[a]["minimum"], scales[a]["maximum"]) == (0, 5) + for a in ("x", "y")), scales) + c.check("matrix: axes titled Effort and Impact", (chart.axis_title("category"), + chart.axis_title("value")) == ("Effort", "Impact"), + (chart.axis_title("category"), chart.axis_title("value"))) + + same = all(deck.to_outline(slides=[i]).split("\n", 1)[1] == src.to_outline(slides=[j]).split("\n", 1)[1] + for i, j in ((1, 1), (2, 2), (3, 3), (6, 4))) + c.check("other slides unchanged", same) + + +if __name__ == "__main__": + main("p15-maturity-charts", body) diff --git a/tests/golden/transcripts/p15.json b/tests/golden/transcripts/p15.json new file mode 100644 index 0000000..9a158c2 --- /dev/null +++ b/tests/golden/transcripts/p15.json @@ -0,0 +1,372 @@ +{ + "task": "p15-maturity-charts", + "inputs": [ + { + "file": "input/maturity-assessment.pptx", + "as": "document", + "sha256": "0f6e0d0c995f7bf459ba4de1dc5988ed9a73fdc7f9524f586b86d8b8d6539796", + "content_sha256": "4a86de71831e4d952a3c12d4876c73ebbcde4e9fe8255563a0722c53f8b8d3db" + }, + { + "file": "input/maturity-scores.csv", + "as": "blob", + "sha256": "b71d01bb80b64c1031986d6b726d2605d9e15d4231bbe64fe0e91cfc34853ce2" + }, + { + "file": "input/recommendations.csv", + "as": "blob", + "sha256": "0745dab3117bd0986f322a5a8824815d663fae1eaa02a11b8d05b24ec0452cf7" + } + ], + "calls": [ + { + "tool": "describe", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "read_blob", + "arguments": { + "blob": "b1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "read_blob", + "arguments": { + "blob": "b2" + }, + "expect": { + "ok": true + } + }, + { + "tool": "ppt_add_slide", + "arguments": { + "doc": "d1", + "layout": "Title Only", + "at": 4, + "title": "All nine capabilities miss target", + "ref": "gap_slide" + }, + "expect": { + "ok": true, + "created": [ + "s:260" + ], + "refs": { + "gap_slide": "s:260" + } + } + }, + { + "tool": "batch", + "arguments": { + "ops": [ + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$gap_slide", + "action": "add", + "chart_type": "column", + "categories": [ + "Governance", + "People", + "Data", + "Technology", + "Request", + "Procure", + "Deploy", + "Monitor", + "Retire" + ], + "data": [ + { + "name": "Current", + "values": [ + 2.13, + 2.1, + 2.17, + 2.33, + 2.33, + 2.08, + 2.25, + 2.19, + 2.33 + ] + }, + { + "name": "Target", + "values": [ + 4, + 3, + 4, + 3, + 3, + 4, + 3, + 4, + 3 + ], + "type": "line" + } + ], + "box": { + "x": 66, + "y": 144, + "w": 828, + "h": 367 + }, + "axis": "value", + "scale": { + "min": 0, + "max": 5, + "major_unit": 1 + }, + "gridlines": "none", + "position": "bottom", + "ref": "gap" + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$gap", + "action": "format_series", + "series": "Current", + "style": { + "fill": "tx2" + } + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$gap", + "action": "format_point", + "series": "Current", + "category": "Procure", + "style": { + "fill": "accent2" + } + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$gap", + "action": "format_series", + "series": "Target", + "style": { + "line": "none", + "marker": "diamond", + "marker_size": 10, + "marker_fill": "accent6" + } + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$gap", + "action": "set_text_style", + "style": { + "size": 10 + } + } + } + ] + }, + "expect": { + "ok": true, + "created": [ + "260.3" + ], + "refs": { + "gap": "260.3" + } + } + }, + { + "tool": "ppt_add_slide", + "arguments": { + "doc": "d1", + "layout": "Title Only", + "at": 5, + "title": "R1 and R4 are quick wins", + "ref": "matrix_slide" + }, + "expect": { + "ok": true, + "created": [ + "s:261" + ], + "refs": { + "matrix_slide": "s:261" + } + } + }, + { + "tool": "batch", + "arguments": { + "ops": [ + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$matrix_slide", + "action": "add", + "chart_type": "bubble", + "values": [ + 1.0, + 2.0, + 4.0, + 1.5, + 3.5, + 4.5 + ], + "data": [ + { + "name": "Recommendations", + "values": [ + 4.5, + 3.0, + 4.0, + 1.5, + 2.0, + 1.0 + ], + "size": [ + 3, + 2, + 3, + 1, + 2, + 1 + ] + } + ], + "box": { + "x": 66, + "y": 144, + "w": 828, + "h": 367 + }, + "axis": "x", + "scale": { + "min": 0, + "max": 5, + "major_unit": 1 + }, + "position": "none", + "ref": "matrix" + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$matrix", + "action": "set_axis_scale", + "axis": "y", + "scale": { + "min": 0, + "max": 5, + "major_unit": 1 + } + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$matrix", + "action": "set_point_labels", + "series": "Recommendations", + "labels": [ + "R1", + "R2", + "R3", + "R4", + "R5", + "R6" + ], + "position": "right", + "cells": true + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$matrix", + "action": "set_axis_title", + "axis": "category", + "text": "Effort" + } + }, + { + "tool": "edit_chart", + "arguments": { + "doc": "d1", + "target": "$matrix", + "action": "set_axis_title", + "axis": "value", + "text": "Impact" + } + } + ] + }, + "expect": { + "ok": true, + "created": [ + "261.3" + ], + "refs": { + "matrix": "261.3" + } + } + }, + { + "tool": "check", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "save_document", + "arguments": { + "doc": "d1", + "name": "maturity-assessment.pptx", + "format": "pptx" + }, + "expect": { + "ok": true + } + } + ], + "output": { + "name": "maturity-assessment.pptx", + "sha256": "16aae5c9fa866a4783a7c27cb53fa862af4904f53d0c5e6fd18ce836c2dee7db", + "calls": 9, + "content_sha256": "343530cdd038a4ec3282de57c9414df56c86155faac545e94e0137ae1f968031" + }, + "check": { + "total": 28, + "passed": 28 + } +} diff --git a/tests/golden_inputs.py b/tests/golden_inputs.py index d005ca3..91ab768 100644 --- a/tests/golden_inputs.py +++ b/tests/golden_inputs.py @@ -1,6 +1,7 @@ """Build the inputs of the trial tasks p2-p6, of the spike's graphics tasks p7, p8, o1 and -m1, of p10 (a review in comments), p11 (SmartArt), p12 (a rebrand through the theme) and p14 -(a chart and a table on a branded template), for the golden transcripts (test_golden.py). +m1, of p10 (a review in comments), p11 (SmartArt), p12 (a rebrand through the theme), p14 +(a chart and a table on a branded template) and p15 (a maturity gap chart and an +effort-against-impact matrix), for the golden transcripts (test_golden.py). The trial's own builder (recovered from its coordinator's transcript), run against this repository's fixtures, writes ``//input/...``. p1's template is the committed @@ -551,6 +552,46 @@ def p14() -> None: deck.save(out("p14-brand-chart-table") / "business-review-2026.pptx") +# ---------------------------------------------------------------- P15 maturity charts +P15_TEXT = { + "256.2": "Warehouse operations", + "256.3": "Maturity assessment, October 2026", + "257.2": "Scope", + "257.3": "Nine capabilities across the three distribution centres\n" + "Scored by the operations panel from 1 (ad hoc) to 5 (optimised)", + "258.2": "Approach", + "258.3": "Interviews and site visits at Tilburg, Venlo and Ghent\n" + "Targets set with the regional directors for the end of 2027\n" + "Six recommendations rated for effort, impact and priority", + "259.2": "Next steps", + "259.3": "Confirm the 2027 targets with the steering committee\n" + "Start the first two recommendations in November", +} +P15_MATURITY = ("Capability,Current,Target\nGovernance,2.13,4\nPeople,2.10,3\nData,2.17,4\n" + "Technology,2.33,3\nRequest,2.33,3\nProcure,2.08,4\nDeploy,2.25,3\n" + "Monitor,2.19,4\nRetire,2.33,3\n") +P15_RECOMMENDATIONS = ( + "Id,Recommendation,Effort,Impact,Priority\n" + "R1,Appoint a process owner per site,1.0,4.5,3\n" + "R2,Shared slotting rules,2.0,3.0,2\n" + "R3,Replace the yard scheduling tool,4.0,4.0,3\n" + "R4,Monthly KPI review,1.5,1.5,1\n" + "R5,Supplier portal for inbound bookings,3.5,2.0,2\n" + "R6,Automated returns sorting,4.5,1.0,1\n") + + +def p15() -> None: + """The vendor-selection deck rewritten as a maturity assessment, with the panel's scores + and the recommendations as CSV files.""" + deck = Document.open(FIX / "generated" / "trial" / "wms-vendor-selection.pptx") + for address, text in P15_TEXT.items(): + deck.shape(address).set_text(text) + folder = out("p15-maturity-charts") + deck.save(folder / "maturity-assessment.pptx") + (folder / "maturity-scores.csv").write_bytes(P15_MATURITY.encode("utf-8")) + (folder / "recommendations.csv").write_bytes(P15_RECOMMENDATIONS.encode("utf-8")) + + BUILDERS = {"p2-quarterly-update": p2, "p3-split-slide": p3, "p4-process-diagram": p4, "p5-rebrand": p5, "p6-restructure-deck": p6, "p7-phased-approach": halden("p7-phased-approach"), @@ -558,7 +599,8 @@ def p14() -> None: "o1-governance-chart": halden("o1-governance-chart", (2, "Programme governance")), "m1-priority-matrix": halden("m1-priority-matrix", (3, "Prioritisation")), "p10-review-comments": p10, "p11-update-smartart": p11, - "p12-theme-rebrand": p12, "p14-brand-chart-table": p14} + "p12-theme-rebrand": p12, "p14-brand-chart-table": p14, + "p15-maturity-charts": p15} #: Builders that draw from scratch what is otherwise committed (``--draw``). diff --git a/tests/test_chart_style.py b/tests/test_chart_style.py new file mode 100644 index 0000000..b578088 --- /dev/null +++ b/tests/test_chart_style.py @@ -0,0 +1,187 @@ +"""A chart's look on a slide: axis scales, gridlines, series and point colours and markers, +combination charts, chart text and point labels (ooxml-edit's ``Chart`` formatting, tested +there), filled radar and bubble charts -- through the library and through ``edit_chart``. + +Here: the deck stays valid, labels and names are found as the deck finds them, every call is +one undo step, and the tool's arguments reach the library. +""" + +from __future__ import annotations + +import pytest + +from ooxml_edit.tools import Toolbox +from pptx_agent import Document +from pptx_agent.tools import FORMAT, GROUPS, TOOLS + +C = "{http://schemas.openxmlformats.org/drawingml/2006/chart}" +EMU = 12700 +CATEGORIES = ["Governance", "People", "Data", "Technology", "Procure"] +CURRENT = [2.13, 2.10, 2.17, 2.33, 2.08] +TARGET = [4, 3, 4, 3, 4] + + +def gap_chart(): + deck = Document.new() + slide = deck.add_slide(deck.layouts[5]) + frame = slide.add_chart("column", CATEGORIES, [{"name": "Current", "values": CURRENT}, + {"name": "Target", "values": TARGET}], + 40 * EMU, 120 * EMU, 880 * EMU, 380 * EMU) + return deck, deck.shape(frame.id).chart + + +def test_the_maturity_gap_chart_through_the_library(): + deck, chart = gap_chart() + chart.set_axis_scale("value", minimum=0, maximum=5, major_unit=1) + chart.format_series("Current", fill="tx2") + chart.format_point("Current", "Procure", fill="accent2") + chart.set_series_type("Target", "line") + chart.format_series("target", line=None, marker="diamond", marker_size=10, + marker_fill="accent6") # names found as series_named finds them + chart.set_text_style(size=10) + chart.set_gridlines("value", major=False) + assert chart.chart_types == ["bar", "line"] + assert chart.series_format("Current")["points"] == {4: {"fill": "accent2", "line": None}} + assert chart.workbook_values()["series"][1] == {"name": "Target", "values": TARGET} + assert deck.validate() == [] + reopened = Document.open(deck.to_bytes()) + again = reopened.shape(chart.address).chart + assert again.scales["value"]["maximum"] == 5 + for _ in range(7): + deck.undo() + assert deck.shape(chart.address).chart.chart_types == ["bar"] + + +def test_a_category_label_no_chart_has_is_a_label_error(): + from pptx_agent import LabelError + + _, chart = gap_chart() + with pytest.raises(LabelError): + chart.format_point("Current", "Nowhere", fill="accent2") + + +@pytest.mark.parametrize("kind", ["radar_filled", "bubble"]) +def test_the_new_kinds_on_a_slide(kind): + deck = Document.new() + slide = deck.add_slide(deck.layouts[5]) + if kind == "bubble": + frame = slide.add_chart("bubble", [1, 2, 4], [{"name": "Recs", "values": [4.5, 3, 4], + "size": [3, 2, 1]}], + 0, 0, 400 * EMU, 300 * EMU, bubble_scale=60) + else: + frame = slide.add_chart("radar_filled", ["Speed", "Cost", "Risk"], + [{"name": "Now", "values": [3, 2, 4]}], 0, 0, 400 * EMU, + 300 * EMU) + chart = deck.shape(frame.id).chart + assert chart.chart_type == ("bubble" if kind == "bubble" else "radar") + assert deck.validate() == [] + + +def test_a_ranked_bar_chart_reads_top_down(): + deck = Document.new() + slide = deck.add_slide(deck.layouts[5]) + frame = slide.add_chart("bar", ["Worst", "Middle", "Best"], + [{"name": "Score", "values": [1.8, 2.4, 3.1]}], 0, 0, 400 * EMU, + 300 * EMU, category_order="reversed", gridlines=False) + chart = deck.shape(frame.id).chart + assert chart.axis_scale("category")["reverse"] + assert chart.categories == ["Worst", "Middle", "Best"] + assert chart.workbook_values()["categories"] == ["Worst", "Middle", "Best"] + assert not list(chart._root().iter(f"{C}majorGridlines")) + + +# -- the tool ------------------------------------------------------------------------------------- + + +@pytest.fixture() +def session(): + deck = Document.new() + deck.add_slide(deck.layouts[5]) + with Toolbox(TOOLS, formats=[FORMAT], groups=GROUPS) as box: + current = box.session() + current.open(deck.to_bytes(), "deck.pptx") + yield box, current + + +def run(session, **arguments): + box, current = session + result = box.dispatch(current, "edit_chart", {"doc": "d1", **arguments}) + assert result.ok, result.to_json() + return result + + +def refused(session, **arguments): + box, current = session + result = box.dispatch(current, "edit_chart", {"doc": "d1", **arguments}) + assert not result.ok + return result.error + + +BOX = {"x": 40, "y": 120, "w": 880, "h": 380} + + +def test_edit_chart_builds_the_maturity_gap_chart(session): + run(session, target="s:256", action="add", chart_type="column", categories=CATEGORIES, + data=[{"name": "Current", "values": CURRENT}, + {"name": "Target", "values": TARGET, "type": "line"}], + box=BOX, axis="value", scale={"min": 0, "max": 5, "major_unit": 1}, gridlines="none", + ref="gap") + run(session, target="$gap", action="format_series", series="Current", + style={"fill": "tx2"}) + run(session, target="$gap", action="format_point", series="Current", category="Procure", + style={"fill": "accent2"}) + run(session, target="$gap", action="format_series", series="Target", + style={"line": "none", "marker": "diamond", "marker_size": 10, "marker_fill": "accent6"}) + styled = run(session, target="$gap", action="set_text_style", style={"size": 10}) + assert styled.data["types"] == ["bar", "line"] + read = run(session, target="$gap", action="read") + assert read.data["scales"]["value"] == {"minimum": 0, "maximum": 5, "major_unit": 1, + "minor_unit": None, "number_format": None, + "reverse": False, "major_gridlines": False, + "minor_gridlines": False} + assert read.data["series_format"]["Target"]["marker"] == "diamond" + assert read.data["series_format"]["Current"]["points"] == {4: {"fill": "accent2", + "line": None}} + box, current = session + assert current.entry("d1").document.validate() == [] + + +def test_edit_chart_reverses_categories_and_labels_points(session): + added = run(session, target="s:256", action="add", chart_type="bar", + categories=["Worst", "Middle", "Best"], + data=[{"name": "Score", "values": [1.8, 2.4, 3.1]}], box=BOX, axis="category", + scale={"reverse": True}, ref="r") + read = run(session, target="$r", action="read") + assert read.data["scales"]["category"]["reverse"] is True + assert added.data["categories"] == ["Worst", "Middle", "Best"] + run(session, target="s:256", action="add", chart_type="bubble", values=[1, 2, 4], + data=[{"name": "Recs", "values": [4.5, 3, 4], "size": [3, 2, 1]}], box=BOX, + position="none", ref="b") + labelled = run(session, target="$b", action="set_point_labels", series="Recs", + labels=["R1", "R2", "R3"], position="right", cells=True) + assert labelled.data["point_labels"] == {"Recs": ["R1", "R2", "R3"]} + run(session, target="$b", action="set_axis_scale", axis="x", + scale={"min": 0, "max": 5, "auto": ["major_unit"]}) + + +@pytest.mark.parametrize("arguments, words", [ + ({"action": "set_series_type", "series": "Score", "chart_type": "line"}, "horizontal bars"), + ({"action": "format_series", "series": "Score", "style": {"size": 10}}, "not for"), + ({"action": "format_series", "series": "Score", "style": {"fill": "purple"}}, "neither"), + ({"action": "set_axis_scale", "axis": "category", "scale": {"max": 3}}, "no numeric"), + ({"action": "set_point_labels", "series": "Score", "labels": ["a", "b", "c"], + "position": "right"}, "center, inside_end"), + ({"action": "set_gridlines", "gridlines": "both"}, "needs axis"), +]) +def test_edit_chart_refuses_what_cannot_be_written(session, arguments, words): + run(session, target="s:256", action="add", chart_type="bar", categories=["A", "B", "C"], + data=[{"name": "Score", "values": [1, 2, 3]}], box=BOX, ref="r") + error = refused(session, target="$r", **arguments) + assert error.code == "invalid_arguments" and words in error.message, error.message + + +def test_a_label_position_is_not_a_legend_position(session): + error = refused(session, target="s:256", action="add", chart_type="bar", + categories=["A"], data=[{"name": "S", "values": [1]}], box=BOX, + position="center") + assert error.field == "position" diff --git a/tests/test_golden.py b/tests/test_golden.py index 09f0bda..7b4856f 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -1,9 +1,11 @@ """Golden transcripts: the trial's PowerPoint tasks p1-p6, the graphics spike's p7, p8, o1 and m1 (positions computed by the caller, in points, since the layout tools went), T4's p9 (a chart slide from a CSV), p10 (a review in comments), p11 (SmartArt updated in place), -p12 (a rebrand through the theme), p13 (a radar chart from a CSV) and round 3's p14 (a chart +p12 (a rebrand through the theme), p13 (a radar chart from a CSV), round 3's p14 (a chart slide with its empty placeholder deleted, and a table in the template's own style without -banding), done with the tools alone and replayed. +banding) and p15 (a maturity gap chart -- columns, a highlighted point, target markers, a +fixed axis, no gridlines, 10 pt text -- and an effort-against-impact bubble matrix labelled +from the workbook), done with the tools alone and replayed. Each transcript in ``golden/transcripts`` is a reference solution as tool calls -- what a model would send, with no code and no files -- recorded with what every call returned. @@ -123,7 +125,7 @@ def test_a_golden_transcript_replays_to_a_passing_check(path, inputs, tmp_path): def test_the_goldens_cover_every_task_in_few_calls(): tasks = {json.loads(p.read_text())["task"].split("-")[0]: json.loads(p.read_text()) for p in TRANSCRIPTS} - assert sorted(tasks) == ["m1", "o1", "p1", "p10", "p11", "p12", "p13", "p14", "p2", "p3", + assert sorted(tasks) == ["m1", "o1", "p1", "p10", "p11", "p12", "p13", "p14", "p15", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"] for transcript in tasks.values(): assert len(transcript["calls"]) <= 10 diff --git a/tests/test_tools.py b/tests/test_tools.py index 6a88779..37bb2ae 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -42,11 +42,16 @@ #: within 5,500) and to every definition (10,269); budget and guard stay. Round 3's table #: styles and Table Design flags (ppt_add_table's style, ppt_edit_table's set_style and #: set_flags) and ppt_manage_slides' delete_empty_placeholders added 198 to every definition -#: (10,467), none to the core (3,398, about 5,029 counted); budget and guard stay. +#: (10,467), none to the core (3,398, about 5,029 counted); budget and guard stay. Round 3's +#: chart formatting in the deferred edit_chart (axis scales, gridlines, series and point +#: colours and markers, series type, chart text, point labels; the radar_filled and bubble +#: types; ``scale``, ``style``, ``gridlines``, ``part``, ``labels``, ``cells``, ``secondary``) +#: added 646 to every definition (11,113), none to the core; the guard rises to 11,200 rather +#: than squeeze the descriptions. CHARS_PER_TOKEN = 3.5 COUNTED_PER_ESTIMATED = 1.48 CORE_COUNTED_BUDGET = 5500 -ALL_GUARD = 10500 +ALL_GUARD = 11200 def estimate_tokens(definitions) -> int: From 91577eba194816d9251202665c6f4be052ebf4b3 Mon Sep 17 00:00:00 2001 From: Evert Koks Date: Sun, 11 Oct 2026 09:48:37 +0200 Subject: [PATCH 2/2] CI: install ooxml-edit from main again, now that #22 is merged Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCxhY1fvWQPiWCwz7SYUVh --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b15c7a..9a61e41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install "ooxml-common @ git+https://github.com/uvrt/ooxml-common@main" - # TEMPORARY (r3): ooxml-edit's r3-chart-formatting branch (chart formatting, 0.15.0); - # back to @main once uvrt/ooxml-edit merges it. - python -m pip install "ooxml-edit @ git+https://github.com/uvrt/ooxml-edit@r3-chart-formatting" + python -m pip install "ooxml-edit @ git+https://github.com/uvrt/ooxml-edit@main" python -m pip install "pptx2svg[png] @ git+https://github.com/uvrt/pptx2svg@main" # The fonts a render draws with, as the README installs them: without them a runner # with no CJK font leaves the fixtures' Japanese out of every PNG (and says so).