diff --git a/feinschliff/lib/dsl/pptx_svg_decompile.py b/feinschliff/lib/dsl/pptx_svg_decompile.py index 27e07db..1a8fb00 100644 --- a/feinschliff/lib/dsl/pptx_svg_decompile.py +++ b/feinschliff/lib/dsl/pptx_svg_decompile.py @@ -446,6 +446,8 @@ 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) if scheme is not None: @@ -453,15 +455,8 @@ 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 @@ -490,6 +485,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 +564,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 +587,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"): @@ -598,20 +656,94 @@ 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 _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") - 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 = _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) 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 = _apply_color_mods(rgb, scheme) + rgb = _blend_on_white(rgb, _alpha_for_color(scheme)) + return nearest_token(rgb, palette) return None @@ -803,12 +935,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 # --------------------------------------------------------------------------- @@ -1096,7 +1336,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 @@ -1262,6 +1505,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( @@ -1602,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") @@ -1613,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 @@ -1649,10 +1910,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; @@ -1672,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 @@ -1712,7 +2021,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: @@ -1870,13 +2190,31 @@ 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 - series.append((name, vals, cats, ser_color)) + 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. 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") + 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 @@ -1904,43 +2242,87 @@ 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)], - )) + # 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( + 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 @@ -1976,11 +2358,13 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): 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: @@ -1991,14 +2375,29 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): 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)) 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 @@ -2008,23 +2407,25 @@ 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 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 @@ -2034,28 +2435,38 @@ 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 + 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 @@ -2066,8 +2477,10 @@ def _emit_chart(chart_part, x0, y0, fw, fh, shapes, cmap, theme, palette): 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) 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",