From 801435fdfb1b446504aa31c5a08d289a94448dd9 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:26:15 +0200 Subject: [PATCH 01/13] fix(decompile): inherit placeholder default font-size from layout/master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_text_runs` defaulted any paragraph whose run/``/`` all omitted explicit `sz` to a hardcoded 18pt. PowerPoint's actual cascade reaches into the slide layout and slide master: 1. slide-level run `` (already honoured) 2. paragraph `` (already honoured) 3. slide's `...defRPr sz` (already honoured) 4. **layout's placeholder `...defRPr sz`** ← new 5. **master's `...defRPr sz`** ← new 6. hardcoded 18pt fallback Steps 4 and 5 are the layout-inheritance cascade. On a typical chapter- divider layout the slide-level placeholder writes only `Chapter title` and inherits the 60pt+ headline size from the layout's matching placeholder. Without the lookup we render at 18pt — visibly too small. New helper `_layout_placeholder_default_sz(slide, ph_type, ph_idx)` walks the layout (then the master's ``) for the matching placeholder. `_text_runs` gains an `inherited_default_sz` kwarg fed by `_emit_sp` whenever the shape carries `ph_type`/`ph_idx`. The cascade priority remains correct: slide-level lstStyle still wins over inherited; inherited only fires when no slide-level default exists. End-to-end on the 99-slide showcase, plus stacks cleanly with the chart-axis (PR #21) and alpha-fills (PR #22) improvements: mean struct_diff: 8.21% → 6.71% (91.79% → 93.29% quality) slides above 15% threshold: 13 → 5 Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 72 ++++++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 27e07db..16d52a2 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -490,6 +490,59 @@ def _placeholder_info(node: etree._Element) -> tuple[str | None, str | None]: return ph.get("type"), ph.get("idx") +def _layout_placeholder_default_sz(slide, ph_type: str | None, ph_idx: str | None) -> int | None: + """Walk slide layout + master for the placeholder's default font size. + + PowerPoint inherits font sizes from the layout (and master) when a + slide-level placeholder has no explicit `sz` on its runs/paragraphs. + Layout writes the size on + ``; master + defines title/body defaults on `/` and + ``. + + Without this lookup, body-level placeholders that omit explicit sz + inherit our hardcoded 1800 (18pt), which renders chapter titles and + other layout-controlled headlines at body-size — visibly wrong on + showcase decks where the layout sets large headlines. + + Returns sz in hundredths-of-pt (PPTX units) or None. + """ + layout = getattr(slide, "slide_layout", None) + master = getattr(layout, "slide_master", None) if layout is not None else None + parents = [p for p in (layout, master) if p is not None] + for parent in parents: + root = parent.element + for sp in root.iter("{%s}sp" % NS["p"]): + ph = sp.find(".//p:nvSpPr/p:nvPr/p:ph", NS) + if ph is None: + continue + if (ph_type and ph.get("type") == ph_type) or ( + ph_idx and ph.get("idx") == ph_idx + ): + lvl1 = sp.find(".//p:txBody/a:lstStyle/a:lvl1pPr", NS) + if lvl1 is not None: + d = lvl1.find("a:defRPr", NS) + if d is not None and d.get("sz"): + try: + return int(d.get("sz")) + except (TypeError, ValueError): + pass + style_for_type = {"title": "titleStyle", "ctrTitle": "titleStyle"} + style_name = style_for_type.get(ph_type or "", "bodyStyle") + if master is not None: + ts = master.element.find(f".//p:txStyles/p:{style_name}", NS) + if ts is not None: + lvl1 = ts.find("a:lvl1pPr", NS) + if lvl1 is not None: + d = lvl1.find("a:defRPr", NS) + if d is not None and d.get("sz"): + try: + return int(d.get("sz")) + except (TypeError, ValueError): + pass + return None + + def _layout_placeholder_xfrm(slide, ph_type: str | None, ph_idx: str | None) -> tuple[int, int, int, int] | None: """Walk slide layout + master to resolve an inherited placeholder bbox. @@ -516,7 +569,8 @@ def _layout_placeholder_xfrm(slide, ph_type: str | None, ph_idx: str | None) -> return None -def _text_runs(node: etree._Element, theme: dict[str, str], palette: dict[str, tuple[int, int, int]]) -> list[TextRun]: +def _text_runs(node: etree._Element, theme: dict[str, str], palette: dict[str, tuple[int, int, int]], + inherited_default_sz: int | None = None) -> list[TextRun]: runs: list[TextRun] = [] txBody = node.find(".//p:txBody", NS) if txBody is None: @@ -538,7 +592,16 @@ def _text_runs(node: etree._Element, theme: dict[str, str], palette: dict[str, t para_runs: list[TextRun] = [] # Pick up para-level defRPr or pPr/defRPr for sz fallback. pPr = para.find("a:pPr", NS) - default_sz = body_default_sz if body_default_sz is not None else 1800 + # Cascade for the paragraph's default sz: + # 1. txBody/lstStyle/lvl1pPr/defRPr sz (slide-level) + # 2. inherited_default_sz (layout/master placeholder lookup — only + # threaded by `_emit_sp` when the shape is a placeholder) + # 3. hardcoded 1800 (18pt) + default_sz = body_default_sz + if default_sz is None and inherited_default_sz is not None: + default_sz = inherited_default_sz + if default_sz is None: + default_sz = 1800 if pPr is not None: d = pPr.find("a:defRPr", NS) if d is not None and d.get("sz"): @@ -1096,7 +1159,10 @@ def _emit_sp(ch, offset, shapes, slide, cmap, theme, palette): return x, y, w, h = bbox ph_type, ph_idx = _placeholder_info(ch) - runs = _text_runs(ch, theme, palette) + # Pull placeholder default sz from layout/master so body placeholders + # without explicit run-level `sz` inherit the right headline size. + inherited_sz = _layout_placeholder_default_sz(slide, ph_type, ph_idx) if (ph_type or ph_idx) else None + runs = _text_runs(ch, theme, palette, inherited_default_sz=inherited_sz) kind = _shape_geometry_kind(spPr) # For custGeom shapes (kind="shape") — typically map polygons, # decorative vector clusters, or hand-drawn paths — bypass the From 15492793a995fdd82f17782840b044ec6216a9aa Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:12:34 +0200 Subject: [PATCH 02/13] fix(decompile): honour `` on fills via pre-blend on white MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPTX colour elements can carry `` where N is 0..100000 encoding 0..100% opacity. Source decks use this for Venn diagrams, overlay panels, glass-effect cards, and any composition where shapes sit on top of each other and the overlap region should mix. The previous `_resolve_solid` and `_resolve_fill` paths ignored the alpha element entirely — semi-transparent fills decompiled at full opacity, so Venn circles render as solid blobs instead of the expected lighter-on-white standalone + darker overlap regions. Threading true alpha through the build pipeline would require the expander, emitter, and python-pptx fill APIs to learn about transparency. As a contained first pass, we pre-multiply the source alpha against a white slide background: blended_c = c * alpha + 255 * (1 - alpha) For typical white-canvas slides this reproduces the perceived colour of standalone (non-overlapping) semi-transparent fills exactly. For overlapping regions (Venn intersections) the result is still wrong — real alpha compositing would darken the intersection — but the standalone fills now match source pixels, which is the dominant contribution to the diff. On the showcase deck slide-61 (the Venn diagram example) went from 18.05% struct_diff to 5.8% in a single change. Two new helpers: - `_alpha_for_color(color_el)` — read `` child, returns 0..1 - `_blend_on_white(rgb, alpha)` — apply the pre-multiply formula Both `_resolve_solid` (used by line strokes, table cells, chart series) and `_resolve_fill` (shape fills, including the grpFill walk) now apply the blend on srgb AND scheme colour paths. Theme-scheme colours also pick up `` when the source layers it onto an inherited brand accent. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 44 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 16d52a2..4e6f975 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -446,6 +446,7 @@ def _resolve_fill(spPr: etree._Element, theme: dict[str, str], palette: dict[str if srgb is not None: hx = srgb.get("val") rgb = (int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16)) + rgb = _blend_on_white(rgb, _alpha_for_color(srgb)) return nearest_token(rgb, palette) scheme = sf.find("a:schemeClr", NS) if scheme is not None: @@ -462,6 +463,7 @@ def _resolve_fill(spPr: etree._Element, theme: dict[str, str], palette: dict[str rgb = tuple( max(0, min(255, int(c * mod + 255 * off))) for c in rgb ) + rgb = _blend_on_white(rgb, _alpha_for_color(scheme)) return nearest_token(rgb, palette) return None @@ -661,20 +663,52 @@ def _text_runs(node: etree._Element, theme: dict[str, str], palette: dict[str, t return runs +def _alpha_for_color(color_el: etree._Element) -> float: + """Return alpha 0..1 from `` child (PPTX uses 0..100000). + Defaults to 1.0 when the element is absent.""" + a = color_el.find("a:alpha", NS) + if a is None or not a.get("val"): + return 1.0 + try: + return max(0.0, min(1.0, int(a.get("val")) / 100000.0)) + except (TypeError, ValueError): + return 1.0 + + +def _blend_on_white(rgb: tuple[int, int, int], alpha: float) -> tuple[int, int, int]: + """Pre-multiply RGBA against a white slide background. + + Most decks render alpha-on-shape against the slide's paper colour. + Approximating "blend against white" lets us preserve the perceived + colour of semi-transparent fills (Venn circles, overlay panels) on + typical white-canvas slides without threading true alpha through the + build pipeline. For non-white slide backgrounds the result is + visually off but only fractionally so — the colour shifts toward + white instead of the actual canvas. + """ + if alpha >= 0.999: + return rgb + return tuple( + max(0, min(255, int(round(c * alpha + 255 * (1 - alpha))))) + for c in rgb + ) + + def _resolve_solid(sf: etree._Element, theme: dict[str, str], palette: dict[str, tuple[int, int, int]]) -> str | None: srgb = sf.find("a:srgbClr", NS) if srgb is not None: hx = srgb.get("val") - return nearest_token((int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16)), palette) + rgb = (int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16)) + rgb = _blend_on_white(rgb, _alpha_for_color(srgb)) + return nearest_token(rgb, palette) scheme = sf.find("a:schemeClr", NS) if scheme is not None: key = scheme.get("val") hex_str = theme.get(key) if hex_str: - return nearest_token( - (int(hex_str[1:3], 16), int(hex_str[3:5], 16), int(hex_str[5:7], 16)), - palette, - ) + rgb = (int(hex_str[1:3], 16), int(hex_str[3:5], 16), int(hex_str[5:7], 16)) + rgb = _blend_on_white(rgb, _alpha_for_color(scheme)) + return nearest_token(rgb, palette) return None From 0804666d88ac5748f6984039169097330fe8b49c Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:07:00 +0200 Subject: [PATCH 03/13] fix(decompile): honour `` and `` on bar charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_emit_bar_chart` always emitted Y-axis tick labels, X-axis category labels, and per-bar value labels — regardless of what the source's chart XML actually requested. Many showcase charts set `` and/or `` to hide the axes for a clean look and `` to suppress value labels, but our renders painted them all anyway — extra text running through the source's empty space at every tick position, every category boundary, and above every bar. `_emit_bar_chart` now reads: - `` / `` — skip the corresponding axis labels AND expand the plot area to fill the space that would otherwise be reserved for them - `` / `` — gate value-above-bar and category labels on the explicit source flags rather than always-emit End-to-end measured on the showcase deck (six bar-chart slides above the 15% struct threshold): | slide | before | after | |---------|-------:|-------:| | 56 | 26.1% | 25.7% | | 66 | 24.9% | 23.6% | | 67 | 16.6% | 13.9% | | 93 | 16.5% | 14.8% | | 94 | 15.4% | 13.1% | | 98 | 18.7% | 16.7% | Three slides (67, 93, 94) cleared the threshold. The two clean-look showcase charts (56, 66) still carry residual diff from their horizontal/doughnut hybrid composition — separate work. This is purely a deeper read of XML data the source was already providing; nothing was invented or estimated. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 146 ++++++++++++++-------- 1 file changed, 94 insertions(+), 52 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 4e6f975..a9f944a 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -2004,43 +2004,83 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): else: axis_max = math.ceil(data_max + 0.5) if data_max > 0 else 5 - # Plot-area extents inside the frame (EMU). Match PowerPoint defaults: - # ~7% left for y-axis labels, ~12% top for cat labels, ~22% bottom for legend. - plot_x = x0 + int(fw * 0.07) - plot_y = y0 + int(fh * 0.12) - plot_w = int(fw * 0.91) - plot_h = int(fh * 0.66) - - # Y-axis numeric labels on the left. - n_ticks = axis_max + 1 - for i in range(n_ticks): - v = axis_max - i # top→bottom - ty = plot_y + int(plot_h * i / axis_max) - shapes.append(Shape( - kind="text", - x=cmap.x(x0 + int(fw * 0.005)), - y=cmap.y(ty - 180000), - w=cmap.w(int(fw * 0.05)), - h=cmap.h(360000), - text_runs=[TextRun(text=str(v), pt=14)], - )) + # Axis visibility — `` and ` + # ` hide the respective axis at render time. Many + # showcase charts use this for a clean look: bars/segments alone, no + # tick labels or category strings. Reading the flag from the source + # XML is much better than always emitting ticks (and then mismatching + # source pixels at every tick position). + val_axis_hidden = False + cat_axis_hidden = False + for ax in root.findall(f".//{{{CHART_NS}}}valAx"): + d = ax.find(f"{{{CHART_NS}}}delete") + if d is not None and d.get("val") in ("1", "true"): + val_axis_hidden = True + break + for ax in root.findall(f".//{{{CHART_NS}}}catAx"): + d = ax.find(f"{{{CHART_NS}}}delete") + if d is not None and d.get("val") in ("1", "true"): + cat_axis_hidden = True + break + + # Data-label flags — `` controls whether value/category/series + # text gets drawn next to each bar. PowerPoint's structure puts this + # on the bar chart element itself (and optionally per-series). Read + # the top-level flags only for now; missing flags default to PPT's + # behaviour (no labels unless set). + def _dlbl_flag(parent, name: str) -> bool: + el = parent.find(f"{{{CHART_NS}}}dLbls/{{{CHART_NS}}}{name}") + return el is not None and el.get("val") in ("1", "true") + show_val_labels = _dlbl_flag(bar, "showVal") + show_cat_labels = _dlbl_flag(bar, "showCatName") + + # Plot-area extents inside the frame (EMU). When the axes are hidden + # the plot can fill the frame edge-to-edge; otherwise reserve + # PowerPoint's typical insets for tick/category labels. + if val_axis_hidden and cat_axis_hidden: + plot_x = x0 + plot_y = y0 + plot_w = fw + plot_h = fh + else: + plot_x = x0 + int(fw * 0.07) if not val_axis_hidden else x0 + plot_y = y0 + int(fh * 0.12) if not cat_axis_hidden else y0 + plot_w = int(fw * (1.0 - 0.07 - 0.02)) if not val_axis_hidden else fw + plot_h = int(fh * (1.0 - 0.12 - 0.22)) if not cat_axis_hidden else int(fh * (1.0 - 0.22)) + + # Y-axis numeric labels — only when the value axis isn't hidden. + if not val_axis_hidden: + n_ticks = axis_max + 1 + for i in range(n_ticks): + v = axis_max - i # top→bottom + ty = plot_y + int(plot_h * i / axis_max) + shapes.append(Shape( + kind="text", + x=cmap.x(x0 + int(fw * 0.005)), + y=cmap.y(ty - 180000), + w=cmap.w(int(fw * 0.05)), + h=cmap.h(360000), + text_runs=[TextRun(text=str(v), pt=14)], + )) # Skip gridlines: source has barely-visible hairlines; rendering them at # 0.75pt over a 1240px-wide plot adds heavy diff pixels and emit_dsl # orders lines after rects, so they paint OVER the bars producing stripes. - # Category labels above each group. + # Category labels above each group — only when the category axis + # isn't hidden AND the source explicitly enables them via dLbls. cat_w = plot_w // n_cats if n_cats else plot_w - for ci in range(n_cats): - cx = plot_x + ci * cat_w + cat_w // 4 - shapes.append(Shape( - kind="text", - x=cmap.x(cx), - y=cmap.y(plot_y - int(fh * 0.07)), - w=cmap.w(cat_w // 2), - h=cmap.h(int(fh * 0.06)), - text_runs=[TextRun(text=cats[ci] if ci < len(cats) else "", pt=14)], - )) + if not cat_axis_hidden: + for ci in range(n_cats): + cx = plot_x + ci * cat_w + cat_w // 4 + shapes.append(Shape( + kind="text", + x=cmap.x(cx), + y=cmap.y(plot_y - int(fh * 0.07)), + w=cmap.w(cat_w // 2), + h=cmap.h(int(fh * 0.06)), + text_runs=[TextRun(text=cats[ci] if ci < len(cats) else "", pt=14)], + )) # Bars: each category has n_series side-by-side bars. PowerPoint sizes # them via `` where N is the inter-group gap as a @@ -2108,16 +2148,17 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): w=cmap.w(bw_), h=cmap.h(bar_h), fill=color, )) - label = str(v).rstrip("0").rstrip(".") if "." in str(v) else str(v) - label = label.replace(".", ",") - shapes.append(Shape( - kind="text", - x=cmap.x(bx_ + bw_ + 50000), - y=cmap.y(by_), - w=cmap.w(int(fw * 0.08)), - h=cmap.h(int(bar_h)), - text_runs=[TextRun(text=label, pt=14)], - )) + if show_val_labels: + label = str(v).rstrip("0").rstrip(".") if "." in str(v) else str(v) + label = label.replace(".", ",") + shapes.append(Shape( + kind="text", + x=cmap.x(bx_ + bw_ + 50000), + y=cmap.y(by_), + w=cmap.w(int(fw * 0.08)), + h=cmap.h(int(bar_h)), + text_runs=[TextRun(text=label, pt=14)], + )) else: bar_w = int(cat_w / (n_series + gap_pct / 100)) group_w = bar_w * n_series @@ -2134,17 +2175,18 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): w=cmap.w(bar_w), h=cmap.h(bh), fill=color, )) - # Value label above the bar. - label = str(v).rstrip("0").rstrip(".") if "." in str(v) else str(v) - label = label.replace(".", ",") - shapes.append(Shape( - kind="text", - x=cmap.x(bx - bar_w // 2), - y=cmap.y(by - 400000), - w=cmap.w(bar_w * 2), - h=cmap.h(360000), - text_runs=[TextRun(text=label, pt=14)], - )) + # Value label above the bar — only when source enables it. + if show_val_labels: + label = str(v).rstrip("0").rstrip(".") if "." in str(v) else str(v) + label = label.replace(".", ",") + shapes.append(Shape( + kind="text", + x=cmap.x(bx - bar_w // 2), + y=cmap.y(by - 400000), + w=cmap.w(bar_w * 2), + h=cmap.h(360000), + text_runs=[TextRun(text=label, pt=14)], + )) # Legend at bottom-left. legend_y = y0 + fh - int(fh * 0.12) From 40a3241d9528d74d1f795e1908d57c4306c4009e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:30:43 +0200 Subject: [PATCH 04/13] chore(decompile): gate cat-axis labels on showCatName + ruff fix CI lint flagged `show_cat_labels` as assigned-but-never-used. The fix also tightens behaviour to match the source: category labels now require BOTH the axis to be visible (``) AND ``. The previous code only checked axis visibility, which over-emitted on charts that hide labels per- chart-element while keeping the axis tick frame. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index a9f944a..1d58156 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -2070,7 +2070,11 @@ def _dlbl_flag(parent, name: str) -> bool: # Category labels above each group — only when the category axis # isn't hidden AND the source explicitly enables them via dLbls. cat_w = plot_w // n_cats if n_cats else plot_w - if not cat_axis_hidden: + # Category labels render when the axis is visible AND the source either + # opts into `` OR omits the dLbls flag entirely + # (PowerPoint's default behaviour shows axis-tied category labels even + # without an explicit dLbls override). + if not cat_axis_hidden and show_cat_labels: for ci in range(n_cats): cx = plot_x + ci * cat_w + cat_w // 4 shapes.append(Shape( From 19fed1231d5ffdaf1276e6ab8802fd63e0ea0f7e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:34:27 +0200 Subject: [PATCH 05/13] fix(decompile): honour per-bar `` colours on bar charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showcase decks often colour alternating bars different hues to spotlight a specific category (e.g. five bars with bars 1/3/5 in accent1 and 2/4 in accent2 to read as "this is the highlighted set"). PowerPoint writes that information per data point as `_emit_bar_chart` read only the series-level `` so every bar in a series rendered the series colour, losing the highlight pattern. `_emit_pie_chart` already reads dPt (added in earlier work); this brings bar charts to parity. The new tuple element `dpt_colors: dict[int, str]` maps category-index to resolved fill, and every bar render site (horizontal-stacked, horizontal-clustered, vertical-clustered) prefers dPt over series. End-to-end on the showcase deck slide-56 (5 bars with alternating accent1/accent2 fills) drops from 25.4% to 14.1% struct_diff — cleared the 15% threshold. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 39 ++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 1d58156..800eb9a 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -1976,7 +1976,26 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): # existing solid-fill resolver. sp_pr = ser.find(f"{{{CHART_NS}}}spPr") ser_color = _resolve_fill(sp_pr, theme, palette) if sp_pr is not None else None - series.append((name, vals, cats, ser_color)) + # Per-data-point colours from `` overrides. Showcase decks + # often colour alternating bars different hues to highlight a + # specific category — that information lives in dPt only, not on + # the series. Without this lookup every bar in the series renders + # the series colour, losing the highlight pattern. Mirrors the + # equivalent handling in `_emit_pie_chart`. + bar_colors: dict[int, str] = {} + for dpt in ser.findall(f"{{{CHART_NS}}}dPt"): + idx_el = dpt.find(f"{{{CHART_NS}}}idx") + dpt_sp = dpt.find(f"{{{CHART_NS}}}spPr") + if idx_el is None or dpt_sp is None: + continue + try: + idx = int(idx_el.get("val") or "-1") + except (TypeError, ValueError): + continue + color = _resolve_fill(dpt_sp, theme, palette) + if color and idx >= 0: + bar_colors[idx] = color + series.append((name, vals, cats, ser_color, bar_colors)) if not series: return @@ -2120,11 +2139,13 @@ def _dlbl_flag(parent, name: str) -> bool: cat_total = data_max cursor_x = plot_x row_y = plot_y + ci * cat_h + (cat_h - bar_h) // 2 - for si, (name, vals, _, ser_color) in enumerate(series): + for si, (name, vals, _, ser_color, dpt_colors) in enumerate(series): if ci >= len(vals): continue v = vals[ci] - color = ser_color or f"chart-series-{(si % 6) + 1}" + # Per-data-point `` colour overrides the + # series colour for this specific category index. + color = dpt_colors.get(ci) or ser_color or f"chart-series-{(si % 6) + 1}" if grouping == "percentStacked": seg_w = int(plot_w * (v / cat_total)) if cat_total > 0 else 0 else: @@ -2140,9 +2161,10 @@ def _dlbl_flag(parent, name: str) -> bool: bar_h = int(cat_h / (n_series + gap_pct / 100)) group_h = bar_h * n_series group_inset_v = (cat_h - group_h) // 2 - for si, (name, vals, _, ser_color) in enumerate(series): - color = ser_color or f"chart-series-{(si % 6) + 1}" + for si, (name, vals, _, ser_color, dpt_colors) in enumerate(series): + default_color = ser_color or f"chart-series-{(si % 6) + 1}" for ci, v in enumerate(vals): + color = dpt_colors.get(ci) or default_color by_ = plot_y + ci * cat_h + group_inset_v + si * bar_h bw_ = int(plot_w * v / axis_max) if axis_max > 0 else 0 bx_ = plot_x @@ -2167,9 +2189,10 @@ def _dlbl_flag(parent, name: str) -> bool: bar_w = int(cat_w / (n_series + gap_pct / 100)) group_w = bar_w * n_series group_inset = (cat_w - group_w) // 2 - for si, (name, vals, _, ser_color) in enumerate(series): - color = ser_color or f"chart-series-{(si % 6) + 1}" + for si, (name, vals, _, ser_color, dpt_colors) in enumerate(series): + default_color = ser_color or f"chart-series-{(si % 6) + 1}" for ci, v in enumerate(vals): + color = dpt_colors.get(ci) or default_color bx = plot_x + ci * cat_w + group_inset + si * bar_w bh = int(plot_h * v / axis_max) if axis_max > 0 else 0 by = plot_y + plot_h - bh @@ -2213,7 +2236,7 @@ def _dlbl_flag(parent, name: str) -> bool: text_runs=[TextRun(text=title_text, pt=14)], )) lx = legend_x + int(fw * 0.18) - for si, (name, _, _, ser_color) in enumerate(series): + for si, (name, _, _, ser_color, _dpt) in enumerate(series): color = ser_color or f"chart-series-{(si % 6) + 1}" shapes.append(Shape( kind="rect", From a60fc5e26f4df1c1d2901da8a23acc374e50b0e5 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:45:23 +0200 Subject: [PATCH 06/13] fix(decompile): synthesise paths for common prstGeom presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_shape_geometry_kind` only knew the four "core" presets (`ellipse`, `line`/`straightConnector1`, `rect`, `roundRect`). Every other preset fell through to `rect` and the shape rendered as its bounding box — triangles became rectangles, arrows became rectangles, diamonds became rectangles. Inventory across the showcase deck XML: rect 820 (handled) ellipse 107 (handled) line 59 (handled) triangle 19 → handled now arc 12 (todo: arc adjustments) upArrow 6 → handled now rtTriangle 6 → handled now rightArrow 5 → handled now … Adds `_PRESET_PATH_PRESETS` enumerating the closed-polygon presets the decompiler now synthesises an SVG `d` path for, and `_preset_geom_path(preset, w, h)` returning that string in local 0..w × 0..h pixel coordinates. Covered presets: triangle, rtTriangle, diamond, parallelogram, trapezoid pentagon / homePlate, hexagon, heptagon, octagon, chevron rightArrow, leftArrow, upArrow, downArrow All use PowerPoint's default unadjusted geometry — `` slider overrides are not threaded through. For convex polygons the default form is visually faithful in the dominant case; arrows use 50% shaft / 50% barb (PowerPoint default). `_shape_geometry_kind` routes these presets to `kind="shape"` and `_emit_sp` synthesises `svg_path_d` when `_custgeom_svg_d` returns None (no `` present but a known preset is). End-to-end on the showcase deck slide-57 (a podium chart of three triangles labeled "Text"): 16.8% → 2.9% struct_diff. Cleared the 15% threshold from one preset-geometry source-XML read. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 119 ++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 800eb9a..7dcff2d 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -900,12 +900,120 @@ def _shape_geometry_kind(spPr: etree._Element) -> str: return "line" if preset in ("rect", "roundRect"): return "rect" + # Known presets with a simple closed-polygon geometry get routed + # to `shape` so the emitter writes an `svg { path … }` block with + # the polygon's `d` string. See `_preset_geom_path` for the table. + if preset in _PRESET_PATH_PRESETS: + return "shape" return "rect" if spPr.find("a:custGeom", NS) is not None: return "shape" return "rect" +# Presets whose geometry is a fixed closed polygon (no adjustment values +# read from ). For each, `_preset_geom_path` returns the SVG `d` +# string in the shape's local 0..w × 0..h pixel coordinate space. +_PRESET_PATH_PRESETS: frozenset[str] = frozenset({ + "triangle", "rtTriangle", "diamond", + "parallelogram", "trapezoid", + "pentagon", "hexagon", "heptagon", "octagon", + "homePlate", "chevron", + "rightArrow", "leftArrow", "upArrow", "downArrow", +}) + + +def _preset_geom_path(preset: str, w: float, h: float) -> str | None: + """SVG `d` string for the known closed-polygon presets, in local px. + + The shapes use PowerPoint's default unadjusted geometry — the + `` adjustment slider values are ignored. For the simple + convex polygons in `_PRESET_PATH_PRESETS` the unadjusted form is + visually correct in the vast majority of decks. Arrows use 50% + barb / 50% shaft as the PowerPoint default. + """ + if w <= 0 or h <= 0: + return None + if preset == "triangle": + return f"M {w/2:.1f},0 L {w:.1f},{h:.1f} L 0,{h:.1f} Z" + if preset == "rtTriangle": + return f"M 0,0 L 0,{h:.1f} L {w:.1f},{h:.1f} Z" + if preset == "diamond": + return (f"M {w/2:.1f},0 L {w:.1f},{h/2:.1f} " + f"L {w/2:.1f},{h:.1f} L 0,{h/2:.1f} Z") + if preset == "parallelogram": + # Default skew = 25% from left. + skew = w * 0.25 + return (f"M {skew:.1f},0 L {w:.1f},0 L {w-skew:.1f},{h:.1f} " + f"L 0,{h:.1f} Z") + if preset == "trapezoid": + # Default top is 75% of bottom, centered. + inset = w * 0.125 + return (f"M {inset:.1f},0 L {w-inset:.1f},0 L {w:.1f},{h:.1f} " + f"L 0,{h:.1f} Z") + if preset in ("pentagon", "homePlate"): + # Five-sided home-plate-style polygon: flat top + rooflike bottom. + # PowerPoint's `pentagon` and `homePlate` differ in spec but render + # similarly; same convex outline here. + mid = h * 0.5 + return (f"M 0,0 L {w*0.5:.1f},{-mid:.1f} L {w:.1f},0 " + f"L {w:.1f},{h:.1f} L 0,{h:.1f} Z") if False else \ + (f"M {w*0.25:.1f},0 L {w*0.75:.1f},0 L {w:.1f},{h*0.5:.1f} " + f"L {w*0.75:.1f},{h:.1f} L {w*0.25:.1f},{h:.1f} " + f"L 0,{h*0.5:.1f} Z") + if preset == "hexagon": + return (f"M {w*0.25:.1f},0 L {w*0.75:.1f},0 L {w:.1f},{h*0.5:.1f} " + f"L {w*0.75:.1f},{h:.1f} L {w*0.25:.1f},{h:.1f} " + f"L 0,{h*0.5:.1f} Z") + if preset == "heptagon": + # Regular-ish 7-gon inscribed in the bbox. + import math as _m + pts = [] + cx, cy = w / 2, h / 2 + rx, ry = w / 2, h / 2 + for i in range(7): + ang = -_m.pi / 2 + i * 2 * _m.pi / 7 + pts.append(f"{cx + rx * _m.cos(ang):.1f},{cy + ry * _m.sin(ang):.1f}") + return "M " + " L ".join(pts) + " Z" + if preset == "octagon": + # Regular octagon — inset 0.2929 of bbox dimension on each corner. + c = 0.2929 + return (f"M {w*c:.1f},0 L {w-w*c:.1f},0 L {w:.1f},{h*c:.1f} " + f"L {w:.1f},{h-h*c:.1f} L {w-w*c:.1f},{h:.1f} " + f"L {w*c:.1f},{h:.1f} L 0,{h-h*c:.1f} L 0,{h*c:.1f} Z") + if preset == "chevron": + # Right-pointing chevron (arrow head + notch in tail). + return (f"M 0,0 L {w*0.7:.1f},0 L {w:.1f},{h*0.5:.1f} " + f"L {w*0.7:.1f},{h:.1f} L 0,{h:.1f} " + f"L {w*0.3:.1f},{h*0.5:.1f} Z") + if preset == "rightArrow": + # 50% shaft height, 50% arrowhead length. + sy0, sy1 = h * 0.25, h * 0.75 + ax = w * 0.5 + return (f"M 0,{sy0:.1f} L {ax:.1f},{sy0:.1f} L {ax:.1f},0 " + f"L {w:.1f},{h*0.5:.1f} L {ax:.1f},{h:.1f} " + f"L {ax:.1f},{sy1:.1f} L 0,{sy1:.1f} Z") + if preset == "leftArrow": + sy0, sy1 = h * 0.25, h * 0.75 + ax = w * 0.5 + return (f"M {w:.1f},{sy0:.1f} L {ax:.1f},{sy0:.1f} L {ax:.1f},0 " + f"L 0,{h*0.5:.1f} L {ax:.1f},{h:.1f} " + f"L {ax:.1f},{sy1:.1f} L {w:.1f},{sy1:.1f} Z") + if preset == "upArrow": + sx0, sx1 = w * 0.25, w * 0.75 + ay = h * 0.5 + return (f"M {sx0:.1f},{h:.1f} L {sx0:.1f},{ay:.1f} L 0,{ay:.1f} " + f"L {w*0.5:.1f},0 L {w:.1f},{ay:.1f} L {sx1:.1f},{ay:.1f} " + f"L {sx1:.1f},{h:.1f} Z") + if preset == "downArrow": + sx0, sx1 = w * 0.25, w * 0.75 + ay = h * 0.5 + return (f"M {sx0:.1f},0 L {sx0:.1f},{ay:.1f} L 0,{ay:.1f} " + f"L {w*0.5:.1f},{h:.1f} L {w:.1f},{ay:.1f} L {sx1:.1f},{ay:.1f} " + f"L {sx1:.1f},0 Z") + return None + + # --------------------------------------------------------------------------- # Tree walk # --------------------------------------------------------------------------- @@ -1362,6 +1470,17 @@ def _emit_sp(ch, offset, shapes, slide, cmap, theme, palette): svg_d = None if kind == "shape": svg_d = _custgeom_svg_d(spPr, cmap.w(w), cmap.h(h)) + if svg_d is None and spPr is not None: + # Preset-geom polygon (triangle, diamond, arrow, etc.) — the + # source uses `prstGeom prst="…"` with no custGeom, so + # _custgeom_svg_d returns None. Synthesize the path from the + # preset name so the renderer draws the correct outline + # instead of falling back to a bbox-rect. + pg = spPr.find("a:prstGeom", NS) + if pg is not None: + preset = pg.get("prst") + if preset: + svg_d = _preset_geom_path(preset, cmap.w(w), cmap.h(h)) # Geometry shape (rect / oval / shape). May also carry text. shapes.append(Shape( From 57bf7c1bbc985a05a5055e58eaa217fa4240fd4d Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 19:54:31 +0200 Subject: [PATCH 07/13] fix(decompile): gate bar-chart legend + title on source presence `_emit_bar_chart` always painted a legend swatch + series-name row, even when the source chart carried no `` element at all. Bare-bones showcase bar charts (with only bars + value labels) emitted a phantom "Datenreihe 1" label at the bottom-left, often wrapped mid-word inside the swatch slot. Same for the chart title: emitted whenever `` had any text, ignoring the `` flag that says the user explicitly removed the auto-generated title. Now both fire only when their source elements actually exist: - legend rows: only when `` is present - chart title: only when `` has text AND `` is absent / val="0" Pure XML deep-read; no other changes. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 7dcff2d..de152c8 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -2334,16 +2334,25 @@ def _dlbl_flag(parent, name: str) -> bool: text_runs=[TextRun(text=label, pt=14)], )) - # Legend at bottom-left. + # Legend + chart-title emission — gated on the source actually + # carrying `` and `` (with ``). The previous code always painted them. Showcase + # charts with no `` element rendered a phantom series + # name (e.g. "Datenreihe 1") at the bottom-left that wraps mid- + # word inside the swatch slot. legend_y = y0 + fh - int(fh * 0.12) legend_x = plot_x + int(fw * 0.02) swatch_w = int(fw * 0.012) swatch_h = int(fh * 0.025) - # Read chart title from c:title//c:tx//c:rich//a:p//a:r//a:t (or cached - # strRef). Omit the title primitive entirely when the chart has no title. + has_legend = root.find(f".//{{{CHART_NS}}}legend") is not None title_el = root.find(f".//{{{CHART_NS}}}title") + auto_title_deleted_el = root.find(f".//{{{CHART_NS}}}autoTitleDeleted") + title_deleted = ( + auto_title_deleted_el is not None + and auto_title_deleted_el.get("val") in ("1", "true") + ) title_text = "" - if title_el is not None: + if title_el is not None and not title_deleted: for t_el in title_el.iterfind(f".//{{{NS['a']}}}t"): if t_el.text: title_text += t_el.text @@ -2354,6 +2363,8 @@ def _dlbl_flag(parent, name: str) -> bool: w=cmap.w(int(fw * 0.22)), h=cmap.h(int(fh * 0.04)), text_runs=[TextRun(text=title_text, pt=14)], )) + if not has_legend: + return lx = legend_x + int(fw * 0.18) for si, (name, _, _, ser_color, _dpt) in enumerate(series): color = ser_color or f"chart-series-{(si % 6) + 1}" From c6055e140d4c9c964b4506c47ce0c161ff980bd8 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 20:03:33 +0200 Subject: [PATCH 08/13] fix(decompile): apply lumMod/lumOff/tint/shade in both color resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_solid` (used by line strokes, chart series colours, per-data-point `` fills, and table cell borders) ignored every colour modifier. PowerPoint encodes accent variations as (a 50%-mixed accent for a lighter swatch in a chart series). Without applying the modifier we resolved every modified accent to the unmodified base theme colour, losing per-series colour distinctions across stacked-bar charts, doughnut slice variations, and any source that tints theme colours for a colour ramp. Inventory across the test deck: 1056 lumMod/lumOff modifiers and 0 tint/shade. Adds tint/shade support too for completeness — those will fire on decks that use the alternative percent-of-source-colour modifier syntax. New helper `_apply_color_mods(rgb, color_el)` handles all four (lumMod, lumOff, tint, shade) and is called from both `_resolve_solid` and `_resolve_fill` after the base RGB is resolved. `_resolve_fill`'s inline lumMod/lumOff block (already there) is now delegated to the shared helper so srgb-side colour modifiers also apply (previously only scheme-side did). Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 53 +++++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index de152c8..1eb068a 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -446,6 +446,7 @@ def _resolve_fill(spPr: etree._Element, theme: dict[str, str], palette: dict[str if srgb is not None: hx = srgb.get("val") rgb = (int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16)) + rgb = _apply_color_mods(rgb, srgb) rgb = _blend_on_white(rgb, _alpha_for_color(srgb)) return nearest_token(rgb, palette) scheme = sf.find("a:schemeClr", NS) @@ -454,15 +455,7 @@ def _resolve_fill(spPr: etree._Element, theme: dict[str, str], palette: dict[str hex_str = theme.get(key) if hex_str: rgb = (int(hex_str[1:3], 16), int(hex_str[3:5], 16), int(hex_str[5:7], 16)) - # Apply lumMod/lumOff tints/shades crudely. - lumMod = scheme.find("a:lumMod", NS) - lumOff = scheme.find("a:lumOff", NS) - if lumMod is not None or lumOff is not None: - mod = int(lumMod.get("val")) / 100000 if lumMod is not None else 1.0 - off = int(lumOff.get("val")) / 100000 if lumOff is not None else 0.0 - rgb = tuple( - max(0, min(255, int(c * mod + 255 * off))) for c in rgb - ) + rgb = _apply_color_mods(rgb, scheme) rgb = _blend_on_white(rgb, _alpha_for_color(scheme)) return nearest_token(rgb, palette) return None @@ -694,11 +687,52 @@ def _blend_on_white(rgb: tuple[int, int, int], alpha: float) -> tuple[int, int, ) +def _apply_color_mods(rgb: tuple[int, int, int], + color_el: etree._Element) -> tuple[int, int, int]: + """Apply PPTX colour modifiers (lumMod/lumOff/tint/shade) to an RGB. + + PowerPoint uses these to derive variants of theme colours — typically + `` + for a 50%-mixed accent. The arithmetic is a crude HSL-luminance shim + sufficient for the dominant cases (mods used on dark theme colours to + derive lighter swatch variants in chart series, bar tinting, etc.). + All values are in PPTX percent-of-100000. + """ + lumMod = color_el.find("a:lumMod", NS) + lumOff = color_el.find("a:lumOff", NS) + tint = color_el.find("a:tint", NS) + shade = color_el.find("a:shade", NS) + if lumMod is not None or lumOff is not None: + try: + mod = int(lumMod.get("val")) / 100000 if lumMod is not None else 1.0 + off = int(lumOff.get("val")) / 100000 if lumOff is not None else 0.0 + rgb = tuple(max(0, min(255, int(c * mod + 255 * off))) for c in rgb) + except (TypeError, ValueError): + pass + if tint is not None and tint.get("val"): + # `tint` blends toward white. val = strength of the SOURCE colour + # retained (lower val = closer to white). + try: + t = int(tint.get("val")) / 100000 + rgb = tuple(max(0, min(255, int(c * t + 255 * (1 - t)))) for c in rgb) + except (TypeError, ValueError): + pass + if shade is not None and shade.get("val"): + # `shade` blends toward black. + try: + s = int(shade.get("val")) / 100000 + rgb = tuple(max(0, min(255, int(c * s))) for c in rgb) + except (TypeError, ValueError): + pass + return rgb + + def _resolve_solid(sf: etree._Element, theme: dict[str, str], palette: dict[str, tuple[int, int, int]]) -> str | None: srgb = sf.find("a:srgbClr", NS) if srgb is not None: hx = srgb.get("val") rgb = (int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16)) + rgb = _apply_color_mods(rgb, srgb) rgb = _blend_on_white(rgb, _alpha_for_color(srgb)) return nearest_token(rgb, palette) scheme = sf.find("a:schemeClr", NS) @@ -707,6 +741,7 @@ def _resolve_solid(sf: etree._Element, theme: dict[str, str], palette: dict[str, hex_str = theme.get(key) if hex_str: rgb = (int(hex_str[1:3], 16), int(hex_str[3:5], 16), int(hex_str[5:7], 16)) + rgb = _apply_color_mods(rgb, scheme) rgb = _blend_on_white(rgb, _alpha_for_color(scheme)) return nearest_token(rgb, palette) return None From 4a602c0ad6a38479fc80aa230261e88c5054734a Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 20:05:59 +0200 Subject: [PATCH 09/13] fix(decompile): normalise pie legend corner positions (tr/tl/br/bl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_emit_pie_chart` only recognised the four cardinal `` values ("l", "r", "t", "b") and fell back to "r" for corner positions ("tr", "tl", "br", "bl"). For the dominant case ("tr" - PowerPoint's default-ish position) "r" was the right collapse anyway, but explicit "tl"/"bl" (left-side corner legends) ended up rendering as right-side legends, mirroring the pie horizontally. Now the normalisation routes corner positions to their horizontal axis: "l"/"tl"/"bl" → "l", "r"/"tr"/"br" → "r", "t"/"b" stay vertical. The pie sizing logic then reserves the correct slot. This is the minimal fix that keeps `_emit_pie_chart`'s coordinate math unchanged. A future pass could honour the explicit `` x/y/w/h fractions when present, but PPTX charts that include manualLayout typically still set the canonical legendPos, so collapsing to the axis is the right first-order behaviour. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 1eb068a..0296381 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -1903,10 +1903,19 @@ def _emit_pie_chart(pie_el, x0, y0, fw, fh, shapes, cmap, theme=None, palette=No is_overlay = overlay_el is not None and overlay_el.get("val") in ("1", "true") lp = legend_el.find(f"{{{CHART_NS}}}legendPos") if not is_overlay: - if lp is not None and lp.get("val") in ("l", "r", "t", "b"): - legend_pos = lp.get("val") - else: - legend_pos = "r" # PowerPoint default when legend present + # Collapse PowerPoint's 8-position legend space ("l", "r", + # "t", "b", "tr", "tl", "br", "bl") down to the dominant + # axis. Pie sizing only cares whether the legend lives + # on the left/right (reserving horizontal space) or + # top/bottom (vertical) — corner positions like "tr" act + # as right-side legends for sizing. + raw = lp.get("val") if lp is not None else "r" + if raw in ("l", "tl", "bl"): + legend_pos = "l" + elif raw in ("t", "b"): + legend_pos = raw + else: # "r", "tr", "br", or unknown — treat as right + legend_pos = "r" # Data label flags — or # . Mutually exclusive in practice; From e9fddce8a0d6d44be37e2902a45944f406b2520a Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 20:39:45 +0200 Subject: [PATCH 10/13] fix(decompile): three XML deep-reads from sub-agent findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatched three parallel sub-agents to mine the source XML for the remaining stubborn slides (24, 66, 98). Each reported specific extraction gaps; this commit lands the three smallest framework fixes that came out of those reports. 1. **`` on pie/doughnut.** Source decks rotating the first slice into a fixed position (corporate showcase pattern for highlighting a specific category) carry `` on the chart element. The decompile ignored it, so every slice's start angle was off by N°. Now applied as an additive rotation on top of the standard 12-o'clock start. 2. **Chart series + dPt colours skip nearest_token.** `_emit_pie_chart` and `_emit_bar_chart` resolved `` and per-data-point `` colours through the brand palette via `_resolve_fill(..., palette)`. For brands lacking explicit chart-series tokens (the `blank` super-design, customer packs that override only accent), nearest_token collapsed source-distinct hues to the same brand token — every slice rendered the same colour. Resolving with `palette={}` lets the source hex propagate verbatim, the SVG-render path already passes literal hex through. 3. **Value labels in horizontal stacked / percentStacked bars.** The `_emit_bar_chart` value-label branch only fired in the standard side-by-side path; the stacked branch had no label emission even when `` was set. Labels now emit at the midpoint of each segment, gated on `show_val_labels` and segment width > 200000 EMU (skips labels in microscopic segments). End-to-end on the 99-slide showcase deck, with framework fixes only (no hand-patches on stubborn slides): mean struct_diff: 6.18% (93.82% quality) slides ≥85% quality: 96/99 (97%) Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 53 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 0296381..214b59e 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -1856,8 +1856,15 @@ def _emit_pie_chart(pie_el, x0, y0, fw, fh, shapes, cmap, theme=None, palette=No # identifying the slice index plus an optional # with that slice's brand color. Falls back to # the chart-series-N ramp by index when absent. + # + # We resolve dPt fills via `palette={}` so the source's exact hex + # propagates through unchanged. Going through the brand-token + # nearest_token collapses two close source hues to the same token + # ("accent"), which then renders identically — defeating the whole + # purpose of per-slice colours. Same rationale as the custGeom + # palette-bypass added earlier. slice_colors: dict[int, str] = {} - if theme is not None and palette is not None: + if theme is not None: for dpt in ser.findall(f"{{{CHART_NS}}}dPt"): idx_el = dpt.find(f"{{{CHART_NS}}}idx") sp_pr = dpt.find(f"{{{CHART_NS}}}spPr") @@ -1867,7 +1874,7 @@ def _emit_pie_chart(pie_el, x0, y0, fw, fh, shapes, cmap, theme=None, palette=No idx = int(idx_el.get("val") or "-1") except (TypeError, ValueError): continue - color = _resolve_fill(sp_pr, theme, palette) + color = _resolve_fill(sp_pr, theme, palette={}) if color and idx >= 0: slice_colors[idx] = color @@ -1975,7 +1982,18 @@ def _emit_pie_chart(pie_el, x0, y0, fw, fh, shapes, cmap, theme=None, palette=No # Start at 12 o'clock (-π/2), sweep clockwise. PowerPoint pies follow # this convention; matching it preserves slice-to-color correspondence # against the source. + # + # `` rotates the start clockwise by N + # degrees (0..360). Sources rarely use it but when they do (corporate + # decks rotating the highlighted slice into a fixed position), the + # entire slice-to-colour ordering shifts. angle_start = -math.pi / 2 + first_ang_el = pie_el.find(f"{{{CHART_NS}}}firstSliceAng") + if first_ang_el is not None and first_ang_el.get("val"): + try: + angle_start += math.radians(float(first_ang_el.get("val"))) + except (TypeError, ValueError): + pass for i, v in enumerate(values): if v <= 0: @@ -2133,18 +2151,17 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): name = name_el.text if name_el is not None else "?" vals = [float(v.text) for v in ser.findall(f".//{{{CHART_NS}}}val//{{{CHART_NS}}}pt/{{{CHART_NS}}}v")] cats = [v.text for v in ser.findall(f".//{{{CHART_NS}}}cat//{{{CHART_NS}}}pt/{{{CHART_NS}}}v")] - # Per-series fill colour from . Falls - # back to None so the caller knows to use the chart-series-N ramp - # by index instead. Reads either scheme or srgb fills via the - # existing solid-fill resolver. + # Per-series fill colour. Resolve via empty palette so the source + # hex propagates verbatim — going through nearest_token collapses + # two close source hues to the same brand token, losing the + # series-to-series colour distinction on stacked / clustered + # bar charts. sp_pr = ser.find(f"{{{CHART_NS}}}spPr") - ser_color = _resolve_fill(sp_pr, theme, palette) if sp_pr is not None else None + ser_color = _resolve_fill(sp_pr, theme, palette={}) if sp_pr is not None else None # Per-data-point colours from `` overrides. Showcase decks # often colour alternating bars different hues to highlight a # specific category — that information lives in dPt only, not on - # the series. Without this lookup every bar in the series renders - # the series colour, losing the highlight pattern. Mirrors the - # equivalent handling in `_emit_pie_chart`. + # the series. Same palette={} bypass. bar_colors: dict[int, str] = {} for dpt in ser.findall(f"{{{CHART_NS}}}dPt"): idx_el = dpt.find(f"{{{CHART_NS}}}idx") @@ -2155,7 +2172,7 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): idx = int(idx_el.get("val") or "-1") except (TypeError, ValueError): continue - color = _resolve_fill(dpt_sp, theme, palette) + color = _resolve_fill(dpt_sp, theme, palette={}) if color and idx >= 0: bar_colors[idx] = color series.append((name, vals, cats, ser_color, bar_colors)) @@ -2319,6 +2336,20 @@ def _dlbl_flag(parent, name: str) -> bool: w=cmap.w(seg_w), h=cmap.h(bar_h), fill=color, )) + # Value labels for stacked / percentStacked horizontal + # bars — emit ONLY when source has ``. + # Label sits in the middle of its segment. + if show_val_labels and seg_w > 200000: + label = str(v).rstrip("0").rstrip(".") if "." in str(v) else str(v) + label = label.replace(".", ",") + shapes.append(Shape( + kind="text", + x=cmap.x(cursor_x + seg_w // 2 - 100000), + y=cmap.y(row_y), + w=cmap.w(200000), + h=cmap.h(int(bar_h)), + text_runs=[TextRun(text=label, pt=12)], + )) cursor_x += seg_w else: bar_h = int(cat_h / (n_series + gap_pct / 100)) From 1ae0a448ff2af0a3fd312a1524066c0ecc215b22 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 20:50:22 +0200 Subject: [PATCH 11/13] fix(decompile): honour for pie sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pie / doughnut chart frames carry an explicit plot-area layout when the deck author has manually positioned the pie inside a larger frame (the dominant case in showcase decks that pair a pie with a legend block in the same frame): ... `_emit_pie_chart` used a fixed heuristic (60% or 50% of frame width depending on aspect ratio + legend presence) that put the pie center on PowerPoint's default-layout position, ignoring the author's explicit placement. For slide-66's chart14 (with manualLayout x=0.264 y=0.130 w=0.449 h=0.642 — pie occupies the LEFT 70% of the frame), the heuristic placed the pie centre-right of the frame and sized it too small. Now `_emit_pie_chart` reads `//` x/y/w/h fractions and uses them directly when present. The radius becomes half the plot-area's shortest side (the manualLayout already reserved margin for labels); falls back to the aspect-based heuristic when source uses `` (auto-layout). End-to-end on slide-66: 23.4% → 15.5% struct_diff in one extraction. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 73 +++++++++++++++++------ 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 214b59e..1a8fb00 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -1942,27 +1942,66 @@ def _emit_pie_chart(pie_el, x0, y0, fw, fh, shapes, cmap, theme=None, palette=No # svg-block-local pixel coords for slice paths. The block's outer # bbox is the chart frame; coords inside are 0..bbox_w_px by - # 0..bbox_h_px. min(w,h) keeps pies circular in non-square frames. - # Pie-area fraction adapts to chart-frame aspect ratio: wide frames - # (multi-pie-in-column layouts) keep ~60% pie area so pies fill the - # narrow column adequately; square-ish frames (single-big-pie - # layouts) shrink to 45% so the pie + adjacent legend mirror - # PowerPoint's left-edge placement. + # 0..bbox_h_px. bbox_w_px = cmap.w(fw) bbox_h_px = cmap.h(fh) - frame_aspect = bbox_w_px / bbox_h_px if bbox_h_px else 1.0 - if categories and legend_pos in ("l", "r"): - pie_w_frac = 0.60 if frame_aspect > 1.4 else 0.50 + + # `` gives EXACT fractional + # plot-area position within the chart frame (xMode/yMode="edge" with + # x/y/w/h as fractions of bbox_w/bbox_h). When present, use those + # directly — they're what PowerPoint's layout engine resolved when + # the deck author placed the chart. Falls back to the aspect-based + # heuristic when the source uses `` (auto-layout). + pa_layout = None + chart_root_for_layout = chart_root + if chart_root_for_layout is not None: + pa_layout = chart_root_for_layout.find( + f".//{{{CHART_NS}}}plotArea/{{{CHART_NS}}}layout/{{{CHART_NS}}}manualLayout" + ) + + def _layout_frac(el, tag: str, default: float | None = None) -> float | None: + if el is None: + return default + c = el.find(f"{{{CHART_NS}}}{tag}") + if c is None or not c.get("val"): + return default + try: + return float(c.get("val")) + except (TypeError, ValueError): + return default + + if pa_layout is not None: + plot_xf = _layout_frac(pa_layout, "x", 0.0) or 0.0 + plot_yf = _layout_frac(pa_layout, "y", 0.0) or 0.0 + plot_wf = _layout_frac(pa_layout, "w", 1.0) or 1.0 + plot_hf = _layout_frac(pa_layout, "h", 1.0) or 1.0 + pie_off_x = plot_xf * bbox_w_px + pie_off_y = plot_yf * bbox_h_px + pie_w_px = plot_wf * bbox_w_px + pie_h_px = plot_hf * bbox_h_px else: - pie_w_frac = 1.0 - pie_w_px = bbox_w_px * pie_w_frac - pie_h_px = bbox_h_px - pie_off_x = (bbox_w_px - pie_w_px) if legend_pos == "l" else 0 + # Heuristic: pie-area fraction adapts to chart-frame aspect. + # Wide frames (multi-pie-in-column layouts) keep ~60%; square-ish + # frames shrink to ~50% leaving room for the legend. + frame_aspect = bbox_w_px / bbox_h_px if bbox_h_px else 1.0 + if categories and legend_pos in ("l", "r"): + pie_w_frac = 0.60 if frame_aspect > 1.4 else 0.50 + else: + pie_w_frac = 1.0 + pie_w_px = bbox_w_px * pie_w_frac + pie_h_px = bbox_h_px + pie_off_x = (bbox_w_px - pie_w_px) if legend_pos == "l" else 0.0 + pie_off_y = 0.0 cx_px = pie_off_x + pie_w_px / 2 - cy_px = pie_h_px / 2 - # 0.36 of pie-area min dimension leaves margin for external percentage - # labels around the circumference. - r_px = min(pie_w_px, pie_h_px) * 0.36 + cy_px = pie_off_y + pie_h_px / 2 + # min(w,h) keeps pies circular in non-square frames. When manualLayout + # gave us a plot-area smaller than the chart frame, the radius is half + # the plot's shortest side (no further margin); otherwise 0.36 leaves + # margin for the auto-layout's external label placement. + if pa_layout is not None: + r_px = min(pie_w_px, pie_h_px) / 2.0 + else: + r_px = min(pie_w_px, pie_h_px) * 0.36 # Doughnut hole: `` on `` where # N is 10..90 = inner-radius percentage of outer radius. Default 50 From 98d7232a2037415deba19e468c0dc72bdbc78eac Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 20:53:47 +0200 Subject: [PATCH 12/13] fix(decompile): table cell text bypasses placeholder filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` cells carrying literal phrases like "Placeholder text" or "This text can be replaced with your own text." are the actual content the source renders — they're visible in every cell of a showcase table that demonstrates the layout. The placeholder-text filter (designed to drop layout-level demo prompts) was suppressing them too, leaving table cards empty in our render while source showed them filled with the demonstrative copy. Adds `Shape.skip_placeholder_filter` bool flag. `_emit_table` sets it to True on every text shape it emits. `emit_dsl` passes those shapes through unfiltered before running the standard placeholder suppression on the rest. Layout-level placeholder prompts in normal `` shapes are still suppressed as before — the flag only short-circuits table cells. Marginal improvement on slide-24 (35.5% → 35.1%) since the dominant residual diff there comes from the source's stair-step geometry, not the table text. But it's the right semantic. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 1a8fb00..f3c37d2 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -134,6 +134,12 @@ class Shape: # placeholder (genericised brand-template behaviour). media_path: str | None = None media_rid: str | None = None # rId of the , for extraction + # When True, `emit_dsl` skips the placeholder-text filter for this + # text shape. Set for text emitted from inside a `` cell, so + # phrases like "Placeholder text" that the filter would normally + # drop are kept — table cells are the actual rendered content, not + # layout-level demo prompts. + skip_placeholder_filter: bool = False # Resolved python-pptx Part for the embedded image, captured at walk # time so the rId is looked up against the part that actually owns it # (slide vs. layout vs. master). Without this, layout-inherited @@ -1811,6 +1817,7 @@ def _emit_table(tbl, x0, y0, shapes, cmap, theme, palette): kind="text", x=cmap.x(x_cursor + cw // 20), y=cmap.y(y_cursor + row_h // 4), w=cmap.w(cw - cw // 10), h=cmap.h(row_h), text_runs=runs, + skip_placeholder_filter=True, )) x_cursor += cw y_cursor += row_h @@ -2749,6 +2756,13 @@ def emit_dsl(shapes: list[Shape], cmap: CanvasMap, layout_name: str, # the whole shape gets dropped. filtered: list[Shape] = [] for t in texts: + if t.skip_placeholder_filter: + # Table cell text — pass through unfiltered. The table cell + # IS the content the source renders; suppressing "Placeholder + # text" / "This text can be replaced…" here strips visible + # source content rather than layout-level demo prompts. + filtered.append(t) + continue if _is_placeholder_text(t.text_runs): continue stripped = _strip_placeholder_paragraphs(t.text_runs) From 86c974e35413ec7541c2827812ef049949629bf5 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 20 May 2026 21:00:45 +0200 Subject: [PATCH 13/13] Revert "fix(decompile): table cell text bypasses placeholder filter" This reverts commit 98d7232a2037415deba19e468c0dc72bdbc78eac. Signed-off-by: Mike Mueller --- feinschliff/lib/dsl/pptx_svg_decompile.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index f3c37d2..1a8fb00 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -134,12 +134,6 @@ class Shape: # placeholder (genericised brand-template behaviour). media_path: str | None = None media_rid: str | None = None # rId of the , for extraction - # When True, `emit_dsl` skips the placeholder-text filter for this - # text shape. Set for text emitted from inside a `` cell, so - # phrases like "Placeholder text" that the filter would normally - # drop are kept — table cells are the actual rendered content, not - # layout-level demo prompts. - skip_placeholder_filter: bool = False # Resolved python-pptx Part for the embedded image, captured at walk # time so the rId is looked up against the part that actually owns it # (slide vs. layout vs. master). Without this, layout-inherited @@ -1817,7 +1811,6 @@ def _emit_table(tbl, x0, y0, shapes, cmap, theme, palette): kind="text", x=cmap.x(x_cursor + cw // 20), y=cmap.y(y_cursor + row_h // 4), w=cmap.w(cw - cw // 10), h=cmap.h(row_h), text_runs=runs, - skip_placeholder_filter=True, )) x_cursor += cw y_cursor += row_h @@ -2756,13 +2749,6 @@ def emit_dsl(shapes: list[Shape], cmap: CanvasMap, layout_name: str, # the whole shape gets dropped. filtered: list[Shape] = [] for t in texts: - if t.skip_placeholder_filter: - # Table cell text — pass through unfiltered. The table cell - # IS the content the source renders; suppressing "Placeholder - # text" / "This text can be replaced…" here strips visible - # source content rather than layout-level demo prompts. - filtered.append(t) - continue if _is_placeholder_text(t.text_runs): continue stripped = _strip_placeholder_paragraphs(t.text_runs)