From 73af8dd392f34bc8fbf141db0860d48d1b2241fd Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Fri, 24 Jul 2026 17:55:29 +0900 Subject: [PATCH 01/22] fix(converter): 3 robustness bugs found by real-world PPTX corpus testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran pptx_to_json over 140 real presentations (Canva / Google Slides exports, business templates, AWS decks). Result before: 12 crashes + silent text loss. After: 0 crashes, 0 missing text lines. 1. Chart per-point colors (c:dPt) were stored with int dict keys; minimal-mode _strip_internal_keys calls k.startswith() and crashed on every deck containing such charts (AttributeError). Store str keys (matches builder, which does int(pt_idx_str)) and make the strip tolerant of non-str keys. 2. Duplicate placeholder idx on one slide (Google Slides exports emit two TITLE/idx=0 shapes) — the second shape's text was silently dropped. Rescue extras as positioned textbox elements. 3. Multi-paragraph title placeholders lost every paragraph after the first (only paragraphs[0] was styled). Style and join all paragraphs. Regression tests included; each fails against the pre-fix converter. --- skill/sdpm/converter/chart.py | 6 +- skill/sdpm/converter/slide.py | 32 ++++++- skill/sdpm/schema/minimal.py | 2 +- tests/test_converter_robustness.py | 138 +++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/test_converter_robustness.py diff --git a/skill/sdpm/converter/chart.py b/skill/sdpm/converter/chart.py index d3a10806..3bc3c75b 100644 --- a/skill/sdpm/converter/chart.py +++ b/skill/sdpm/converter/chart.py @@ -136,15 +136,15 @@ def extract_chart_element(shape, theme_colors=None, color_mapping=None): srgb = solid.find('a:srgbClr', ns_c) scheme = solid.find('a:schemeClr', ns_c) if srgb is not None: - point_colors[pt_idx] = _hex(srgb) + point_colors[str(pt_idx)] = _hex(srgb) elif scheme is not None: resolved = _resolve_scheme_color(scheme.get('val'), theme_colors, color_mapping) if resolved: - point_colors[pt_idx] = resolved + point_colors[str(pt_idx)] = resolved elif grad is not None: # Preserve gradient as XML from lxml import etree as _et - point_xml[pt_idx] = _et.tostring(spPr, encoding='unicode') + point_xml[str(pt_idx)] = _et.tostring(spPr, encoding='unicode') if point_colors and si < len(series_list): series_list[si]["pointColors"] = point_colors if point_xml and si < len(series_list): diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 87072622..907c499f 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -151,6 +151,7 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non # Extract placeholders by idx placeholders = {} + dup_placeholder_ids = set() # extras when one slide reuses a placeholder idx for shape in slide.shapes: if not shape.is_placeholder or not shape.has_text_frame or not shape.text_frame.text.strip(): continue @@ -161,6 +162,10 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non continue str_idx = str(idx) if str_idx in placeholders: + # Duplicate placeholder idx on one slide (Google Slides exports do + # this — e.g. two TITLE/idx=0 shapes). The dict can hold only one, + # so rescue the extras as positioned textbox elements below. + dup_placeholder_ids.add(shape.shape_id) continue text = shape.text # Styled text for title @@ -170,7 +175,16 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non tx1 = color_mapping.get('tx1', 'dk1') if theme_colors and tx1 in theme_colors: default_tc = theme_colors[tx1] - styled = _extract_styled_text(shape.text_frame.paragraphs[0].runs, theme_colors, color_mapping, default_text_color=default_tc, paragraph=shape.text_frame.paragraphs[0]) if shape.text_frame.paragraphs else text + paras = [p for p in shape.text_frame.paragraphs] + if paras: + # Style every paragraph, not just the first — multi-paragraph + # titles (common in Google Slides exports) lost lines 2+. + styled = "\n".join( + _extract_styled_text(p.runs, theme_colors, color_mapping, default_text_color=default_tc, paragraph=p) + for p in paras + ) + else: + styled = text text = styled if styled != shape.text else shape.text val = text # Check for explicit font size (all runs same size) @@ -301,6 +315,22 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non pass elements.append(elem) + # Rescue duplicate-idx placeholders (recorded above) as positioned + # textboxes — their text would otherwise be silently dropped. + for shape in slide.shapes: + if shape.shape_id not in dup_placeholder_ids: + continue + try: + elem = extract_textbox_element(shape, theme_colors, color_mapping, theme_styles, is_placeholder=True, builder_text_color=builder_text_color) + if elem and (elem.get("text", "").strip() or elem.get("paragraphs")): + elem["x"] = round(shape.left / EMU_PER_PX) + elem["y"] = round(shape.top / EMU_PER_PX) + elem["width"] = round(shape.width / EMU_PER_PX) + elem["height"] = round(shape.height / EMU_PER_PX) + elements.append(elem) + except Exception: + pass + # Extract placeholder images (PICTURE type or pic element) img_counter = 0 for shape in slide.shapes: diff --git a/skill/sdpm/schema/minimal.py b/skill/sdpm/schema/minimal.py index 24986305..b0a87b8e 100644 --- a/skill/sdpm/schema/minimal.py +++ b/skill/sdpm/schema/minimal.py @@ -60,7 +60,7 @@ def _strip_conditional(elem: dict) -> None: def _strip_internal_keys(obj): if isinstance(obj, dict): - return {k: _strip_internal_keys(v) for k, v in obj.items() if not k.startswith('_')} + return {k: _strip_internal_keys(v) for k, v in obj.items() if not (isinstance(k, str) and k.startswith('_'))} if isinstance(obj, list): return [_strip_internal_keys(i) for i in obj] return obj diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py new file mode 100644 index 00000000..c7715b8d --- /dev/null +++ b/tests/test_converter_robustness.py @@ -0,0 +1,138 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Converter robustness tests from real-world PPTX corpus findings. + +Each test reproduces a bug found by running pptx_to_json over a corpus of +~140 real presentations (Canva/Google Slides exports, business templates): + +1. Chart per-point colors used int dict keys → minimal-mode strip crashed + with AttributeError on every deck containing such charts. +2. Slides with a duplicate placeholder idx (Google Slides exports emit two + TITLE/idx=0 shapes on one slide) silently dropped the second shape. +3. Multi-paragraph title placeholders lost every paragraph after the first. +""" + +import copy +import json +from pathlib import Path + +from pptx import Presentation +from pptx.util import Emu + +from sdpm.converter.pipeline import pptx_to_json +from sdpm.schema.minimal import _strip_internal_keys + + +def _template() -> Path: + return Path(__file__).parent.parent / "skill" / "templates" / "blank-dark.pptx" + + +def _title_slide(prs): + """Add a slide using a layout that has a title placeholder.""" + for layout in prs.slide_layouts: + try: + slide = prs.slides.add_slide(layout) + except Exception: + continue + if slide.shapes.title is not None: + return slide + # remove unusable slide? keep simple: continue scanning + raise AssertionError("no layout with title placeholder in template") + + +class TestMinimalStripNonStrKeys: + def test_int_keys_do_not_crash(self): + slide = { + "layout": "Blank", + "elements": [{ + "type": "chart", + "chartType": "pie", + "series": [{"name": "s", "values": [1, 2], "pointColors": {0: "#FF0000", 1: "#00FF00"}}], + "_internal": "dropme", + }], + } + out = _strip_internal_keys(slide) + el = out["elements"][0] + assert "_internal" not in el + # int keys survive the strip untouched (previously: AttributeError) + assert el["series"][0]["pointColors"] == {0: "#FF0000", 1: "#00FF00"} + + +class TestChartPointColorKeys: + def test_extracted_point_colors_use_str_keys(self, tmp_path): + # Build a pie chart with explicit per-point colors via raw dPt XML + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + from lxml import etree + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + data = CategoryChartData() + data.categories = ["a", "b"] + data.add_series("s1", (1.0, 2.0)) + gf = slide.shapes.add_chart( + XL_CHART_TYPE.PIE, Emu(0), Emu(0), Emu(3000000), Emu(3000000), data + ) + ser = gf.chart._chartSpace.findall( + ".//{http://schemas.openxmlformats.org/drawingml/2006/chart}ser")[0] + ns_c = "http://schemas.openxmlformats.org/drawingml/2006/chart" + ns_a = "http://schemas.openxmlformats.org/drawingml/2006/main" + for i, color in enumerate(["FF0000", "00FF00"]): + dpt = etree.SubElement(ser, f"{{{ns_c}}}dPt") + idx = etree.SubElement(dpt, f"{{{ns_c}}}idx") + idx.set("val", str(i)) + sppr = etree.SubElement(dpt, f"{{{ns_c}}}spPr") + fill = etree.SubElement(sppr, f"{{{ns_a}}}solidFill") + srgb = etree.SubElement(fill, f"{{{ns_a}}}srgbClr") + srgb.set("val", color) + pptx_path = tmp_path / "chart.pptx" + prs.save(str(pptx_path)) + + # minimal=True used to crash (int keys hit str.startswith in strip) + result = pptx_to_json(pptx_path, tmp_path / "out", minimal=True) + + charts = [el for s in result["slides"] for el in s.get("elements", []) if el.get("type") == "chart"] + assert charts, "chart element not extracted" + pc = charts[0]["series"][0].get("pointColors") + assert pc, "pointColors not extracted" + assert all(isinstance(k, str) for k in pc), f"non-str keys: {pc}" + + +class TestDuplicatePlaceholderIdx: + def test_second_same_idx_placeholder_is_rescued(self, tmp_path): + prs = Presentation(str(_template())) + slide = _title_slide(prs) + title = slide.shapes.title + title.text_frame.text = "first title" + # Clone the title sp element (same placeholder idx) — as emitted by + # Google Slides exports — and give it different text/position. + clone = copy.deepcopy(title._element) + title._element.addnext(clone) + second = [sh for sh in slide.shapes if sh.shape_id != title.shape_id and sh.is_placeholder][0] + second.text_frame.text = "second duplicate" + second.left = Emu(1000000) + second.top = Emu(2000000) + pptx_path = tmp_path / "dup.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + dumped = json.dumps(result["slides"], ensure_ascii=False) + assert "first title" in dumped + assert "second duplicate" in dumped, "duplicate-idx placeholder text was dropped" + + +class TestMultiParagraphTitle: + def test_title_keeps_all_paragraphs(self, tmp_path): + prs = Presentation(str(_template())) + slide = _title_slide(prs) + tf = slide.shapes.title.text_frame + tf.text = "line one" + p2 = tf.add_paragraph() + p2.text = "line two" + pptx_path = tmp_path / "multi.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + dumped = json.dumps(result["slides"], ensure_ascii=False) + assert "line one" in dumped + assert "line two" in dumped, "second title paragraph was dropped" From 768e01f9c6993e3e2fb321676e7f5aa062489ecf Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Fri, 24 Jul 2026 19:01:27 +0900 Subject: [PATCH 02/22] fix: 3 fidelity bugs found by visual roundtrip testing + diff blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered original vs converted-and-rebuilt PPTX to PNG (LibreOffice + pdftoppm) for a 15-deck sample and pixel-diffed each slide: 1. Hidden slides (show="0") were dropped silently: converter now emits "hidden": true, builder restores the attribute. Was the top visual defect — decks with hidden slides rebuilt with page offsets (worst-slide pixel diff 226.9 → 11.5 on a 56-slide deck). 2. Explicit image crop was defeated by fit=contain: with a crop the srcRect fills the frame (PowerPoint semantics), but the builder still shrank the frame to the *uncropped* aspect ratio. Skip contain/cover adjustment when "crop" is present (worst slide 62.0 → 2.6). 3. diff_report never compared "placeholders": hand-edits to titles (the most common edit) were invisible to Workflow C / diff_pptx. Tampered- file test: has_diff was False, now reports placeholder text changes. Regression tests added for all three. --- skill/sdpm/builder/__init__.py | 4 ++ skill/sdpm/builder/elements/image.py | 4 +- skill/sdpm/converter/slide.py | 3 + skill/sdpm/diff/__init__.py | 17 ++++++ tests/test_converter_robustness.py | 89 ++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index 23509366..6f031aca 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -198,6 +198,10 @@ def add_slide(self, slide_def: dict): }) slide = self.prs.slides.add_slide(layout) + # Hidden slide (round-trips PowerPoint's Hide Slide flag) + if slide_def.get("hidden"): + slide._element.set("show", "0") + self._fill_placeholders(slide, slide_def) # Per-slide theme override (save/restore around element processing) diff --git a/skill/sdpm/builder/elements/image.py b/skill/sdpm/builder/elements/image.py index 01a201bf..abe00e8e 100644 --- a/skill/sdpm/builder/elements/image.py +++ b/skill/sdpm/builder/elements/image.py @@ -149,7 +149,9 @@ def _add_image(self, slide, elem): img_w, img_h = img.size except Exception: img_w, img_h = 1, 1 - if img_w > 0 and img_h > 0: + if img_w > 0 and img_h > 0 and not elem.get("crop"): + # With an explicit crop, srcRect fills the frame + # (PowerPoint semantics) — no contain/cover adjustment. img_ratio = img_w / img_h box_ratio = width / height # Warn if aspect ratios differ significantly diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 907c499f..7e4ec4b6 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -128,6 +128,9 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non "layout": layout_name, "masterIndex": master_idx } + # Hidden slide (right-click → Hide Slide in PowerPoint) + if slide._element.get("show") == "0": + slide_dict["hidden"] = True # Extract slide background (if different from layout) try: diff --git a/skill/sdpm/diff/__init__.py b/skill/sdpm/diff/__init__.py index df25e72e..0b3552b5 100644 --- a/skill/sdpm/diff/__init__.py +++ b/skill/sdpm/diff/__init__.py @@ -276,6 +276,23 @@ def diff_report(baseline, edited) -> dict: if bv != ev and (bv or ev): slide_diffs.append(_diff_value(key, bv, ev)) + # Compare placeholders (title/body text captured by idx) — hand-edits + # to titles land here, not in elements. + b_ph = bs.get("placeholders") or {} + e_ph = es.get("placeholders") or {} + for idx in sorted(set(b_ph) | set(e_ph)): + bv, ev = b_ph.get(idx), e_ph.get(idx) + if bv == ev: + continue + b_txt = bv.get("text") if isinstance(bv, dict) else bv + e_txt = ev.get("text") if isinstance(ev, dict) else ev + if b_txt != e_txt: + slide_diffs.append(_diff_value(f"placeholder[{idx}]", b_txt, e_txt)) + elif bv != ev: + slide_diffs.append(_diff_value(f"placeholder[{idx}] (format/position)", + json.dumps(bv, ensure_ascii=False)[:60], + json.dumps(ev, ensure_ascii=False)[:60])) + b_elems = [e for e in bs.get("elements", []) if "_comment" not in e] e_elems = [e for e in es.get("elements", []) if "_comment" not in e] diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index c7715b8d..89715227 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -136,3 +136,92 @@ def test_title_keeps_all_paragraphs(self, tmp_path): dumped = json.dumps(result["slides"], ensure_ascii=False) assert "line one" in dumped assert "line two" in dumped, "second title paragraph was dropped" + + +class TestHiddenSlides: + def test_hidden_flag_roundtrips(self, tmp_path): + prs = Presentation(str(_template())) + s1 = prs.slides.add_slide(prs.slide_layouts[0]) + s2 = prs.slides.add_slide(prs.slide_layouts[0]) + s2._element.set("show", "0") # PowerPoint: Hide Slide + del s1 # only to silence linters + pptx_path = tmp_path / "hidden.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + # template may ship with its own slides — check the last two we added + hidden_flags = [sl.get("hidden") for sl in result["slides"]] + assert hidden_flags[-1] is True, f"hidden flag not extracted: {hidden_flags}" + assert hidden_flags[-2] is not True + + # Builder restores the flag + from sdpm.builder import PPTXBuilder + builder = PPTXBuilder(str(_template()), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, default_text_color="#FFFFFF") + builder.add_slide({"layout": builder_first_layout(builder)}) + builder.add_slide({"layout": builder_first_layout(builder), "hidden": True}) + out = tmp_path / "rebuilt.pptx" + builder.save(str(out)) + prs2 = Presentation(str(out)) + slides = list(prs2.slides) + assert slides[0]._element.get("show") != "0" + assert slides[1]._element.get("show") == "0", "hidden flag not rebuilt" + + +def builder_first_layout(builder) -> str: + return next(iter(builder.layouts)) + + +class TestDiffDetectsPlaceholderEdits: + def test_title_placeholder_edit_is_reported(self, tmp_path): + from sdpm.diff import diff_report + + base = {"slides": [{"layout": "L", "placeholders": {"0": "original title"}, "elements": []}]} + edit = {"slides": [{"layout": "L", "placeholders": {"0": "edited title"}, "elements": []}]} + bp = tmp_path / "base.json" + ep = tmp_path / "edit.json" + bp.write_text(json.dumps(base)) + ep.write_text(json.dumps(edit)) + + rep = diff_report(bp, ep) + assert rep["has_diff"], "placeholder edit went undetected" + assert "placeholder[0]" in rep["report"] + + +class TestExplicitCropKeepsFrame: + def test_cropped_image_fills_declared_box(self, tmp_path): + """With an explicit crop, the frame must NOT shrink to the uncropped + image's aspect ratio (PowerPoint srcRect semantics: crop fills frame).""" + from PIL import Image as PILImage + + img = tmp_path / "tall.png" + PILImage.new("RGB", (400, 800), "red").save(img) # tall image + + deck = tmp_path / "deck" + (deck / "slides").mkdir(parents=True) + (deck / "specs").mkdir() + (deck / "deck.json").write_text(json.dumps({ + "template": str(_template()), + "fonts": {"fullwidth": "Meiryo", "halfwidth": "Arial"}, + })) + (deck / "specs" / "outline.md").write_text("- [s1] test\n") + (deck / "slides" / "s1.json").write_text(json.dumps({ + "layout": "Blank", + "elements": [{ + "type": "image", "src": str(img), + "x": 0, "y": 0, "width": 800, "height": 200, # wide box + "crop": {"top": 40.0, "bottom": 35.0}, # crops to wide region + }], + })) + from sdpm.api import generate + out = tmp_path / "out.pptx" + generate(deck, output_path=out) + + prs = Presentation(str(out)) + pics = [sh for sh in prs.slides[0].shapes if sh.shape_type == 13] + assert pics, "picture not built" + pic = pics[0] + # Frame keeps the declared 800px-wide box (compare as a fraction of + # slide width to stay independent of the builder's px scale). The old + # fit=contain behaviour shrank width to height*aspect = 100px (~5%). + ratio = pic.width / prs.slide_width + assert ratio > 0.35, f"frame shrank despite explicit crop: width ratio={ratio:.2f}" From 87c7568067de6607fe4e97af18411279164d7c37 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 00:03:03 +0900 Subject: [PATCH 03/22] fix: theme table styles + gradient/image slide backgrounds; faithful-mode guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second visual-roundtrip pass over the corpus (140 decks rendered and pixel-diffed): 1. Theme table styles (tableStyleId) resolve to a table-level style dict (body/header/altRow/border). Previously themed tables lost all fills/borders and the builder repainted them with its default banding — white rows over dark decks (worst slide 74.8 → 7.2). 2. Gradient / picture-fill slide backgrounds are no longer dropped: the converter emits a bottom-of-z-order full-slide gradient shape or cover image (builder itself only supports solid backgrounds). Section-divider slides went from white-on-white to faithful (91.7 → 7.2, 80.2 → 2.2, 145.9 → 12.8). 3. Builder: unknown element types now print a warning instead of silently vanishing (this masked bug 2 — a mistyped element was dropped with no trace). 4. import-pptx guide (option b per design discussion): Step 4 now spreads _originalEffects (crop/mask/brightness) into image elements for faithful reproduction; builder contract unchanged. Regression tests: theme table style resolution, gradient-bg extraction, image-bg extraction. --- skill/references/guides/import-pptx.md | 10 +++ skill/sdpm/builder/__init__.py | 4 + skill/sdpm/converter/constants.py | 3 +- skill/sdpm/converter/slide.py | 42 +++++++++ skill/sdpm/converter/table.py | 50 ++++++++--- tests/test_converter_robustness.py | 116 +++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 14 deletions(-) diff --git a/skill/references/guides/import-pptx.md b/skill/references/guides/import-pptx.md index 655a89a6..8c18dc1a 100644 --- a/skill/references/guides/import-pptx.md +++ b/skill/references/guides/import-pptx.md @@ -232,6 +232,16 @@ else: mapped = image_mapping.get(base) if mapped: node["src"] = mapped + # Faithful reproduction: spread _originalEffects (crop, mask, + # brightness...) into the element. The builder ignores the + # underscore key by design — without this the original image + # framing (e.g. a full-width cropped photo band) is lost. + # Only skip this when you intentionally reuse the image as + # fresh material in a NEW slide of your own design. + oe = node.pop("_originalEffects", None) + if oe: + for k, v in oe.items(): + node.setdefault(k, v) for v in node.values(): _rewrite_image_refs(v) elif isinstance(node, list): diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index 6f031aca..419d2290 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -315,6 +315,10 @@ def add_slide(self, slide_def: dict): self._add_chart(slide, elem) elif elem_type == "video": self._add_video(slide, elem) + else: + import sys as _sys + print(f"Warning: unknown element type {elem_type!r} skipped " + f"(slide {slide_def.get('id', '?')})", file=_sys.stderr) if "notes" in slide_def: notes_frame = slide.notes_slide.notes_text_frame diff --git a/skill/sdpm/converter/constants.py b/skill/sdpm/converter/constants.py index aa9c8456..ff228f52 100644 --- a/skill/sdpm/converter/constants.py +++ b/skill/sdpm/converter/constants.py @@ -9,7 +9,8 @@ _NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', - 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main'} + 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', + 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'} EMU_PER_PX = 6350 diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 7e4ec4b6..017b4f20 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -133,6 +133,7 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non slide_dict["hidden"] = True # Extract slide background (if different from layout) + bg_extra_element = None try: bg = slide.background._element.find(f'{{{_NS["p"]}}}bg') if bg is not None: @@ -149,6 +150,45 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) if resolved: slide_dict["background"] = resolved + else: + # Gradient / image backgrounds: the builder only supports a + # solid slide background, so emit a full-slide element at + # the bottom of the z-order instead of dropping the fill. + try: + prs_obj = slide.part.package.presentation_part.presentation + slide_w = round(prs_obj.slide_width / EMU_PER_PX) + slide_h = round(prs_obj.slide_height / EMU_PER_PX) + except Exception: + slide_w, slide_h = 1920, 1080 + grad = bgPr.find(f'{{{_NS["a"]}}}gradFill') + blip_fill = bgPr.find(f'{{{_NS["a"]}}}blipFill') + if grad is not None: + from .xml_helpers import _extract_fill_from_xml + fill_info = _extract_fill_from_xml(bgPr, theme_colors, color_mapping) + if fill_info.get("gradient"): + bg_extra_element = { + "type": "shape", + "shape": "rectangle", + "x": 0, "y": 0, "width": slide_w, "height": slide_h, + "gradient": fill_info["gradient"], + "line": "none", + } + elif blip_fill is not None and output_dir: + blip = blip_fill.find(f'{{{_NS["a"]}}}blip') + rid = blip.get(f'{{{_NS["r"]}}}embed') if blip is not None else None + if rid: + img_part = slide.part.related_part(rid) + ext = img_part.content_type.split('/')[-1].replace('jpeg', 'jpg') + img_name = f"slide{slide_idx+1}_bg.{ext}" + img_dir = output_dir / "images" + img_dir.mkdir(parents=True, exist_ok=True) + (img_dir / img_name).write_bytes(img_part.blob) + bg_extra_element = { + "type": "image", + "src": f"images/{img_name}", + "x": 0, "y": 0, "width": slide_w, "height": slide_h, + "fit": "cover", + } except Exception: pass @@ -236,6 +276,8 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non # Extract content from placeholders as textboxes (to preserve position) elements = [] + if bg_extra_element is not None: + elements.append(bg_extra_element) for shape in slide.shapes: if shape.is_placeholder and shape.placeholder_format.type in (2, 7, 13): diff --git a/skill/sdpm/converter/table.py b/skill/sdpm/converter/table.py index 9eae405e..8ec28cce 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -174,18 +174,27 @@ def resolve_fill(tc_style): return base return None - def resolve_border_color(tc_bdr): + def resolve_border(tc_bdr): + """Return {'color': hex, 'width': pt} from the first solid border side.""" if tc_bdr is None: return None - for tag in ['a:left', 'a:right', 'a:top', 'a:bottom', 'a:insideH', 'a:insideV']: - ln = tc_bdr.find(f'{tag}/a:ln/a:solidFill', _NS) - if ln is not None: - scheme = ln.find('a:schemeClr', _NS) - if scheme is not None: - return _resolve_scheme_color(scheme.get('val'), theme_colors, color_mapping) - srgb = ln.find('a:srgbClr', _NS) - if srgb is not None: - return _hex(srgb) + for tag in ['a:insideH', 'a:insideV', 'a:left', 'a:right', 'a:top', 'a:bottom']: + ln_el = tc_bdr.find(f'{tag}/a:ln', _NS) + if ln_el is None: + continue + fill = ln_el.find('a:solidFill', _NS) + if fill is None: + continue + color = None + scheme = fill.find('a:schemeClr', _NS) + if scheme is not None: + color = _resolve_scheme_color(scheme.get('val'), theme_colors, color_mapping) + srgb = fill.find('a:srgbClr', _NS) + if srgb is not None: + color = _hex(srgb) + if color: + w = ln_el.get('w') + return {"color": color, "width": round(int(w) / 12700, 1) if w else 1} return None def resolve_text_color(tc_txt): @@ -207,9 +216,9 @@ def resolve_text_color(tc_txt): f = resolve_fill(tc_style) if f: info['background'] = f - bc = resolve_border_color(tc_style.find('a:tcBdr', _NS)) - if bc: - info['borderColor'] = bc + bd = resolve_border(tc_style.find('a:tcBdr', _NS)) + if bd: + info['border'] = bd tc_txt = part.find('a:tcTxStyle', _NS) if tc_txt is not None: tc = resolve_text_color(tc_txt) @@ -289,6 +298,21 @@ def extract_table_element(shape, theme_colors=None, color_mapping=None, pptx_pat style = band1 if ri % 2 == 0 else band2 elem["rows"][ri] = [_apply_style_to_cell(c, style) for c in row] + # Emit a table-level style so the builder reproduces the theme + # table style instead of applying its own default banding. + style_out = {} + body = {"background": band1.get("background", "none")} + header = {"background": first_row.get("background", body["background"])} + if first_row.get("font-weight"): + header["font-weight"] = first_row["font-weight"] + if has_band_row: + style_out["altRow"] = {"background": band2.get("background", "none")} + style_out["body"] = body + style_out["header"] = header + if whole.get("border"): + style_out["border"] = dict(whole["border"]) + elem["style"] = style_out + return elem except Exception as e: print(f"Warning: Failed to extract table: {e}", file=sys.stderr) diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 89715227..ce9ae6d3 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -225,3 +225,119 @@ def test_cropped_image_fills_declared_box(self, tmp_path): # fit=contain behaviour shrank width to height*aspect = 100px (~5%). ratio = pic.width / prs.slide_width assert ratio > 0.35, f"frame shrank despite explicit crop: width ratio={ratio:.2f}" + + +class TestTableThemeStyle: + def test_table_style_id_resolves_to_style_dict(self, tmp_path): + """Tables styled via tableStyleId (theme table styles) must emit a + table-level style dict; otherwise the builder overwrites the look + with its own default banding (white rows on dark decks).""" + import zipfile + + from pptx.util import Inches + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + gf = slide.shapes.add_table(3, 2, Inches(1), Inches(1), Inches(6), Inches(2)) + tbl = gf.table + for r in range(3): + for c in range(2): + tbl.cell(r, c).text = f"r{r}c{c}" + # style id python-pptx stamped on the table + ns_a = "http://schemas.openxmlformats.org/drawingml/2006/main" + sid = tbl._tbl.find(f"{{{ns_a}}}tblPr/{{{ns_a}}}tableStyleId").text + raw_path = tmp_path / "raw.pptx" + prs.save(str(raw_path)) + + # Inject a tableStyles.xml defining that style (fill + border) + table_styles = f''' + + + + + + + + + +''' + pptx_path = tmp_path / "styled_table.pptx" + with zipfile.ZipFile(raw_path) as zin, zipfile.ZipFile(pptx_path, "w") as zout: + for item in zin.namelist(): + if item == "ppt/tableStyles.xml": + zout.writestr(item, table_styles) + else: + zout.writestr(item, zin.read(item)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + tables = [el for s in result["slides"] for el in s.get("elements", []) if el.get("type") == "table"] + assert tables, "table element not extracted" + style = tables[0].get("style") + assert style is not None, "tableStyleId did not resolve to a style dict" + assert style["body"]["background"] == "#112233" + assert style["border"]["color"] == "#AD5CFF" + + +class TestBackgroundFills: + def test_gradient_background_becomes_fullslide_rect(self, tmp_path): + """Slides with a gradient background must not silently lose it — + the converter emits a full-slide gradient rectangle.""" + from lxml import etree + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + ns_a = "http://schemas.openxmlformats.org/drawingml/2006/main" + ns_p = "http://schemas.openxmlformats.org/presentationml/2006/main" + bg = etree.SubElement(slide._element.find(f"{{{ns_p}}}cSld"), f"{{{ns_p}}}bg") + bgpr = etree.SubElement(bg, f"{{{ns_p}}}bgPr") + grad = etree.fromstring( + f'' + f'' + f'' + f'') + bgpr.append(grad) + etree.SubElement(bgpr, f"{{{ns_a}}}effectLst") + # move bg before spTree (schema order) + csld = slide._element.find(f"{{{ns_p}}}cSld") + csld.remove(bg) + csld.insert(0, bg) + pptx_path = tmp_path / "gradbg.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + last = result["slides"][-1] + els = last.get("elements", []) + assert els and els[0].get("type") == "shape" and els[0].get("shape") == "rectangle" \ + and els[0].get("gradient"), f"gradient background not extracted: {els[:1]}" + + def test_image_background_becomes_fullslide_image(self, tmp_path): + """Slides with a picture-fill background keep it as a cover image.""" + from lxml import etree + from PIL import Image as PILImage + + img_file = tmp_path / "bg.png" + PILImage.new("RGB", (32, 18), "blue").save(img_file) + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + # add picture to obtain an image part + rId, then reference it from bg + pic = slide.shapes.add_picture(str(img_file), 0, 0) + ns_a = "http://schemas.openxmlformats.org/drawingml/2006/main" + ns_p = "http://schemas.openxmlformats.org/presentationml/2006/main" + ns_r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + rid = pic._element.find(f".//{{{ns_a}}}blip").get(f"{{{ns_r}}}embed") + slide.shapes._spTree.remove(pic._element) # picture itself not needed + csld = slide._element.find(f"{{{ns_p}}}cSld") + bg = etree.fromstring( + f'' + f'' + f'') + csld.insert(0, bg) + pptx_path = tmp_path / "imgbg.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + last = result["slides"][-1] + els = last.get("elements", []) + assert els and els[0].get("type") == "image" and "_bg" in els[0].get("src", ""), \ + f"image background not extracted: {els[:1]}" From ba77a945ea7416cdc8ed5e2ee4cb976c90450352 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 00:41:50 +0900 Subject: [PATCH 04/22] fix: roundtrip showMasterSp=0 (Hide Background Graphics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slides using PowerPoint's 'Hide Background Graphics' lost the flag on conversion; on rebuild the master's decoration (e.g. a white cover shape) reappeared over the slide's own background fill, rendering white-on-white (worst slide pixel diff 99.7 → 1.7). Converter emits "hideMasterShapes": true; builder restores the attribute. Regression test included. --- skill/sdpm/builder/__init__.py | 4 ++++ skill/sdpm/converter/slide.py | 5 +++++ tests/test_converter_robustness.py | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index 419d2290..6c95274c 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -202,6 +202,10 @@ def add_slide(self, slide_def: dict): if slide_def.get("hidden"): slide._element.set("show", "0") + # Hide Background Graphics (round-trips showMasterSp="0") + if slide_def.get("hideMasterShapes"): + slide._element.set("showMasterSp", "0") + self._fill_placeholders(slide, slide_def) # Per-slide theme override (save/restore around element processing) diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 017b4f20..183bec9c 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -131,6 +131,11 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non # Hidden slide (right-click → Hide Slide in PowerPoint) if slide._element.get("show") == "0": slide_dict["hidden"] = True + # Hide Background Graphics (master shapes suppressed on this slide). + # Losing this made master decoration (e.g. a white cover shape) reappear + # over the slide's own background fill. + if slide._element.get("showMasterSp") == "0": + slide_dict["hideMasterShapes"] = True # Extract slide background (if different from layout) bg_extra_element = None diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index ce9ae6d3..7af35772 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -341,3 +341,27 @@ def test_image_background_becomes_fullslide_image(self, tmp_path): els = last.get("elements", []) assert els and els[0].get("type") == "image" and "_bg" in els[0].get("src", ""), \ f"image background not extracted: {els[:1]}" + + +class TestHideMasterShapes: + def test_show_master_sp_roundtrips(self, tmp_path): + """showMasterSp="0" (Hide Background Graphics) must roundtrip; + losing it lets master decoration cover the slide's own background.""" + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide._element.set("showMasterSp", "0") + pptx_path = tmp_path / "hidemaster.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + assert result["slides"][-1].get("hideMasterShapes") is True, \ + "showMasterSp=0 not extracted" + + from sdpm.builder import PPTXBuilder + builder = PPTXBuilder(str(_template()), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, default_text_color="#FFFFFF") + builder.add_slide({"layout": builder_first_layout(builder), "hideMasterShapes": True}) + out = tmp_path / "rebuilt.pptx" + builder.save(str(out)) + prs2 = Presentation(str(out)) + assert list(prs2.slides)[-1]._element.get("showMasterSp") == "0", \ + "showMasterSp=0 not rebuilt" From f1d3e6babfc0454a2e229689f2471c5d4330a2fc Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 02:12:04 +0900 Subject: [PATCH 05/22] fix: eliminate remaining worst-fidelity slides (9 fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chased every slide still scoring >50 pixel-diff in the visual roundtrip: 1. SVG artwork recolored to theme color: imported SVGs (waves, decorative graphics) were treated as theme icons and repainted. New iconColor "none" opt-out; converter sets it on extracted SVGs (55.9 → 14.9). 2. SVG-only slide backgrounds (svgBlip extension without raster embed) were skipped (89.3 → 11.6). 3. Images inside raw-XML groups dangled: _groupXml passthrough kept source rIds that mean nothing in the new package. Converter saves a rId→path map (_groupImages); builder re-adds parts and rewrites ids (71.4 → 1.4). 4. Picture-filled shapes (spPr blipFill on textbox/autoshape) dropped the picture; now extracted as a cover image element. 5. WordArt-class shapes (prstTxWarp arch/circle text, per-character picture fill) can't be expressed in the JSON schema — new rawShape element carries the verbatim XML + reattached images (76.8 → 3.9). 6. Run-level text effects (glow/shadow) lost; roundtripped via _textEffects raw XML (part of 53.6 → 4.4). 7. font= styled tag only set a:latin — CJK glyphs kept the theme font; now sets a:ea too (rest of 53.6 → 4.4). 8. lumMod/tint/shade on srgbClr ignored (only schemeClr was handled): new apply_element_transforms; also prstClr preset color names and semi-transparent slide backgrounds (alpha blend) (59.3 → 13.6). 9. Table cells without an anchor rendered middle-aligned: OOXML default is top; converter emits vertical-align explicitly (builder default unchanged — documented schema behavior for AI-authored decks). Not fixed (documented long tail): empty picture-placeholder mosaics (PowerPoint itself renders them blank) and 36%-alpha text (schema has no text opacity). Regression tests for all fix categories. --- skill/sdpm/builder/__init__.py | 2 + skill/sdpm/builder/elements/group.py | 37 ++++++ skill/sdpm/builder/elements/image.py | 6 +- skill/sdpm/builder/elements/textbox.py | 23 ++++ skill/sdpm/builder/formatting.py | 15 +++ skill/sdpm/converter/color.py | 57 +++++++++ skill/sdpm/converter/elements.py | 163 ++++++++++++++++++++++++- skill/sdpm/converter/slide.py | 35 +++++- skill/sdpm/converter/table.py | 10 +- tests/test_converter_robustness.py | 128 +++++++++++++++++++ 10 files changed, 467 insertions(+), 9 deletions(-) diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index 6c95274c..bdfcf7aa 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -319,6 +319,8 @@ def add_slide(self, slide_def: dict): self._add_chart(slide, elem) elif elem_type == "video": self._add_video(slide, elem) + elif elem_type == "rawShape": + self._add_raw_shape(slide, elem) else: import sys as _sys print(f"Warning: unknown element type {elem_type!r} skipped " diff --git a/skill/sdpm/builder/elements/group.py b/skill/sdpm/builder/elements/group.py index ad7f5b96..11b3e7f8 100644 --- a/skill/sdpm/builder/elements/group.py +++ b/skill/sdpm/builder/elements/group.py @@ -15,6 +15,10 @@ def _add_group(self, slide, elem): from lxml import etree from pptx.oxml.ns import qn grp_el = etree.fromstring(group_xml) + # Re-attach images referenced from inside the group: the source + # rIds are meaningless in this package, so add each saved image + # as a part of the new slide and rewrite r:embed/r:link. + self._reattach_xml_images(slide, grp_el, elem.get("_groupImages")) spTree = slide._element.find(qn('p:cSld')).find(qn('p:spTree')) spTree.append(grp_el) return @@ -37,6 +41,39 @@ def _add_group(self, slide, elem): elif sub_type == "chart": self._add_chart(slide, sub_elem) + def _reattach_xml_images(self, slide, xml_el, images_map): + """Rewrite r:embed/r:link ids in injected XML to freshly added parts.""" + if not images_map: + return + r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + new_rids = {} + for old_rid, rel_path in images_map.items(): + try: + img_path = self._base_dir / rel_path + if not img_path.exists(): + continue + image_part, rid = slide.part.get_or_add_image_part(str(img_path)) + new_rids[old_rid] = rid + except Exception: + continue + for el_ref in xml_el.iter(): + for attr in (f'{{{r_ns}}}embed', f'{{{r_ns}}}link'): + old = el_ref.get(attr) + if old and old in new_rids: + el_ref.set(attr, new_rids[old]) + + def _add_raw_shape(self, slide, elem): + """Inject a verbatim shape XML (WordArt-class passthrough).""" + shape_xml = elem.get("_shapeXml") + if not shape_xml: + return + from lxml import etree + from pptx.oxml.ns import qn + sp_el = etree.fromstring(shape_xml) + self._reattach_xml_images(slide, sp_el, elem.get("_shapeImages")) + spTree = slide._element.find(qn('p:cSld')).find(qn('p:spTree')) + spTree.append(sp_el) + def _add_arch_group(self, slide, elem): """Add AWS architecture group with predefined styling.""" group_type = elem.get("groupType", "generic") diff --git a/skill/sdpm/builder/elements/image.py b/skill/sdpm/builder/elements/image.py index abe00e8e..0d94fb2c 100644 --- a/skill/sdpm/builder/elements/image.py +++ b/skill/sdpm/builder/elements/image.py @@ -118,7 +118,11 @@ def _add_image(self, slide, elem): svg_bytes = None if is_svg: svg_bytes = img_path.read_bytes() - if src and is_recolor_protected(src): + if icon_color == "none": + # Explicit opt-out: keep the SVG's own colors (used for + # artwork imported from existing decks, not theme icons). + pass + elif src and is_recolor_protected(src): if icon_color: print(f"Warning: iconColor ignored (recolor-protected asset): {src}", file=sys.stderr) else: diff --git a/skill/sdpm/builder/elements/textbox.py b/skill/sdpm/builder/elements/textbox.py index 4dfe59ed..bfde4eb0 100644 --- a/skill/sdpm/builder/elements/textbox.py +++ b/skill/sdpm/builder/elements/textbox.py @@ -302,6 +302,29 @@ def _add_textbox(self, slide, elem): grad_runs = elem.get("_textGradientRuns") if grad_runs: self._apply_text_gradient_runs(textbox, grad_runs) + + # Re-apply run-level text effects (glow/shadow) saved by the converter + text_effects = elem.get("_textEffects") + if text_effects: + try: + from lxml import etree as _et + from pptx.oxml.ns import qn as _qn + for r in textbox._element.findall('.//' + _qn('a:r')): + rPr = r.find(_qn('a:rPr')) + if rPr is None: + rPr = r.makeelement(_qn('a:rPr'), {}) + r.insert(0, rPr) + for old_eff in rPr.findall(_qn('a:effectLst')): + rPr.remove(old_eff) + eff = _et.fromstring(text_effects) + # schema order: effectLst goes before latin/ea/cs fonts + font_el = rPr.find(_qn('a:latin')) + if font_el is not None: + rPr.insert(list(rPr).index(font_el), eff) + else: + rPr.append(eff) + except Exception: + pass # Override cap=all and bold from lstStyle if elem.get("_capNone") or elem.get("_boldOff"): diff --git a/skill/sdpm/builder/formatting.py b/skill/sdpm/builder/formatting.py index 3937a14d..cb5ae133 100644 --- a/skill/sdpm/builder/formatting.py +++ b/skill/sdpm/builder/formatting.py @@ -241,6 +241,21 @@ def _apply_styled_text(self, paragraph, text, default_color=None, default_font_s run = paragraph.add_run() run.text = line run.font.name = seg.get("fontName") or (None if no_default_font else font_name) or None + # python-pptx only writes a:latin; CJK glyphs render with + # a:ea, so an explicit font tag must set both or Japanese + # text silently keeps the theme font. + if seg.get("fontName"): + from lxml import etree as _et_ea + from pptx.oxml.ns import qn as _qn_ea + rPr = run._r.get_or_add_rPr() + ea = rPr.find(_qn_ea('a:ea')) + if ea is None: + ea = _et_ea.SubElement(rPr, _qn_ea('a:ea')) + latin = rPr.find(_qn_ea('a:latin')) + if latin is not None: + rPr.remove(ea) + rPr.insert(list(rPr).index(latin) + 1, ea) + ea.set('typeface', seg["fontName"]) # Set sym font for symbol fonts (Wingdings etc) actual_font = run.font.name if actual_font and actual_font.startswith(('Wingdings', 'Symbol', 'Webdings')): diff --git a/skill/sdpm/converter/color.py b/skill/sdpm/converter/color.py index bcbb5fc8..551a8c4c 100644 --- a/skill/sdpm/converter/color.py +++ b/skill/sdpm/converter/color.py @@ -19,6 +19,16 @@ 9: 'accent5', 10: 'accent6', 13: 'tx1', 14: 'bg1' } +# Common OOXML preset color names (a:prstClr). Not exhaustive — covers the +# names seen in real decks; unknown names simply resolve to None. +_PRST_CLR = { + "white": "#FFFFFF", "black": "#000000", "red": "#FF0000", "green": "#008000", + "blue": "#0000FF", "yellow": "#FFFF00", "cyan": "#00FFFF", "magenta": "#FF00FF", + "gray": "#808080", "grey": "#808080", "ltGray": "#D3D3D3", "dkGray": "#404040", + "orange": "#FFA500", "purple": "#800080", "brown": "#A52A2A", "pink": "#FFC0CB", +} + + def extract_text_color(run, theme_colors=None, color_mapping=None, is_placeholder=False): """Extract text color from run, converting theme colors to RGB. Returns None for scheme colors that map to the default text role (tx1).""" @@ -53,6 +63,17 @@ def extract_text_color(run, theme_colors=None, color_mapping=None, is_placeholde if theme_colors and run.font.color.theme_color in theme_colors: return theme_colors[run.font.color.theme_color] return "#000000" + elif run.font.color and getattr(run.font.color, "type", None) is not None and int(run.font.color.type) == 102: + # PRESET color () — resolve via name table + from pptx.oxml.ns import qn + rPr = run._r.find(qn('a:rPr')) + if rPr is not None: + prst = rPr.find(f'{{{_NS["a"]}}}solidFill/{{{_NS["a"]}}}prstClr') + if prst is not None: + hex_val = _PRST_CLR.get(prst.get('val')) + if hex_val: + return apply_element_transforms(hex_val, prst) + return None elif run.font.color is None or run.font.color.type is None: if is_placeholder: return None @@ -203,6 +224,42 @@ def _resolve_color_with_transforms(scheme_el, theme_colors, color_mapping, overr resolved = f"#{int(r * 255):02X}{int(g * 255):02X}{int(b * 255):02X}" return resolved +def apply_element_transforms(base_hex, color_el): + """Apply lumMod/lumOff/tint/shade children of a color element to a hex. + + Works for srgbClr as well as schemeClr — PowerPoint emits e.g. + and dropping + the transform shifts the rendered color dramatically. + """ + lum_mod = color_el.find('a:lumMod', _NS) + lum_off = color_el.find('a:lumOff', _NS) + shade = color_el.find('a:shade', _NS) + tint = color_el.find('a:tint', _NS) + if lum_mod is None and lum_off is None and shade is None and tint is None: + return base_hex + hx = base_hex.lstrip('#') + r, g, b = int(hx[0:2], 16) / 255, int(hx[2:4], 16) / 255, int(hx[4:6], 16) / 255 + if shade is not None: + f = int(shade.get('val')) / 100000 + r, g, b = pow(r, 2.2) * f, pow(g, 2.2) * f, pow(b, 2.2) * f + r, g, b = pow(max(0, r), 1/2.2), pow(max(0, g), 1/2.2), pow(max(0, b), 1/2.2) + if tint is not None: + f = int(tint.get('val')) / 100000 + rl, gl, bl = pow(r, 2.2), pow(g, 2.2), pow(b, 2.2) + rl, gl, bl = rl + (1-rl)*(1-f), gl + (1-gl)*(1-f), bl + (1-bl)*(1-f) + r, g, b = pow(max(0, rl), 1/2.2), pow(max(0, gl), 1/2.2), pow(max(0, bl), 1/2.2) + if lum_mod is not None or lum_off is not None: + h, lum, sat = colorsys.rgb_to_hls(r, g, b) + if lum_mod is not None: + lum *= int(lum_mod.get('val')) / 100000 + if lum_off is not None: + lum += int(lum_off.get('val')) / 100000 + lum = min(1.0, max(0.0, lum)) + r, g, b = colorsys.hls_to_rgb(h, lum, sat) + r, g, b = min(1.0, max(0.0, r)), min(1.0, max(0.0, g)), min(1.0, max(0.0, b)) + return f"#{int(r * 255):02X}{int(g * 255):02X}{int(b * 255):02X}" + + def apply_color_transforms(base_color_hex, transforms): """Apply color transforms (lumMod, tint) to a base color.""" hex_color = base_color_hex.lstrip("#") diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index d432b79a..526c1935 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -573,7 +573,32 @@ def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_ elem["_textGradientRuns"] = grad_runs except Exception: pass - + + # Extract run-level text effects (glow/shadow on the characters). All + # runs sharing one effectLst is the common case (decorated headline); + # store the raw XML for lossless rebuild. + try: + from lxml import etree as _et_eff + effect_xmls = set() + has_run = False + for para in shape.text_frame.paragraphs: + for run in para.runs: + if not run.text.strip(): + continue + has_run = True + rpr = run._r.find(f'{{{_NS["a"]}}}rPr') + eff = rpr.find(f'{{{_NS["a"]}}}effectLst') if rpr is not None else None + if eff is not None and len(eff) > 0: + effect_xmls.add(_et_eff.tostring(eff, encoding='unicode')) + else: + effect_xmls.add("") + if has_run and len(effect_xmls) == 1: + xml = effect_xmls.pop() + if xml: + elem["_textEffects"] = xml + except Exception: + pass + # Detect cap=none and bold=off overrides (when lstStyle has cap=all / b=1) try: _all_runs = [r for p in shape.text_frame.paragraphs for r in p.runs] @@ -893,6 +918,9 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them filename = f"slide{slide_idx + 1}_image{img_idx + 1}.svg" (images_dir / filename).write_bytes(svg_bytes) elem["src"] = f"images/{filename}" + # Imported artwork keeps its own colors — opt out of the builder's + # theme-icon recolor (which repainted e.g. green wave shapes black). + elem["iconColor"] = "none" return elem # Save image to file @@ -996,13 +1024,116 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them return elem +def _extract_blipfill_image(shape, output_dir, slide_idx, img_counter): + """Picture-filled shape/textbox (spPr>blipFill) → image element. + + PowerPoint allows any shape to be filled with a picture. The builder has + no image-fill support, so a text-less picture-filled shape is best + reproduced as a plain image element with the same geometry. Returns the + element or None (has text / no blipFill / extraction failed). + """ + try: + if shape.has_text_frame and shape.text_frame.text.strip(): + return None + sp_pr = shape._element.spPr + blip_fill = sp_pr.find(f'{{{_NS["a"]}}}blipFill') if sp_pr is not None else None + if blip_fill is None: + return None + blip = blip_fill.find(f'{{{_NS["a"]}}}blip') + if blip is None: + return None + r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + rid = blip.get(f'{{{r_ns}}}embed') + is_svg = False + if rid is None: + svg_blip = blip.find( + './/{http://schemas.microsoft.com/office/drawing/2016/SVG/main}svgBlip') + if svg_blip is not None: + rid = svg_blip.get(f'{{{r_ns}}}embed') + is_svg = True + if rid is None or output_dir is None: + return None + part = shape.part.rels[rid].target_part + ext = 'svg' if is_svg else part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + filename = f"slide{slide_idx + 1}_image{img_counter + 1}.{ext}" + (images_dir / filename).write_bytes(part.blob) + elem = _base_element(shape, "image") + elem["src"] = f"images/{filename}" + elem["fit"] = "cover" + if ext == 'svg': + elem["iconColor"] = "none" + elem.pop("fill", None) + elem.pop("line", None) + return elem + except Exception: + return None + + +def _shape_needs_raw_passthrough(shape): + """WordArt-class decoration the JSON schema can't express. + + - prstTxWarp: warped text (arch / circle / wave WordArt) + - run-level blipFill: characters painted with a picture + """ + try: + x_el = shape._element + warp = x_el.find(f'.//{{{_NS["a"]}}}prstTxWarp') + if warp is not None and warp.get('prst') not in (None, 'textNoShape'): + return True + tx_body = x_el.find(f'.//{{{_NS["p"]}}}txBody') + if tx_body is not None: + for rpr in tx_body.iter(f'{{{_NS["a"]}}}rPr'): + if rpr.find(f'{{{_NS["a"]}}}blipFill') is not None: + return True + except Exception: + pass + return False + + +def _extract_raw_shape(shape, output_dir, slide_idx, img_counter): + """Save shape XML verbatim (plus referenced images) for lossless rebuild.""" + from lxml import etree as _et + elem = _base_element(shape, "rawShape") + elem["_shapeXml"] = _et.tostring(shape._element, encoding='unicode') + if output_dir: + r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + rid_map = {} + for el_ref in shape._element.iter(): + rid = el_ref.get(f'{{{r_ns}}}embed') or el_ref.get(f'{{{r_ns}}}link') + if not rid or rid in rid_map: + continue + try: + part = shape.part.rels[rid].target_part + ext = part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + fname = f"slide{slide_idx + 1}_raw{img_counter + 1}_{rid}.{ext}" + (images_dir / fname).write_bytes(part.blob) + rid_map[rid] = f"images/{fname}" + img_counter += 1 + except Exception: + continue + if rid_map: + elem["_shapeImages"] = rid_map + return elem, img_counter + + def _dispatch_shape(shape, theme_colors=None, color_mapping=None, theme_styles=None, output_dir=None, slide_idx=0, img_counter=0, builder_text_color=None, pptx_path=None): """Dispatch shape extraction by type. Returns (elem, img_counter).""" elem = None + if shape.shape_type in (MSO_SHAPE_TYPE.TEXT_BOX, MSO_SHAPE_TYPE.AUTO_SHAPE, + MSO_SHAPE_TYPE.FREEFORM) and _shape_needs_raw_passthrough(shape): + return _extract_raw_shape(shape, output_dir, slide_idx, img_counter) if shape.shape_type == MSO_SHAPE_TYPE.GROUP: elem, img_counter = extract_group_element(shape, theme_colors, color_mapping, theme_styles, output_dir, slide_idx, img_counter, builder_text_color=builder_text_color) elif shape.shape_type == MSO_SHAPE_TYPE.TEXT_BOX: - elem = extract_textbox_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) + elem = _extract_blipfill_image(shape, output_dir, slide_idx, img_counter) + if elem: + img_counter += 1 + else: + elem = extract_textbox_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) elif shape.shape_type == MSO_SHAPE_TYPE.PICTURE: elem = extract_picture_element(shape, output_dir, slide_idx, img_counter, theme_colors, color_mapping) if elem: @@ -1024,6 +1155,10 @@ def _dispatch_shape(shape, theme_colors=None, color_mapping=None, theme_styles=N if elem: img_counter += 1 elif shape.shape_type in (MSO_SHAPE_TYPE.AUTO_SHAPE, MSO_SHAPE_TYPE.FREEFORM): + elem = _extract_blipfill_image(shape, output_dir, slide_idx, img_counter) + if elem: + img_counter += 1 + return elem, img_counter if shape.shape_type == MSO_SHAPE_TYPE.FREEFORM: elem = extract_freeform_element(shape, theme_colors, color_mapping, builder_text_color=builder_text_color) if not elem: @@ -1057,6 +1192,30 @@ def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_st try: from lxml import etree as _et elem["_groupXml"] = _et.tostring(shape._element, encoding='unicode') + # Save any images referenced from inside the group XML and record + # a rId → deck-relative-path mapping. Without this the injected + # XML carries dangling r:embed ids and pictures/picture-fills + # inside the group vanish on rebuild. + if output_dir: + r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + rid_map = {} + for el_ref in shape._element.iter(): + rid = el_ref.get(f'{{{r_ns}}}embed') or el_ref.get(f'{{{r_ns}}}link') + if not rid or rid in rid_map: + continue + try: + part = shape.part.rels[rid].target_part + ext = part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + fname = f"slide{slide_idx + 1}_grp{img_counter + 1}_{rid}.{ext}" + (images_dir / fname).write_bytes(part.blob) + rid_map[rid] = f"images/{fname}" + img_counter += 1 + except Exception: + continue + if rid_map: + elem["_groupImages"] = rid_map except Exception: pass diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 183bec9c..f9f378a1 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -148,13 +148,30 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non if solid is not None: srgb = solid.find(f'{{{_NS["a"]}}}srgbClr') scheme = solid.find(f'{{{_NS["a"]}}}schemeClr') + resolved = None + color_el = None if srgb is not None: - slide_dict["background"] = f"#{srgb.get('val')}" + resolved = f"#{srgb.get('val')}" + color_el = srgb elif scheme is not None: from .color import _resolve_color_with_transforms resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) - if resolved: - slide_dict["background"] = resolved + color_el = scheme + if resolved: + # Semi-transparent background: approximate by blending + # toward white (the typical master background). + alpha_el = color_el.find(f'{{{_NS["a"]}}}alpha') if color_el is not None else None + if alpha_el is not None: + try: + a = int(alpha_el.get('val')) / 100000 + rr = int(resolved[1:3], 16) + gg = int(resolved[3:5], 16) + bb = int(resolved[5:7], 16) + blend = lambda c: round(c * a + 255 * (1 - a)) # noqa: E731 + resolved = f"#{blend(rr):02X}{blend(gg):02X}{blend(bb):02X}" + except Exception: + pass + slide_dict["background"] = resolved else: # Gradient / image backgrounds: the builder only supports a # solid slide background, so emit a full-slide element at @@ -181,9 +198,16 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non elif blip_fill is not None and output_dir: blip = blip_fill.find(f'{{{_NS["a"]}}}blip') rid = blip.get(f'{{{_NS["r"]}}}embed') if blip is not None else None + if rid is None and blip is not None: + # SVG-only background: the raster embed is absent and + # the reference lives on the svgBlip extension. + svg_blip = blip.find( + './/{http://schemas.microsoft.com/office/drawing/2016/SVG/main}svgBlip') + if svg_blip is not None: + rid = svg_blip.get(f'{{{_NS["r"]}}}embed') if rid: img_part = slide.part.related_part(rid) - ext = img_part.content_type.split('/')[-1].replace('jpeg', 'jpg') + ext = img_part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') img_name = f"slide{slide_idx+1}_bg.{ext}" img_dir = output_dir / "images" img_dir.mkdir(parents=True, exist_ok=True) @@ -193,6 +217,9 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non "src": f"images/{img_name}", "x": 0, "y": 0, "width": slide_w, "height": slide_h, "fit": "cover", + # keep artwork colors (SVG bg would otherwise be + # recolored to the theme text color) + "iconColor": "none", } except Exception: pass diff --git a/skill/sdpm/converter/table.py b/skill/sdpm/converter/table.py index 8ec28cce..287342d7 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -30,9 +30,11 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): srgb = solid.find('a:srgbClr', _NS) scheme = solid.find('a:schemeClr', _NS) if srgb is not None: - props["background"] = _hex(srgb) + from .color import apply_element_transforms + props["background"] = apply_element_transforms(_hex(srgb), srgb) elif scheme is not None: - resolved = _resolve_scheme_color(scheme.get('val'), theme_colors, color_mapping) + from .color import _resolve_color_with_transforms + resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) if resolved: props["background"] = resolved elif grad is not None: @@ -77,6 +79,10 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): va = _va_reverse.get(anchor) if va: props["vertical-align"] = va + else: + # OOXML default anchor is top, but the builder's documented + # default is middle — emit explicitly to keep source fidelity. + props["vertical-align"] = "top" # Padding (cell inset) padding = {} diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 7af35772..fe1d9d33 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -365,3 +365,131 @@ def test_show_master_sp_roundtrips(self, tmp_path): prs2 = Presentation(str(out)) assert list(prs2.slides)[-1]._element.get("showMasterSp") == "0", \ "showMasterSp=0 not rebuilt" + + +class TestSrgbColorTransforms: + def test_lummod_on_srgb_cell_fill(self): + """ must darken the + color — dropping the transform rendered strong blue instead of the + original muted tone.""" + from lxml import etree + from sdpm.converter.color import apply_element_transforms + + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + el = etree.fromstring( + f'') + out = apply_element_transforms("#4F81BD", el) + assert out != "#4F81BD" + # darker than the base + assert int(out[1:3], 16) < 0x4F + + +class TestPresetColor: + def test_prstclr_white_extracted(self, tmp_path): + from lxml import etree + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + from pptx.util import Emu + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(2000000), Emu(500000)) + tb.text_frame.text = "PresetRed" + run = tb.text_frame.paragraphs[0].runs[0] + rPr = run._r.get_or_add_rPr() + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + fill = etree.SubElement(rPr, f"{{{ns}}}solidFill") + etree.SubElement(fill, f"{{{ns}}}prstClr").set("val", "red") + rPr.insert(0, fill) + pptx_path = tmp_path / "prst.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + dumped = json.dumps(result["slides"], ensure_ascii=False) + assert "#FF0000" in dumped and "PresetRed" in dumped + + +class TestCellVerticalAlignDefault: + def test_missing_anchor_becomes_top(self, tmp_path): + """OOXML default cell anchor is top; the builder's default is middle, + so the converter must emit vertical-align explicitly.""" + from pptx.util import Inches + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + gf = slide.shapes.add_table(2, 1, Inches(1), Inches(1), Inches(4), Inches(2)) + gf.table.cell(0, 0).text = "a" + gf.table.cell(1, 0).text = "b" + # remove anchor attribute if python-pptx set one + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + for tc in gf.table._tbl.iter(f"{{{ns}}}tcPr"): + tc.attrib.pop("anchor", None) + pptx_path = tmp_path / "table_anchor.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + tables = [el for s in result["slides"] for el in s.get("elements", []) if el.get("type") == "table"] + cell = tables[0]["rows"][0][0] + assert isinstance(cell, dict) and cell.get("vertical-align") == "top" + + +class TestRawShapePassthrough: + def test_wordart_text_warp_roundtrips(self, tmp_path): + """prstTxWarp (WordArt arch/circle text) can't be expressed in the + JSON schema — the shape must roundtrip as rawShape XML.""" + from lxml import etree + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + from pptx.util import Emu + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(3000000), Emu(3000000)) + tb.text_frame.text = "ARCHED" + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + bodyPr = tb.text_frame._txBody.find(f"{{{ns}}}bodyPr") + warp = etree.SubElement(bodyPr, f"{{{ns}}}prstTxWarp") + warp.set("prst", "textArchUp") + pptx_path = tmp_path / "wordart.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + raws = [el for s in result["slides"] for el in s.get("elements", []) if el.get("type") == "rawShape"] + assert raws and "textArchUp" in raws[0].get("_shapeXml", ""), "WordArt not captured as rawShape" + + # builder injects it back + from sdpm.builder import PPTXBuilder + builder = PPTXBuilder(str(_template()), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, default_text_color="#FFFFFF") + builder.add_slide({"layout": builder_first_layout(builder), "elements": [raws[0]]}) + out = tmp_path / "rebuilt.pptx" + builder.save(str(out)) + import zipfile + z = zipfile.ZipFile(str(out)) + slides_xml = "".join(z.read(n).decode() for n in z.namelist() if n.startswith("ppt/slides/slide")) + assert "textArchUp" in slides_xml, "rawShape not injected on rebuild" + + +class TestGroupImageReattach: + def test_group_with_picture_keeps_image(self, tmp_path): + """Images inside raw-XML groups must be re-attached on rebuild (the + source rIds dangle in the new package otherwise).""" + from PIL import Image as PILImage + from pptx.util import Emu + + img = tmp_path / "photo.png" + PILImage.new("RGB", (60, 40), "magenta").save(img) + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[0]) + pic = slide.shapes.add_picture(str(img), Emu(0), Emu(0)) + tb = slide.shapes.add_textbox(Emu(0), Emu(500000), Emu(1000000), Emu(300000)) + tb.text_frame.text = "label" + # freeform member forces _groupXml passthrough; use a real group + grp = slide.shapes.add_group_shape([pic, tb]) + # inject a fake freeform marker: easier — rotate group to force rawXml path + grp.rotation = 15.0 + pptx_path = tmp_path / "grouped.pptx" + prs.save(str(pptx_path)) + + result = pptx_to_json(pptx_path, tmp_path / "out") + groups = [el for s in result["slides"] for el in s.get("elements", []) if el.get("type") == "group"] + assert groups and groups[0].get("_groupXml") + assert groups[0].get("_groupImages"), "group-referenced image not saved" + rel = list(groups[0]["_groupImages"].values())[0] + assert (tmp_path / "out" / rel).exists() From de9a6db708ae57141d59233222b709046a069d5d Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 08:57:20 +0900 Subject: [PATCH 06/22] refactor(converter): dedupe rId image saving, drop orphaned dead code - Remove unreachable orphaned copy of _extract_svg_blob body left after a past edit inside extract_textbox_element - Consolidate three copies of the 'resolve rId -> save image part' logic (_extract_raw_shape, extract_group_element _groupImages, _extract_blipfill_image) into shared helpers _image_ext / _save_image_part / _save_referenced_images - Output is byte-identical (verified on a 404-slide corpus deck covering rawShape, _groupImages and blipFill paths) --- skill/sdpm/converter/elements.py | 112 ++++++++++++++----------------- 1 file changed, 49 insertions(+), 63 deletions(-) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index 526c1935..c12c5dfb 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -825,20 +825,6 @@ def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_ elem["_spc"] = spc_values.pop() return elem - """Extract SVG bytes from asvg:svgBlip if present. Returns bytes or None.""" - ASVG_NS = 'http://schemas.microsoft.com/office/drawing/2016/SVG/main' - svg_blip = shape._element.find(f'.//{{{ASVG_NS}}}svgBlip') - if svg_blip is None: - return None - r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' - r_embed = svg_blip.get(f'{{{r_ns}}}embed') - if not r_embed: - return None - try: - part = shape.part.rels[r_embed].target_part - return part.blob - except (KeyError, Exception): - return None def extract_video_element(shape, output_dir=None, slide_idx=0, img_idx=0): """Extract video as element dict, saving video file and poster image.""" @@ -888,6 +874,45 @@ def extract_video_element(shape, output_dir=None, slide_idx=0, img_idx=0): return elem +def _image_ext(part): + """File extension for an image part, derived from its content type.""" + return part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') + + +def _save_image_part(part, output_dir, filename): + """Write an image part's blob under {output_dir}/images/. Returns deck-relative path.""" + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + (images_dir / filename).write_bytes(part.blob) + return f"images/{filename}" + + +def _save_referenced_images(shape, output_dir, slide_idx, img_counter, prefix): + """Save every image part referenced (r:embed / r:link) inside a shape's XML. + + Used when raw XML is re-injected on rebuild (rawShape _shapeXml / group + _groupXml): without the returned rId → deck-relative-path mapping the + injected XML carries dangling r:embed ids and its pictures vanish. + + Returns (rid_map, img_counter). + """ + rid_map = {} + if output_dir is None: + return rid_map, img_counter + for el_ref in shape._element.iter(): + rid = el_ref.get(f'{{{_NS["r"]}}}embed') or el_ref.get(f'{{{_NS["r"]}}}link') + if not rid or rid in rid_map: + continue + try: + part = shape.part.rels[rid].target_part + fname = f"slide{slide_idx + 1}_{prefix}{img_counter + 1}_{rid}.{_image_ext(part)}" + rid_map[rid] = _save_image_part(part, output_dir, fname) + img_counter += 1 + except Exception: + continue + return rid_map, img_counter + + def _extract_svg_blob(shape): """Extract SVG bytes from asvg:svgBlip if present. Returns bytes or None.""" ASVG_NS = 'http://schemas.microsoft.com/office/drawing/2016/SVG/main' @@ -1054,13 +1079,10 @@ def _extract_blipfill_image(shape, output_dir, slide_idx, img_counter): if rid is None or output_dir is None: return None part = shape.part.rels[rid].target_part - ext = 'svg' if is_svg else part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') - images_dir = Path(output_dir) / "images" - images_dir.mkdir(exist_ok=True) + ext = 'svg' if is_svg else _image_ext(part) filename = f"slide{slide_idx + 1}_image{img_counter + 1}.{ext}" - (images_dir / filename).write_bytes(part.blob) elem = _base_element(shape, "image") - elem["src"] = f"images/{filename}" + elem["src"] = _save_image_part(part, output_dir, filename) elem["fit"] = "cover" if ext == 'svg': elem["iconColor"] = "none" @@ -1097,26 +1119,9 @@ def _extract_raw_shape(shape, output_dir, slide_idx, img_counter): from lxml import etree as _et elem = _base_element(shape, "rawShape") elem["_shapeXml"] = _et.tostring(shape._element, encoding='unicode') - if output_dir: - r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' - rid_map = {} - for el_ref in shape._element.iter(): - rid = el_ref.get(f'{{{r_ns}}}embed') or el_ref.get(f'{{{r_ns}}}link') - if not rid or rid in rid_map: - continue - try: - part = shape.part.rels[rid].target_part - ext = part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') - images_dir = Path(output_dir) / "images" - images_dir.mkdir(exist_ok=True) - fname = f"slide{slide_idx + 1}_raw{img_counter + 1}_{rid}.{ext}" - (images_dir / fname).write_bytes(part.blob) - rid_map[rid] = f"images/{fname}" - img_counter += 1 - except Exception: - continue - if rid_map: - elem["_shapeImages"] = rid_map + rid_map, img_counter = _save_referenced_images(shape, output_dir, slide_idx, img_counter, "raw") + if rid_map: + elem["_shapeImages"] = rid_map return elem, img_counter @@ -1192,30 +1197,11 @@ def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_st try: from lxml import etree as _et elem["_groupXml"] = _et.tostring(shape._element, encoding='unicode') - # Save any images referenced from inside the group XML and record - # a rId → deck-relative-path mapping. Without this the injected - # XML carries dangling r:embed ids and pictures/picture-fills - # inside the group vanish on rebuild. - if output_dir: - r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' - rid_map = {} - for el_ref in shape._element.iter(): - rid = el_ref.get(f'{{{r_ns}}}embed') or el_ref.get(f'{{{r_ns}}}link') - if not rid or rid in rid_map: - continue - try: - part = shape.part.rels[rid].target_part - ext = part.content_type.split('/')[-1].replace('jpeg', 'jpg').replace('svg+xml', 'svg') - images_dir = Path(output_dir) / "images" - images_dir.mkdir(exist_ok=True) - fname = f"slide{slide_idx + 1}_grp{img_counter + 1}_{rid}.{ext}" - (images_dir / fname).write_bytes(part.blob) - rid_map[rid] = f"images/{fname}" - img_counter += 1 - except Exception: - continue - if rid_map: - elem["_groupImages"] = rid_map + # Save any images referenced from inside the group XML — see + # _save_referenced_images for why the rId mapping is required. + rid_map, img_counter = _save_referenced_images(shape, output_dir, slide_idx, img_counter, "grp") + if rid_map: + elem["_groupImages"] = rid_map except Exception: pass From 4cf2a68a81ed26acd3643098589d5471aa822e18 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 11:06:13 +0900 Subject: [PATCH 07/22] fix(converter): rotated connectors lost orientation on roundtrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connectors carrying xfrm rot= (90/180/270° — common in hand-drawn architecture diagrams) were extracted ignoring the rotation, so rebuilt arrows pointed the wrong way or bent on the wrong axis. - Bake the rotation into the x1/y1/x2/y2 endpoints (the schema has no rotation on lines) - Emit elbowStart="vertical" for 90/270° bent connectors so the builder reconstructs the V-H-V elbow instead of H-V-H Found on a real deck where 11 of 25 connectors on one architecture slide carried rot=. 4 new regression tests. --- skill/sdpm/converter/elements.py | 24 ++++++++++++- tests/test_converter_robustness.py | 57 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index c12c5dfb..394d95d6 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -78,7 +78,12 @@ def extract_line_element(shape, theme_colors=None, color_mapping=None, theme_sty h = round(shape.height / EMU_PER_PX) x1, y1, x2, y2 = x, y, x + w, y + h - # Absorb flip into coordinates + # Absorb flip and rotation into coordinates. + # OOXML renders a connector inside its bounding box (start at one + # corner, end at the opposite), flips it, then rotates the whole box + # about its center. The schema has no rotation on lines, so bake the + # rotation into the endpoints instead. + rot_deg = 0 try: xfrm = shape._element.spPr.find( './/{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm') @@ -87,8 +92,19 @@ def extract_line_element(shape, theme_colors=None, color_mapping=None, theme_sty x1, x2 = x2, x1 if xfrm.get('flipV') == '1': y1, y2 = y2, y1 + rot_deg = int(xfrm.get('rot', '0')) / 60000 except Exception: pass + if rot_deg: + import math + theta = math.radians(rot_deg) # clockwise in y-down coords + c, s = math.cos(theta), math.sin(theta) + cx0, cy0 = x + w / 2, y + h / 2 + def _rot(px_, py_): + dx, dy = px_ - cx0, py_ - cy0 + return round(cx0 + dx * c - dy * s), round(cy0 + dx * s + dy * c) + x1, y1 = _rot(x1, y1) + x2, y2 = _rot(x2, y2) elem = {"type": "line", "x1": x1, "y1": y1, "x2": x2, "y2": y2} @@ -107,6 +123,12 @@ def extract_line_element(shape, theme_colors=None, color_mapping=None, theme_sty elem["connectorType"] = "straight" elif 'bent' in prst.lower(): elem["connectorType"] = "elbow" + # A 90/270° rotated bent connector renders V-H-V + # (first segment vertical); the builder reconstructs + # elbows as H-V-H unless told otherwise. + r = rot_deg % 360 + if 45 <= r < 135 or 225 <= r < 315: + elem["elbowStart"] = "vertical" elif 'curved' in prst.lower(): elem["connectorType"] = "curved" diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index fe1d9d33..44f328fd 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -493,3 +493,60 @@ def test_group_with_picture_keeps_image(self, tmp_path): assert groups[0].get("_groupImages"), "group-referenced image not saved" rel = list(groups[0]["_groupImages"].values())[0] assert (tmp_path / "out" / rel).exists() + + +class TestRotatedConnectors: + """Connectors carrying xfrm rot= (90/180/270°) lost their orientation. + + The schema has no rotation on line elements, so the converter must bake + the rotation into the endpoints — and flag 90/270° bent connectors as + V-H-V via elbowStart so the builder reconstructs the elbow correctly. + """ + + @staticmethod + def _connector_slide(rot_deg, prst="bentConnector3", flip_v=False): + import tempfile + + from pptx.enum.shapes import MSO_CONNECTOR + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + conn = slide.shapes.add_connector( + MSO_CONNECTOR.ELBOW, Emu(1270000), Emu(635000), Emu(2540000), Emu(1905000)) + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + sp_pr = conn._element.spPr + sp_pr.find(f"{ns}prstGeom").set("prst", prst) + xfrm = sp_pr.find(f"{ns}xfrm") + if rot_deg: + xfrm.set("rot", str(rot_deg * 60000)) + if flip_v: + xfrm.set("flipV", "1") + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + lines = [e for s in result["slides"] for e in s["elements"] if e.get("type") == "line"] + assert lines, "connector was not extracted" + return lines[0] + + def test_180_rotation_swaps_endpoints(self): + base = self._connector_slide(rot_deg=0) + rot = self._connector_slide(rot_deg=180) + # 180° = point reflection through the box center: endpoints swap. + assert (rot["x1"], rot["y1"]) == (base["x2"], base["y2"]) + assert (rot["x2"], rot["y2"]) == (base["x1"], base["y1"]) + assert "elbowStart" not in rot # still starts horizontal + + def test_90_rotation_marks_vertical_elbow(self): + rot = self._connector_slide(rot_deg=90) + assert rot["elbowStart"] == "vertical" + + def test_270_rotation_marks_vertical_elbow(self): + rot = self._connector_slide(rot_deg=270, flip_v=True) + assert rot["elbowStart"] == "vertical" + + def test_unrotated_connector_unchanged(self): + base = self._connector_slide(rot_deg=0) + assert "elbowStart" not in base + assert (base["x1"], base["y1"]) == (200, 100) + assert (base["x2"], base["y2"]) == (400, 300) From 4d2de770f2df44399eab61647b33958d180371ec Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 11:16:49 +0900 Subject: [PATCH 08/22] fix(converter): textbox vertical anchor + sysClr color resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fidelity bugs found on a real architecture deck: - Textboxes with bodyPr anchor= (ctr/b) lost their vertical alignment: the shape path extracted verticalAlign but the textbox path did not, so center-anchored labels rendered top-aligned on rebuild. - (Windows system colors, e.g. windowText written by Office when picking 'Automatic' color) resolved to none — visible borders disappeared and fills went transparent. Resolve via lastClr with lumMod/lumOff/tint/shade transforms and alpha applied like srgbClr. 7 new regression tests. --- skill/sdpm/converter/elements.py | 7 +++ skill/sdpm/converter/xml_helpers.py | 34 +++++++++++ tests/test_converter_robustness.py | 93 +++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index 394d95d6..31c717de 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -554,6 +554,13 @@ def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_ if tf.margin_bottom is not None and tf.margin_bottom != 45720: elem["marginBottom"] = round(tf.margin_bottom / EMU_PER_PX) + # Extract vertical anchor (builder textbox default is top when unset) + if tf.vertical_anchor is not None: + _va_reverse = {1: "top", 3: "middle", 4: "bottom"} + va = _va_reverse.get(int(tf.vertical_anchor)) + if va: + elem["verticalAlign"] = va + # Extract fill and line using XML helpers try: sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') diff --git a/skill/sdpm/converter/xml_helpers.py b/skill/sdpm/converter/xml_helpers.py index dbcbf077..1fd151d0 100644 --- a/skill/sdpm/converter/xml_helpers.py +++ b/skill/sdpm/converter/xml_helpers.py @@ -12,6 +12,28 @@ from .color import _resolve_scheme_color, _resolve_color_with_transforms, apply_color_transforms +def _sys_hex(solid): + """Resolve (Windows system color) inside a solidFill. + + Office writes the concretely rendered color into lastClr, so use it + (falling back to black/white for the two common values) and apply any + child transforms (lumMod/lumOff/tint/shade) the same way as srgbClr. + """ + sys_clr = solid.find('a:sysClr', _NS) + if sys_clr is None: + return None + base = f"#{sys_clr.get('lastClr')}" if sys_clr.get('lastClr') else \ + {"windowText": "#000000", "window": "#FFFFFF"}.get(sys_clr.get('val')) + if not base: + return None + transforms = {} + for t in ('lumMod', 'lumOff', 'tint', 'shade'): + el = sys_clr.find(f'a:{t}', _NS) + if el is not None: + transforms[t] = el.get('val') + return _apply_srgb_transforms(base, transforms) if transforms else base + + def _apply_srgb_transforms(hex_color, transforms): """Apply lumMod/lumOff/tint/shade transforms to an srgbClr.""" h = hex_color.lstrip("#") @@ -245,6 +267,14 @@ def _extract_fill_from_xml(sp_pr, theme_colors=None, color_mapping=None): alpha = scheme.find('a:alpha', _NS) if alpha is not None: result["opacity"] = round(int(alpha.get('val')) / 100000, 2) + else: + sys_hex = _sys_hex(solid) + if sys_hex: + result["fill"] = sys_hex + sys_clr = solid.find('a:sysClr', _NS) + alpha = sys_clr.find('a:alpha', _NS) if sys_clr is not None else None + if alpha is not None: + result["opacity"] = round(int(alpha.get('val')) / 100000, 2) return result if "fill" in result else {"fill": "none"} # Gradient fill grad = sp_pr.find('a:gradFill', _NS) @@ -406,6 +436,10 @@ def _extract_line_from_xml(sp_pr, theme_colors=None, color_mapping=None): resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) if resolved: result["line"] = resolved + else: + sys_hex = _sys_hex(solid) + if sys_hex: + result["line"] = sys_hex if "line" not in result and "lineGradient" not in result: # Only set none if ln has noFill; otherwise leave unset for style resolution if ln.find('a:noFill', _NS) is not None: diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 44f328fd..1066ba8d 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -550,3 +550,96 @@ def test_unrotated_connector_unchanged(self): assert "elbowStart" not in base assert (base["x1"], base["y1"]) == (200, 100) assert (base["x2"], base["y2"]) == (400, 300) + + +class TestTextboxVerticalAnchor: + """Textboxes with bodyPr anchor= lost their vertical alignment. + + extract_shape_element extracted verticalAlign but the textbox path did + not, so center/bottom-anchored labels rendered top-aligned on rebuild + (builder textbox default is top). + """ + + @staticmethod + def _textbox_slide(anchor=None): + import tempfile + + from pptx.util import Emu as E + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + tb = slide.shapes.add_textbox(E(1270000), E(635000), E(2540000), E(635000)) + tb.text_frame.text = "label" + if anchor: + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + tb.text_frame._txBody.find(f"{ns}bodyPr").set("anchor", anchor) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + boxes = [e for s in result["slides"] for e in s["elements"] if e.get("type") == "textbox"] + assert boxes + return boxes[0] + + def test_center_anchor_extracted(self): + assert self._textbox_slide("ctr")["verticalAlign"] == "middle" + + def test_bottom_anchor_extracted(self): + assert self._textbox_slide("b")["verticalAlign"] == "bottom" + + def test_no_anchor_stays_unset(self): + assert "verticalAlign" not in self._textbox_slide(None) + + +class TestSysClrResolution: + """ fills/lines resolved to 'none', dropping visible borders.""" + + @staticmethod + def _shape_slide(fill_xml=None, line_xml=None): + import tempfile + + from lxml import etree + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(1270000), E(635000), E(2540000), E(1270000)) + ns = "http://schemas.openxmlformats.org/drawingml/2006/main" + sp_pr = sp._element.spPr + # Drop the default style-based fill python-pptx leaves in place + for tag in ("solidFill", "ln"): + for el in sp_pr.findall(f"{{{ns}}}{tag}"): + sp_pr.remove(el) + style = sp._element.find("{http://schemas.openxmlformats.org/presentationml/2006/main}style") + if style is not None: + sp._element.remove(style) + geom = sp_pr.find(f"{{{ns}}}prstGeom") + if fill_xml: + geom.addnext(etree.fromstring(fill_xml)) + if line_xml: + sp_pr.append(etree.fromstring(line_xml)) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + shapes = [e for s in result["slides"] for e in s["elements"] if e.get("type") == "shape"] + assert shapes + return shapes[0] + + A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"' + + def test_sysclr_line_uses_lastclr(self): + el = self._shape_slide(line_xml=( + f'' + '')) + assert el["line"] == "#000000" + + def test_sysclr_fill_applies_lum_transforms_and_alpha(self): + el = self._shape_slide(fill_xml=( + f'' + '' + '')) + # 20% lum + 80% offset of black = #CCCCCC light gray + assert el["fill"] == "#CCCCCC" + assert el["opacity"] == 0.3 From 2a048c0c6e2faa244f54fb0f13863ee5b8a086da Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 11:25:38 +0900 Subject: [PATCH 09/22] fix(converter): sysClr gradient stops were dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradient stop parsing only recognized srgbClr/schemeClr, so stops using sysClr (Office 'Automatic' white) vanished — a green→white 4-stop chevron collapsed to a single green stop and rendered solid. Resolve via the shared _sys_hex helper (lastClr + transforms + alpha) in both fill and line gradient loops. 1 new regression test. --- skill/sdpm/converter/xml_helpers.py | 15 +++++++++++++++ tests/test_converter_robustness.py | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/skill/sdpm/converter/xml_helpers.py b/skill/sdpm/converter/xml_helpers.py index 1fd151d0..11bb8b07 100644 --- a/skill/sdpm/converter/xml_helpers.py +++ b/skill/sdpm/converter/xml_helpers.py @@ -306,6 +306,14 @@ def _extract_fill_from_xml(sp_pr, theme_colors=None, color_mapping=None): alpha = scheme.find('a:alpha', _NS) if alpha is not None: stop_info["opacity"] = round(int(alpha.get('val')) / 100000, 2) + else: + sys_hex = _sys_hex(gs) + if sys_hex: + stop_info["color"] = sys_hex + sys_clr = gs.find('.//a:sysClr', _NS) + alpha = sys_clr.find('a:alpha', _NS) if sys_clr is not None else None + if alpha is not None: + stop_info["opacity"] = round(int(alpha.get('val')) / 100000, 2) if "color" in stop_info: stops.append(stop_info) if stops: @@ -396,6 +404,13 @@ def _extract_line_from_xml(sp_pr, theme_colors=None, color_mapping=None): alpha = scheme.find('a:alpha', _NS) if alpha is not None: opacity = round(int(alpha.get('val')) / 100000, 2) + else: + color = _sys_hex(gs) + if color: + sys_clr = gs.find('.//a:sysClr', _NS) + alpha = sys_clr.find('a:alpha', _NS) if sys_clr is not None else None + if alpha is not None: + opacity = round(int(alpha.get('val')) / 100000, 2) if color: stop_info = {"position": pos, "color": color} if opacity is not None: diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 1066ba8d..88bd17b3 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -643,3 +643,16 @@ def test_sysclr_fill_applies_lum_transforms_and_alpha(self): # 20% lum + 80% offset of black = #CCCCCC light gray assert el["fill"] == "#CCCCCC" assert el["opacity"] == 0.3 + + def test_sysclr_gradient_stops_survive(self): + """sysClr gradient stops were dropped — a 4-stop green→white + gradient collapsed to a single green stop (rendered solid).""" + el = self._shape_slide(fill_xml=( + f'' + '' + '' + '' + '')) + stops = el["gradient"]["stops"] + assert [(s["position"], s["color"]) for s in stops] == [ + (0.0, "#00B050"), (0.37, "#FFFFFF"), (1.0, "#FFFFFF")] From f3876683939f0bdb3ba7abac7b8c5c87ed3ee69d Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 11:33:10 +0900 Subject: [PATCH 10/22] fix(converter): invert tint semantics + satMod support in srgb transforms ECMA-376 tint keeps N% of the color (val=100000 = unchanged); the old formula mixed toward white by N% instead, so Office gradient fills with tint/shade/satMod stops (e.g. the classic light-red -> red preset) washed out to white/pale. Also apply satMod (HLS saturation modulation), which was collected from the XML but silently ignored. 3 new regression tests. --- skill/sdpm/converter/xml_helpers.py | 12 ++++++++++-- tests/test_converter_robustness.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/converter/xml_helpers.py b/skill/sdpm/converter/xml_helpers.py index 11bb8b07..afab9411 100644 --- a/skill/sdpm/converter/xml_helpers.py +++ b/skill/sdpm/converter/xml_helpers.py @@ -35,7 +35,7 @@ def _sys_hex(solid): def _apply_srgb_transforms(hex_color, transforms): - """Apply lumMod/lumOff/tint/shade transforms to an srgbClr.""" + """Apply lumMod/lumOff/tint/shade/satMod transforms to an srgbClr.""" h = hex_color.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) if 'lumMod' in transforms or 'lumOff' in transforms: @@ -45,14 +45,22 @@ def _apply_srgb_transforms(hex_color, transforms): g = min(255, max(0, int(g * mod + 255 * off))) b = min(255, max(0, int(b * mod + 255 * off))) if 'tint' in transforms: + # ECMA-376: an N% tint is N% of the input color mixed with (100-N)% + # white — val=100000 leaves the color unchanged (in linear gamma). t = int(transforms['tint']) / 100000 rl, gl, bl = pow(r/255, 2.2), pow(g/255, 2.2), pow(b/255, 2.2) - rl, gl, bl = rl + (1-rl)*t, gl + (1-gl)*t, bl + (1-bl)*t + rl, gl, bl = rl*t + (1-t), gl*t + (1-t), bl*t + (1-t) r, g, b = int(pow(max(0,rl), 1/2.2)*255), int(pow(max(0,gl), 1/2.2)*255), int(pow(max(0,bl), 1/2.2)*255) if 'shade' in transforms: s = int(transforms['shade']) / 100000 rl, gl, bl = pow(r/255, 2.2)*s, pow(g/255, 2.2)*s, pow(b/255, 2.2)*s r, g, b = int(pow(max(0,rl), 1/2.2)*255), int(pow(max(0,gl), 1/2.2)*255), int(pow(max(0,bl), 1/2.2)*255) + if 'satMod' in transforms: + import colorsys + m = int(transforms['satMod']) / 100000 + hh, ll, ss = colorsys.rgb_to_hls(r/255, g/255, b/255) + rr, gg, bb = colorsys.hls_to_rgb(hh, ll, min(1.0, ss * m)) + r, g, b = int(rr*255), int(gg*255), int(bb*255) return f"#{min(255,max(0,r)):02X}{min(255,max(0,g)):02X}{min(255,max(0,b)):02X}" def extract_line_dash(shape): diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 88bd17b3..388ee388 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -656,3 +656,29 @@ def test_sysclr_gradient_stops_survive(self): stops = el["gradient"]["stops"] assert [(s["position"], s["color"]) for s in stops] == [ (0.0, "#00B050"), (0.37, "#FFFFFF"), (1.0, "#FFFFFF")] + + +class TestSrgbTintSemantics: + """ECMA-376 tint: val=100000 must leave the color unchanged. + + The old formula was inverted (100% tint → white), washing out + gradient fills that Office writes with tint/shade/satMod stops. + """ + + def test_full_tint_is_identity(self): + from sdpm.converter.xml_helpers import _apply_srgb_transforms + assert _apply_srgb_transforms("#B22600", {"tint": "100000"}) == "#B22600" + + def test_partial_tint_mixes_toward_white(self): + from sdpm.converter.xml_helpers import _apply_srgb_transforms + out = _apply_srgb_transforms("#B22600", {"tint": "50000"}) + r, g, b = int(out[1:3], 16), int(out[3:5], 16), int(out[5:7], 16) + assert r > 0xB2 and g > 0x26 and b > 0x00 # lighter than input + assert out != "#FFFFFF" + + def test_satmod_boosts_saturation(self): + from sdpm.converter.xml_helpers import _apply_srgb_transforms + out = _apply_srgb_transforms("#996666", {"satMod": "300000"}) + r, g, b = int(out[1:3], 16), int(out[3:5], 16), int(out[5:7], 16) + assert r > g and r > b # pushed toward pure red + assert (r - g) > (0x99 - 0x66) # more separation than input From d5a58b116aa16ee72c5da3144281d80e0f680a95 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 12:58:05 +0900 Subject: [PATCH 11/22] fix(converter): justify align, verbatim import text, sized empty paragraphs Three fidelity bugs found on a real deck's header/label text: - align="justify" fell back to center in all three shape-builder text paths (the textbox path already supported it) - CJK<->Latin auto-spacing (a typography aid for AI-authored decks) mutated imported text; converted decks now write autoSpacing:false in deck.json and the builder threads the flag down to parse_styled_text - empty paragraphs with an explicit endParaRPr size (spacers that shift center-anchored text) lost their size in the joined-text form; the converter now switches to paragraphs mode with endFontSize 3 new regression tests. --- skill/sdpm/api.py | 3 ++ skill/sdpm/builder/__init__.py | 3 +- skill/sdpm/builder/elements/shape.py | 14 ++----- skill/sdpm/builder/formatting.py | 2 +- skill/sdpm/converter/pipeline.py | 2 + skill/sdpm/converter/text.py | 41 +++++++++++++++++--- skill/sdpm/utils/text.py | 26 +++++++++---- tests/test_converter_robustness.py | 57 ++++++++++++++++++++++++++++ 8 files changed, 123 insertions(+), 25 deletions(-) diff --git a/skill/sdpm/api.py b/skill/sdpm/api.py index e21b7f8a..51687a64 100644 --- a/skill/sdpm/api.py +++ b/skill/sdpm/api.py @@ -333,6 +333,7 @@ class BuildConfig: base_dir: Path = field(default_factory=lambda: Path(".")) warnings: list[str] = field(default_factory=list) lint_diagnostics: list = field(default_factory=list) + auto_spacing: bool = True # deck.json "autoSpacing"; False for imported decks def _assemble_slides_from_dir( @@ -494,6 +495,7 @@ def _resolve_config( base_dir=base_dir, warnings=warnings, lint_diagnostics=lint_diagnostics, + auto_spacing=data.get("autoSpacing", True), ) @@ -507,6 +509,7 @@ def _build(config: BuildConfig, output_path: Path) -> Path: fonts=config.fonts, base_dir=config.base_dir, default_text_color=config.default_text_color, + auto_spacing=config.auto_spacing, ) for s in config.slides: builder.add_slide(s) diff --git a/skill/sdpm/builder/__init__.py b/skill/sdpm/builder/__init__.py index bdfcf7aa..9ad9e719 100644 --- a/skill/sdpm/builder/__init__.py +++ b/skill/sdpm/builder/__init__.py @@ -72,7 +72,7 @@ class PPTXBuilder( def __init__(self, template_path: Path, custom_template: bool = False, fonts: dict = None, base_dir: Path = None, keep_empty_placeholders: bool = False, - default_text_color: str = None): + default_text_color: str = None, auto_spacing: bool = True): """Initialize PPTXBuilder. Args: @@ -106,6 +106,7 @@ def __init__(self, template_path: Path, custom_template: bool = False, self.EMU_PER_PX = int(self.prs.slide_width) / 1920 self.custom_template = custom_template self.fonts = fonts + self.auto_spacing = auto_spacing # CJK↔Latin spacing; False = keep source text verbatim self.keep_empty_placeholders = keep_empty_placeholders self.layouts = self._build_layout_map() self.invalid_layouts: list[dict] = [] # {"slug", "attempted", "used", "available"} diff --git a/skill/sdpm/builder/elements/shape.py b/skill/sdpm/builder/elements/shape.py index da7292cd..11189776 100644 --- a/skill/sdpm/builder/elements/shape.py +++ b/skill/sdpm/builder/elements/shape.py @@ -280,17 +280,11 @@ def _add_shape(self, slide, elem): # Apply text alignment align = elem.get("align", "center") - if align == "center": - p.alignment = 2 - elif align == "right": - p.alignment = 3 - elif align == "left": - p.alignment = 1 - else: - p.alignment = 2 # Default center + p.alignment = {"center": 2, "right": 3, "left": 1, + "justify": 4, "just": 4}.get(align, 2) elif paragraphs: # Paragraphs array with per-paragraph styles - align_map = {"left": 1, "center": 2, "right": 3} + align_map = {"left": 1, "center": 2, "right": 3, "justify": 4, "just": 4} for i, para_def in enumerate(paragraphs): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() if isinstance(para_def, str): @@ -337,7 +331,7 @@ def _add_shape(self, slide, elem): # Apply text alignment align = elem.get("align", "center") - align_val = {"center": 2, "right": 3, "left": 1}.get(align, 2) + align_val = {"center": 2, "right": 3, "left": 1, "justify": 4, "just": 4}.get(align, 2) for p in tf.paragraphs: p.alignment = align_val diff --git a/skill/sdpm/builder/formatting.py b/skill/sdpm/builder/formatting.py index cb5ae133..48bb28b1 100644 --- a/skill/sdpm/builder/formatting.py +++ b/skill/sdpm/builder/formatting.py @@ -215,7 +215,7 @@ def _apply_styled_text(self, paragraph, text, default_color=None, default_font_s Args: font_family: If specified, use this font for halfwidth chars (e.g., 'Lucida Console' for code) """ - segments = parse_styled_text(text) + segments = parse_styled_text(text, auto_spacing=getattr(self, 'auto_spacing', True)) paragraph.clear() from lxml import etree from pptx.oxml.ns import qn diff --git a/skill/sdpm/converter/pipeline.py b/skill/sdpm/converter/pipeline.py index c1b5cfdb..a9cddf53 100644 --- a/skill/sdpm/converter/pipeline.py +++ b/skill/sdpm/converter/pipeline.py @@ -105,6 +105,8 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo deck_meta["fonts"] = result["fonts"] if "defaultTextColor" in result: deck_meta["defaultTextColor"] = result["defaultTextColor"] + # Imported text must roundtrip verbatim — disable CJK↔Latin auto-spacing + deck_meta["autoSpacing"] = False write_json(output_dir / "deck.json", deck_meta) # Write slides/slide-{NN}.json (1-based, zero-padded, hyphen separator to match parse_outline_slugs) diff --git a/skill/sdpm/converter/text.py b/skill/sdpm/converter/text.py index 9b61a69a..601407ae 100644 --- a/skill/sdpm/converter/text.py +++ b/skill/sdpm/converter/text.py @@ -213,12 +213,41 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t paras.append(p) elem["paragraphs"] = paras else: - parts = [] - for i, para in enumerate(all_paragraphs): - if i > 0: - parts.append('\n') - parts.append(_extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para)) - elem["text"] = ''.join(parts) + # Empty paragraphs with an explicit endParaRPr size act as sized + # spacers (they shift anchored text); the joined-text form cannot + # carry per-line sizes, so switch to paragraphs mode when present. + ns_a = _NS["a"] + + def _end_sz(para): + endPr = para._element.find(f'{{{ns_a}}}endParaRPr') + if endPr is not None and endPr.get('sz'): + return int(endPr.get('sz')) / 100 + return None + + sized_empties = [para for para in all_paragraphs + if not para.text.strip() and _end_sz(para) + and _end_sz(para) != default_font_size] + if sized_empties: + paras = [] + for para in all_paragraphs: + p = {"text": _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para)} + a = _get_alignment(para) + if a: + p["align"] = a + if para.runs and para.runs[0].font.size: + p["fontSize"] = int(para.runs[0].font.size.pt) + esz = _end_sz(para) + if esz: + p["endFontSize"] = esz + paras.append(p) + elem["paragraphs"] = paras + else: + parts = [] + for i, para in enumerate(all_paragraphs): + if i > 0: + parts.append('\n') + parts.append(_extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para)) + elem["text"] = ''.join(parts) # Extract indent/marL from first paragraph for single-text shapes if paragraphs_with_text: pPr = paragraphs_with_text[0]._element.find(f'{{{_NS["a"]}}}pPr') diff --git a/skill/sdpm/utils/text.py b/skill/sdpm/utils/text.py index 82568001..840b4a40 100644 --- a/skill/sdpm/utils/text.py +++ b/skill/sdpm/utils/text.py @@ -66,7 +66,7 @@ def is_closing_quote(pos, ch): return ''.join(result) -def parse_styled_text(text): +def parse_styled_text(text, auto_spacing=True): """Parse {{attrs:text}} syntax into styled segments. Supported attrs (comma-separated): @@ -75,6 +75,11 @@ def parse_styled_text(text): - #RRGGBB (color) - NNpt (font size) - link:URL (hyperlink) + + Args: + auto_spacing: Insert half-width spaces between CJK and Latin text + (typography for AI-authored decks). Pass False to preserve the + source text verbatim (e.g. decks imported from existing PPTX). """ link_pattern = r'\{\{(?:(\d+pt),)?link:([^}]+)\}\}' segments = [] @@ -83,7 +88,7 @@ def parse_styled_text(text): for match in re.finditer(link_pattern, text): if match.start() > last_end: plain_part = text[last_end:match.start()] - segments.extend(_parse_non_link_styles(plain_part)) + segments.extend(_parse_non_link_styles(plain_part, auto_spacing=auto_spacing)) size_attr = match.group(1) # e.g. "14pt" or None content = match.group(2) @@ -111,12 +116,12 @@ def parse_styled_text(text): last_end = match.end() if last_end < len(text): - segments.extend(_parse_non_link_styles(text[last_end:])) + segments.extend(_parse_non_link_styles(text[last_end:], auto_spacing=auto_spacing)) return segments if segments else [{"text": text}] -def _parse_non_link_styles(text): +def _parse_non_link_styles(text, auto_spacing=True): """Parse non-link styled text.""" pattern = r'\{\{([^:}]+):((?:\\}|[^}])+)\}\}' segments = [] @@ -124,7 +129,9 @@ def _parse_non_link_styles(text): for match in re.finditer(pattern, text): if match.start() > last_end: - plain_text = normalize_spacing(text[last_end:match.start()]) + plain_text = text[last_end:match.start()] + if auto_spacing: + plain_text = normalize_spacing(plain_text) segments.append({"text": plain_text}) raw_attrs = match.group(1) @@ -137,7 +144,7 @@ def _parse_non_link_styles(text): inner_text = inner_text[8:] # skip "#RRGGBB:" attrs = raw_attrs.split(',') has_font = any(a.startswith('font=') for a in attrs) - inner_text = inner_text if has_font else normalize_spacing(inner_text) + inner_text = inner_text if (has_font or not auto_spacing) else normalize_spacing(inner_text) segment = {"text": inner_text} for attr in attrs: @@ -162,9 +169,14 @@ def _parse_non_link_styles(text): last_end = match.end() if last_end < len(text): - plain_text = normalize_spacing(text[last_end:]) + plain_text = text[last_end:] + if auto_spacing: + plain_text = normalize_spacing(plain_text) segments.append({"text": plain_text}) + if not auto_spacing: + return segments + for i in range(len(segments) - 1): cur_text = segments[i]["text"] nxt_text = segments[i + 1]["text"] diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 388ee388..8fefc274 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -682,3 +682,60 @@ def test_satmod_boosts_saturation(self): r, g, b = int(out[1:3], 16), int(out[3:5], 16), int(out[5:7], 16) assert r > g and r > b # pushed toward pure red assert (r - g) > (0x99 - 0x66) # more separation than input + + +class TestImportTextFidelity: + """Header/label text drifted on rebuild (found on a real deck). + + - align="justify" fell back to center in the shape builder + - CJK↔Latin auto-spacing mutated imported text (autoSpacing deck flag) + - empty paragraphs lost their endParaRPr size, shifting anchored text + """ + + def test_parse_styled_text_auto_spacing_off(self): + from sdpm.utils.text import parse_styled_text + text = "{{font=Meiryo UI:自動化基盤は}}{{bold,font=Meiryo UI:CLAP/TMT/DNA}}{{font=Meiryo UI:により構成される}}" + on = parse_styled_text(text) + off = parse_styled_text(text, auto_spacing=False) + assert on[0]["text"].endswith(" ") # spacing applied by default + assert off[0]["text"] == "自動化基盤は" # verbatim when disabled + assert off[1]["text"] == "CLAP/TMT/DNA" + + def test_converted_deck_disables_auto_spacing(self): + import tempfile + prs = Presentation(str(_template())) + prs.slides.add_slide(prs.slide_layouts[-1]) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + pptx_to_json(p, Path(td) / "out") + deck = json.loads((Path(td) / "out" / "deck.json").read_text()) + assert deck["autoSpacing"] is False + + def test_sized_empty_paragraphs_roundtrip(self): + import tempfile + + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E, Pt + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(1270000), E(635000), E(2540000), E(1905000)) + tf = sp.text_frame + tf.paragraphs[0].text = "Title" + tf.paragraphs[0].runs[0].font.size = Pt(12) + for _ in range(2): + para = tf.add_paragraph() # empty spacer, larger than the text + from lxml import etree + from pptx.oxml.ns import qn + end = etree.SubElement(para._p, qn('a:endParaRPr')) + end.set('sz', '1600') + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + shapes = [e for s in result["slides"] for e in s["elements"] + if e.get("type") == "shape" and "paragraphs" in e] + assert shapes, "sized empty paragraphs should force paragraphs mode" + paras = shapes[0]["paragraphs"] + assert [pa.get("endFontSize") for pa in paras[1:]] == [16.0, 16.0] From 46471f7aa64b78c07a9469b3935a19b49eee7758 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 13:35:56 +0900 Subject: [PATCH 12/22] fix(converter): no-wrap labels re-wrapped + inherited font size lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two label-fidelity bugs found on a real deck: - autoWidth (bodyPr wrap=none) was skipped when the textbox also carried _noAutofit — wrap and autofit are orthogonal, so no-wrap labels re-wrapped mid-word on rebuild - runs with no explicit sz inherit the presentation defaultTextStyle size (OOXML spec fallback: 18pt), but the rebuilt shape got the builder default 14pt; the converter now emits the inherited size explicitly when any run lacks an explicit size 2 new regression tests. --- skill/sdpm/builder/elements/textbox.py | 7 ++- skill/sdpm/converter/text.py | 27 ++++++++++++ tests/test_converter_robustness.py | 59 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/builder/elements/textbox.py b/skill/sdpm/builder/elements/textbox.py index bfde4eb0..21f90174 100644 --- a/skill/sdpm/builder/elements/textbox.py +++ b/skill/sdpm/builder/elements/textbox.py @@ -68,8 +68,11 @@ def _add_textbox(self, slide, elem): tag = child.tag.split('}')[1] if tag in ('spAutoFit', 'noAutofit', 'normAutofit'): bodyPr.remove(child) - if not elem.get("_noAutofit"): - tf.word_wrap = not auto_width + if auto_width: + # wrap=none is orthogonal to autofit — honor it even with _noAutofit + tf.word_wrap = False + elif not elem.get("_noAutofit"): + tf.word_wrap = True # Apply rotation rotation = elem.get("rotation", _DEFAULTS["rotation"]) diff --git a/skill/sdpm/converter/text.py b/skill/sdpm/converter/text.py index 601407ae..bfc3bc21 100644 --- a/skill/sdpm/converter/text.py +++ b/skill/sdpm/converter/text.py @@ -106,6 +106,28 @@ def _detect_font_size(paragraphs): return most_common +def _inherited_default_size(shape): + """Effective size for runs with no explicit sz anywhere in the shape. + + Non-placeholder shape text inherits from presentation.xml + defaultTextStyle; absent that, the OOXML spec default for defRPr sz + is 1800 (18pt). The builder's shape default is 14pt, so the inherited + size must be made explicit or rebuilt text shrinks. + """ + try: + pres = shape.part.package.presentation_part._element + dts = pres.find(f'{{{_NS["p"]}}}defaultTextStyle') + if dts is not None: + l1 = dts.find(f'{{{_NS["a"]}}}lvl1pPr') + d = l1.find(f'{{{_NS["a"]}}}defRPr') if l1 is not None else None + if d is not None and d.get('sz'): + sz = int(d.get('sz')) / 100 + return int(sz) if sz == int(sz) else sz + except Exception: + pass + return 18 + + _ALIGN_MAP = {1: "left", 2: "center", 3: "right", 4: "justify"} def _get_alignment(paragraph): @@ -174,6 +196,11 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t # Skip default_font_size if shape has lstStyle (sizes handled by lstStyle) has_lstStyle = _serialize_lstStyle(shape) is not None default_font_size = None if has_lstStyle else _detect_font_size(paragraphs_with_text) + if (default_font_size is None and not has_lstStyle and paragraphs_with_text + and any(r.font.size is None for para in paragraphs_with_text for r in para.runs)): + # Runs without explicit sz inherit the presentation default (spec + # fallback 18pt) — make it explicit or the builder default applies. + default_font_size = _inherited_default_size(shape) if _has_bullets(paragraphs_with_text): # Check if all text paragraphs have bullets — if mixed, use text mode diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 8fefc274..09fb14cf 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -739,3 +739,62 @@ def test_sized_empty_paragraphs_roundtrip(self): assert shapes, "sized empty paragraphs should force paragraphs mode" paras = shapes[0]["paragraphs"] assert [pa.get("endFontSize") for pa in paras[1:]] == [16.0, 16.0] + + +class TestTextSizingAndWrap: + """Two more label-fidelity bugs from a real deck. + + - autoWidth (wrap=none) was ignored when the textbox also carried + _noAutofit, so no-wrap labels re-wrapped mid-word on rebuild + - runs with no explicit sz inherit the presentation default (spec + fallback 18pt), but rebuilt text got the builder default (14pt) + """ + + def test_autowidth_survives_noautofit(self): + import tempfile + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + from pptx.util import Emu as E + tb = slide.shapes.add_textbox(E(1270000), E(635000), E(1905000), E(444500)) + tb.text_frame.text = "シナリオ開発プロセス" + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + body = tb.text_frame._txBody.find(f"{ns}bodyPr") + body.set("wrap", "none") + from lxml import etree + etree.SubElement(body, f"{ns}noAutofit") + with tempfile.TemporaryDirectory() as td: + src = Path(td) / "t.pptx" + prs.save(src) + result = pptx_to_json(src, Path(td) / "out") + el = next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "textbox") + assert el.get("autoWidth") is True + el["_noAutofit"] = True # coexists with autoWidth on real decks + # Rebuild and confirm wrap=none survives + from sdpm.builder import PPTXBuilder + b = PPTXBuilder(_template(), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, + default_text_color="#000000") + b.add_slide({"layout": "Blank", "elements": [el]}) + out = Path(td) / "rebuilt.pptx" + b.save(out) + reprs = Presentation(str(out)) + tb2 = next(sh for sh in reprs.slides[0].shapes if sh.has_text_frame and "シナリオ" in sh.text_frame.text) + body2 = tb2.text_frame._txBody.find(f"{ns}bodyPr") + assert body2.get("wrap") == "none" + + def test_unsized_runs_get_inherited_default(self): + import tempfile + + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(1270000), E(635000), E(5080000), E(952500)) + sp.text_frame.text = "運用者がワークフローエディタを用いて作成" + # no explicit run size — inherits presentation default (18pt fallback) + with tempfile.TemporaryDirectory() as td: + src = Path(td) / "t.pptx" + prs.save(src) + result = pptx_to_json(src, Path(td) / "out") + el = next(e for s in result["slides"] for e in s["elements"] + if e.get("type") == "shape" and "運用者" in str(e.get("text", ""))) + assert el.get("fontSize") == 18 From c634324b5791977454b12d93dc9a8b04fe694545 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 13:46:00 +0900 Subject: [PATCH 13/22] fix(converter): table cell lstStyle color, sysClr borders, cell bullets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three table-cell fidelity bugs found on a real deck's spec table: - Cells can carry their own txBody lstStyle whose defRPr sets the text color (white header labels); inheriting runs fell back to the slide tx1 default and rebuilt black. Resolve the cell-level color and emit it as the cell 'color' prop. - sysClr border colors (white gridlines) were dropped — only srgbClr/schemeClr were recognized. Reuse _sys_hex. - buChar bullets inside cells vanished; the builder has no list support in table cells, so keep the bullet character as literal text. 3 new regression tests. --- skill/sdpm/converter/table.py | 51 +++++++++++++++++- tests/test_converter_robustness.py | 83 ++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/converter/table.py b/skill/sdpm/converter/table.py index 287342d7..1840cb95 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -10,16 +10,60 @@ from .text import _extract_styled_text +def _cell_lststyle_color(tc, theme_colors, color_mapping): + """Default text color defined by the cell's own txBody lstStyle. + + Table cells can carry a per-cell lstStyle whose lvl1 defRPr solidFill + sets the text color (e.g. white header cells). Runs without an explicit + color inherit it — not the slide-level tx1 default. + """ + try: + lst = tc.find('a:txBody/a:lstStyle', _NS) + if lst is None: + return None + d = lst.find('a:lvl1pPr/a:defRPr', _NS) + if d is None: + return None + solid = d.find('a:solidFill', _NS) + if solid is None: + return None + srgb = solid.find('a:srgbClr', _NS) + if srgb is not None: + return _hex(srgb) + scheme = solid.find('a:schemeClr', _NS) + if scheme is not None: + from .color import _resolve_color_with_transforms + return _resolve_color_with_transforms(scheme, theme_colors, color_mapping) + from .xml_helpers import _sys_hex + return _sys_hex(solid) + except Exception: + return None + + def _extract_cell(cell, theme_colors=None, color_mapping=None): """Extract cell as string (text only) or dict (has extra properties).""" tc = cell._tc tc_pr = tc.find('a:tcPr', _NS) tf = cell.text_frame + # Cell-level default text color (from the cell's own lstStyle): + # suppress the tx1 fallback for inheriting runs and emit it as the + # cell's "color" prop instead. + cell_color = _cell_lststyle_color(tc, theme_colors, color_mapping) styled_parts = [] for para in tf.paragraphs: - styled_parts.append(_extract_styled_text(para.runs, theme_colors, color_mapping, paragraph=para)) + part = _extract_styled_text(para.runs, theme_colors, color_mapping, + is_placeholder=bool(cell_color), paragraph=para) + # Bullets: the builder has no list support inside table cells, so + # keep the rendered bullet character as literal text. + pPr = para._element.find('a:pPr', _NS) + bu = pPr.find('a:buChar', _NS) if pPr is not None else None + if bu is not None and para.text.strip(): + part = f"{bu.get('char', '•')}{part}" + styled_parts.append(part) text = "\n".join(styled_parts) props = {} + if cell_color: + props["color"] = cell_color if tc_pr is not None: # Fill → background @@ -67,6 +111,11 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): resolved = _resolve_scheme_color(scheme.get('val'), theme_colors, color_mapping) if resolved: border["color"] = resolved + else: + from .xml_helpers import _sys_hex + sys_hex = _sys_hex(sf) + if sys_hex: + border["color"] = sys_hex if border: borders[side] = border if borders: diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 09fb14cf..638c1efc 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -798,3 +798,86 @@ def test_unsized_runs_get_inherited_default(self): el = next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "shape" and "運用者" in str(e.get("text", ""))) assert el.get("fontSize") == 18 + + +class TestTableCellFidelity: + """Table-cell fidelity bugs from a real deck's spec table. + + - per-cell lstStyle text color (white header text) fell back to tx1 black + - sysClr cell borders lost their color (white gridlines vanished) + - buChar bullets inside cells were dropped (builder has no cell lists) + """ + + @staticmethod + def _convert(mutate): + import tempfile + + from pptx.util import Emu as E + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + gfx = slide.shapes.add_table(2, 2, E(1270000), E(635000), E(5080000), E(1905000)) + mutate(gfx.table) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + for s in result["slides"]: + for e in s["elements"]: + if e.get("type") == "table": + return e + raise AssertionError("no table extracted") + + NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + + def test_cell_lststyle_color_extracted(self): + from lxml import etree + + def mutate(tbl): + cell = tbl.rows[0].cells[0] + cell.text = "概要" + txBody = cell._tc.find(f"{self.NS}txBody") + lst = txBody.find(f"{self.NS}lstStyle") + lvl = etree.SubElement(lst, f"{self.NS}lvl1pPr") + d = etree.SubElement(lvl, f"{self.NS}defRPr") + sf = etree.SubElement(d, f"{self.NS}solidFill") + etree.SubElement(sf, f"{self.NS}srgbClr").set("val", "FFEE00") + t = self._convert(mutate) + cell = t["headers"][0] if t.get("headers") else t["rows"][0][0] + assert isinstance(cell, dict) and cell.get("color") == "#FFEE00" + assert "#000000" not in str(cell.get("text")) + + def test_sysclr_border_color_extracted(self): + from lxml import etree + + def mutate(tbl): + cell = tbl.rows[0].cells[0] + cell.text = "x" + tcPr = cell._tc.get_or_add_tcPr() + ln = etree.SubElement(tcPr, f"{self.NS}lnB") + ln.set("w", "12700") + sf = etree.SubElement(ln, f"{self.NS}solidFill") + sys_el = etree.SubElement(sf, f"{self.NS}sysClr") + sys_el.set("val", "window") + sys_el.set("lastClr", "FFFFFF") + t = self._convert(mutate) + cell = t["headers"][0] if t.get("headers") else t["rows"][0][0] + assert cell["borders"]["bottom"]["color"] == "#FFFFFF" + + def test_cell_bullets_kept_as_text(self): + from lxml import etree + + def mutate(tbl): + cell = tbl.rows[0].cells[0] + tf = cell.text_frame + tf.text = "各システムに対するドメイン知識" + p2 = tf.add_paragraph() + p2.text = "CLAPの仕様理解" + for para in tf.paragraphs: + pPr = para._element.get_or_add_pPr() + bu = etree.SubElement(pPr, f"{self.NS}buChar") + bu.set("char", "•") + t = self._convert(mutate) + cell = t["headers"][0] if t.get("headers") else t["rows"][0][0] + text = cell["text"] if isinstance(cell, dict) else cell + assert text.count("•") == 2 From 6dfe2b90b1ca5f80ee5105d4a11bee8f2e023fad Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 14:41:53 +0900 Subject: [PATCH 14/22] feat(builder): bullet list support inside table cells Table cell dicts now accept a paragraphs array: {"paragraphs": [{"text": "...", "bullet": "\u2022"}, {"text": "..."}]} Each item becomes its own a:p; bullet items get a real buChar with a hanging indent, non-bullet items get buNone. Cell-level style props (color, font-size, weight, align) apply across all paragraphs. The converter now emits this form for cells with buChar bullets instead of flattening the bullet character into literal text. Regression test upgraded to a full convert->build roundtrip. --- skill/sdpm/builder/elements/table.py | 39 ++++++++++++++++++++++++---- skill/sdpm/converter/table.py | 12 ++++++--- tests/test_converter_robustness.py | 24 ++++++++++++++--- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/skill/sdpm/builder/elements/table.py b/skill/sdpm/builder/elements/table.py index e49653ed..aad1c2e9 100644 --- a/skill/sdpm/builder/elements/table.py +++ b/skill/sdpm/builder/elements/table.py @@ -244,17 +244,46 @@ def _add_table(self, slide, elem): text_color = RGBColor.from_string(color_val.lstrip('#')) align = resolved.get("text-align") - if align: - para.alignment = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(align) - - self._apply_styled_text(para, str(text), default_color=text_color, no_default_color=text_color is None) + align_val = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}.get(align) if align else None + if align_val is not None: + para.alignment = align_val + + cell_paras = cell_val.get("paragraphs") if isinstance(cell_val, dict) else None + if cell_paras: + # Paragraph list with optional bullets: one a:p per item. + tf = cell.text_frame + a_ns = nsmap["a"] + for pi, pd in enumerate(cell_paras): + if isinstance(pd, str): + pd = {"text": pd} + p = tf.paragraphs[0] if pi == 0 else tf.add_paragraph() + if align_val is not None: + p.alignment = align_val + self._apply_styled_text(p, str(pd.get("text", "")), default_color=text_color, no_default_color=text_color is None) + pPr = p._element.get_or_add_pPr() + bu = pd.get("bullet") + if bu: + char = bu if isinstance(bu, str) else "•" + bu_font = etree.SubElement(pPr, f'{{{a_ns}}}buFont') + bu_font.set('typeface', 'Arial') + bu_char = etree.SubElement(pPr, f'{{{a_ns}}}buChar') + bu_char.set('char', char) + # Hanging indent so wrapped lines align after the bullet + pPr.set('marL', '171450') + pPr.set('indent', '-171450') + else: + etree.SubElement(pPr, f'{{{a_ns}}}buNone') + cell_runs = [r for p in cell.text_frame.paragraphs for r in p.runs] + else: + self._apply_styled_text(para, str(text), default_color=text_color, no_default_color=text_color is None) + cell_runs = para.runs # Font properties on all runs font_weight = resolved.get("font-weight") font_style = resolved.get("font-style") text_decoration = resolved.get("text-decoration") font_size = resolved.get("font-size") - for run in para.runs: + for run in cell_runs: if font_weight == "bold": run.font.bold = True if font_style == "italic": diff --git a/skill/sdpm/converter/table.py b/skill/sdpm/converter/table.py index 1840cb95..aae3fc03 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -50,18 +50,22 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): # cell's "color" prop instead. cell_color = _cell_lststyle_color(tc, theme_colors, color_mapping) styled_parts = [] + bullet_chars = [] for para in tf.paragraphs: part = _extract_styled_text(para.runs, theme_colors, color_mapping, is_placeholder=bool(cell_color), paragraph=para) - # Bullets: the builder has no list support inside table cells, so - # keep the rendered bullet character as literal text. pPr = para._element.find('a:pPr', _NS) bu = pPr.find('a:buChar', _NS) if pPr is not None else None - if bu is not None and para.text.strip(): - part = f"{bu.get('char', '•')}{part}" + bullet_chars.append(bu.get('char', '•') if bu is not None and para.text.strip() else None) styled_parts.append(part) text = "\n".join(styled_parts) props = {} + if any(bullet_chars): + # Bulleted lines → paragraphs form (builder renders real buChar bullets) + props["paragraphs"] = [ + {"text": part, "bullet": bc} if bc else {"text": part} + for part, bc in zip(styled_parts, bullet_chars) + ] if cell_color: props["color"] = cell_color diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 638c1efc..a4410d85 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -864,7 +864,9 @@ def mutate(tbl): cell = t["headers"][0] if t.get("headers") else t["rows"][0][0] assert cell["borders"]["bottom"]["color"] == "#FFFFFF" - def test_cell_bullets_kept_as_text(self): + def test_cell_bullets_roundtrip(self): + import tempfile + from lxml import etree def mutate(tbl): @@ -879,5 +881,21 @@ def mutate(tbl): bu.set("char", "•") t = self._convert(mutate) cell = t["headers"][0] if t.get("headers") else t["rows"][0][0] - text = cell["text"] if isinstance(cell, dict) else cell - assert text.count("•") == 2 + assert isinstance(cell, dict) + paras = cell["paragraphs"] + assert [p.get("bullet") for p in paras] == ["•", "•"] + # Rebuild: builder must emit real buChar bullets on each a:p + from sdpm.builder import PPTXBuilder + b = PPTXBuilder(_template(), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, + default_text_color="#000000", auto_spacing=False) + b.add_slide({"layout": "Blank", "elements": [t]}) + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "rebuilt.pptx" + b.save(out) + prs = Presentation(str(out)) + gfx = next(sh for sh in prs.slides[0].shapes if sh.has_table) + tc = gfx.table.rows[0].cells[0]._tc + bu_chars = tc.findall(f".//{self.NS}buChar") + assert [b_.get("char") for b_ in bu_chars] == ["•", "•"] + texts = [p.text for p in gfx.table.rows[0].cells[0].text_frame.paragraphs] + assert texts == ["各システムに対するドメイン知識", "CLAPの仕様理解"] From 1362c133ffcf3e7a9d62abe16cf3394ca6b95ee7 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 14:55:42 +0900 Subject: [PATCH 15/22] fix(converter): preserve exact bullet indent in table cell paragraphs Cell paragraphs now carry the source marL/indent (EMU) and the builder applies them verbatim, falling back to the generic hanging indent only when unspecified. The hardcoded 171450 EMU was ~2x the real deck's 93663, visibly widening the bullet indent. --- skill/sdpm/builder/elements/table.py | 11 ++++++++--- skill/sdpm/converter/table.py | 15 +++++++++++---- tests/test_converter_robustness.py | 5 +++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/skill/sdpm/builder/elements/table.py b/skill/sdpm/builder/elements/table.py index aad1c2e9..99762d83 100644 --- a/skill/sdpm/builder/elements/table.py +++ b/skill/sdpm/builder/elements/table.py @@ -268,11 +268,16 @@ def _add_table(self, slide, elem): bu_font.set('typeface', 'Arial') bu_char = etree.SubElement(pPr, f'{{{a_ns}}}buChar') bu_char.set('char', char) - # Hanging indent so wrapped lines align after the bullet - pPr.set('marL', '171450') - pPr.set('indent', '-171450') + # Hanging indent so wrapped lines align after the + # bullet — explicit marL/indent (EMU) win. + pPr.set('marL', str(pd.get('marL', 171450))) + pPr.set('indent', str(pd.get('indent', -171450))) else: etree.SubElement(pPr, f'{{{a_ns}}}buNone') + if pd.get('marL') is not None: + pPr.set('marL', str(pd['marL'])) + if pd.get('indent') is not None: + pPr.set('indent', str(pd['indent'])) cell_runs = [r for p in cell.text_frame.paragraphs for r in p.runs] else: self._apply_styled_text(para, str(text), default_color=text_color, no_default_color=text_color is None) diff --git a/skill/sdpm/converter/table.py b/skill/sdpm/converter/table.py index aae3fc03..9285497d 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -62,10 +62,17 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): props = {} if any(bullet_chars): # Bulleted lines → paragraphs form (builder renders real buChar bullets) - props["paragraphs"] = [ - {"text": part, "bullet": bc} if bc else {"text": part} - for part, bc in zip(styled_parts, bullet_chars) - ] + paras = [] + for para, part, bc in zip(tf.paragraphs, styled_parts, bullet_chars): + pd = {"text": part, "bullet": bc} if bc else {"text": part} + pPr = para._element.find('a:pPr', _NS) + if pPr is not None: + if pPr.get('marL') is not None: + pd["marL"] = int(pPr.get('marL')) + if pPr.get('indent') is not None: + pd["indent"] = int(pPr.get('indent')) + paras.append(pd) + props["paragraphs"] = paras if cell_color: props["color"] = cell_color diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index a4410d85..62aef246 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -877,6 +877,8 @@ def mutate(tbl): p2.text = "CLAPの仕様理解" for para in tf.paragraphs: pPr = para._element.get_or_add_pPr() + pPr.set("marL", "93663") + pPr.set("indent", "-93663") bu = etree.SubElement(pPr, f"{self.NS}buChar") bu.set("char", "•") t = self._convert(mutate) @@ -884,6 +886,7 @@ def mutate(tbl): assert isinstance(cell, dict) paras = cell["paragraphs"] assert [p.get("bullet") for p in paras] == ["•", "•"] + assert [p.get("marL") for p in paras] == [93663, 93663] # Rebuild: builder must emit real buChar bullets on each a:p from sdpm.builder import PPTXBuilder b = PPTXBuilder(_template(), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, @@ -897,5 +900,7 @@ def mutate(tbl): tc = gfx.table.rows[0].cells[0]._tc bu_chars = tc.findall(f".//{self.NS}buChar") assert [b_.get("char") for b_ in bu_chars] == ["•", "•"] + pprs = tc.findall(f".//{self.NS}pPr") + assert [p_.get("marL") for p_ in pprs] == ["93663", "93663"] texts = [p.text for p in gfx.table.rows[0].cells[0].text_frame.paragraphs] assert texts == ["各システムに対するドメイン知識", "CLAPの仕様理解"] From e8b50cda7fd81045e140d40d86bbe62859cefad4 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 15:41:04 +0900 Subject: [PATCH 16/22] fix(converter): rebuilt shapes gained theme shadows the source never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-pptx's add_shape writes a default whose effectRef references the theme effect style — a drop shadow in both built-in templates and many real decks. Imported shapes with no effects (empty effectLst, no effectRef) rendered as floating shadowed cards, visibly breaking staircase charts built from tiled white rectangles. Converter now emits _noEffects for shapes whose source defines no effects; the builder drops the default style and writes an empty effectLst (LibreOffice ignores empty effectLst vs effectRef, so the style must go). AI-authored decks are unaffected. 2 new regression tests. --- skill/sdpm/builder/elements/shape.py | 11 ++++++ skill/sdpm/builder/formatting.py | 10 +++++ skill/sdpm/converter/elements.py | 12 ++++++ tests/test_converter_robustness.py | 58 ++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/skill/sdpm/builder/elements/shape.py b/skill/sdpm/builder/elements/shape.py index 11189776..7851cd4c 100644 --- a/skill/sdpm/builder/elements/shape.py +++ b/skill/sdpm/builder/elements/shape.py @@ -120,6 +120,17 @@ def _add_shape(self, slide, elem): sp_pr = shape._element.find(qn('p:spPr')) for pg in sp_pr.findall(qn('a:prstGeom')): pg.set('prst', raw_prst) + + # Source had no effects: drop python-pptx's default (its + # effectRef pulls the theme shadow; explicit fill/line are applied + # below anyway) and write an empty effectLst for good measure. + if elem.get("_noEffects"): + from lxml import etree as _et_fx + from pptx.oxml.ns import qn as _qn_fx + for _st in shape._element.findall(_qn_fx('p:style')): + shape._element.remove(_st) + if shape._element.spPr.find(_qn_fx('a:effectLst')) is None: + _et_fx.SubElement(shape._element.spPr, _qn_fx('a:effectLst')) # Apply rotation rotation = elem.get("rotation", _DEFAULTS["rotation"]) diff --git a/skill/sdpm/builder/formatting.py b/skill/sdpm/builder/formatting.py index 48bb28b1..f92274c6 100644 --- a/skill/sdpm/builder/formatting.py +++ b/skill/sdpm/builder/formatting.py @@ -72,6 +72,16 @@ def _apply_shape_formatting(self, shape, elem): """Apply fill, gradient, and line formatting to a shape.""" gradient = elem.get("gradient") fill_color = elem.get("fill") + + # Source had no effects: write an empty effectLst so the theme + # shadow referenced by python-pptx's default effectRef + # doesn't apply. + if elem.get("_noEffects"): + from lxml import etree as _et_fx + from pptx.oxml.ns import qn as _qn_fx + sp_pr = shape._element.spPr + if sp_pr.find(_qn_fx('a:effectLst')) is None: + _et_fx.SubElement(sp_pr, _qn_fx('a:effectLst')) if gradient: self._apply_gradient_fill(shape, gradient) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index 31c717de..5ca58b77 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -485,6 +485,18 @@ def extract_shape_element(shape, theme_colors=None, color_mapping=None, theme_st # Extract visual effects elem.update(_extract_visual_effects(sp_pr_xml, theme_colors, color_mapping)) + + # No effects in source → say so explicitly. The builder's add_shape + # carries python-pptx's default whose effectRef pulls the + # theme shadow; an empty effectLst is needed to suppress it. + if not any(k in elem for k in ("shadow", "glow", "softEdge", "reflection")): + try: + style_el = shape._element.find(f'{{{_NS["p"]}}}style') + eff_ref = style_el.find(f'{{{_NS["a"]}}}effectRef') if style_el is not None else None + if eff_ref is None or int(eff_ref.get('idx', '0') or 0) == 0: + elem["_noEffects"] = True + except Exception: + pass # Preserve lstStyle for roundtrip fidelity (non-placeholder shapes) _lst = _serialize_lstStyle(shape) if shape.has_text_frame else None diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 62aef246..d575fa55 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -904,3 +904,61 @@ def mutate(tbl): assert [p_.get("marL") for p_ in pprs] == ["93663", "93663"] texts = [p.text for p in gfx.table.rows[0].cells[0].text_frame.paragraphs] assert texts == ["各システムに対するドメイン知識", "CLAPの仕様理解"] + + +class TestNoEffectsSuppression: + """Rebuilt shapes gained a theme shadow the source never had. + + python-pptx's add_shape writes a default whose effectRef + references the theme effect style (a shadow in both built-in + templates). Shapes converted from decks whose spPr carries no + effects now emit _noEffects, and the builder drops the style and + writes an empty effectLst. + """ + + def test_plain_shape_marks_no_effects(self): + import tempfile + + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(1270000), E(635000), E(2540000), E(635000)) + # python-pptx's own style stays (effectRef idx=2 in the template + # theme), but spPr has no effectLst → still "no effects" per source? + # No: effectRef>0 means themed effects apply. Clear the style to + # simulate the common hand-drawn shape (no style, no effects). + style = sp._element.find("{http://schemas.openxmlformats.org/presentationml/2006/main}style") + if style is not None: + sp._element.remove(style) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + el = next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "shape") + assert el.get("_noEffects") is True + + def test_builder_suppresses_theme_shadow(self): + import tempfile + + from sdpm.builder import PPTXBuilder + b = PPTXBuilder(_template(), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, + default_text_color="#000000") + b.add_slide({"layout": "Blank", "elements": [ + {"type": "shape", "shape": "rectangle", "x": 100, "y": 100, + "width": 300, "height": 100, "fill": "#F2F2F2", "_noEffects": True}, + {"type": "shape", "shape": "rectangle", "x": 100, "y": 300, + "width": 300, "height": 100, "fill": "#F2F2F2"}, + ]}) + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "t.pptx" + b.save(out) + prs = Presentation(str(out)) + ns_p = "{http://schemas.openxmlformats.org/presentationml/2006/main}" + ns_a = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + shapes = [sh for sh in prs.slides[0].shapes if sh.shape_type is not None] + marked, unmarked = shapes[0], shapes[1] + assert marked._element.find(f"{ns_p}style") is None + assert marked._element.spPr.find(f"{ns_a}effectLst") is not None + # default behavior unchanged for AI-authored decks + assert unmarked._element.find(f"{ns_p}style") is not None From 9a2c48ee15b8d0606a7d2277e7f18a73a392b9ef Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 16:30:18 +0900 Subject: [PATCH 17/22] fix(builder): OLE objects in injected raw XML corrupted the package Raw group XML (_groupXml) can contain embedded OLE graphicFrames whose p:oleObj r:id references an oleObject part that does not exist in the rebuilt package. PowerPoint then shows the repair dialog and strips the slide's content (LibreOffice tolerates it, which hid the bug from render-based verification). _reattach_xml_images now sanitizes injected XML first: each OLE graphicFrame is replaced by its mc:Fallback picture, positioned from the frame's p:xfrm; the pic's r:embed is remapped like any other saved image. Verified: rebuilt deck has zero oleObj remnants and no dangling r:id/r:embed/r:link across all slides. 1 new regression test. --- skill/sdpm/builder/elements/group.py | 41 +++++++++++++++++++++++++++ tests/test_converter_robustness.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/skill/sdpm/builder/elements/group.py b/skill/sdpm/builder/elements/group.py index 11b3e7f8..805054aa 100644 --- a/skill/sdpm/builder/elements/group.py +++ b/skill/sdpm/builder/elements/group.py @@ -41,8 +41,49 @@ def _add_group(self, slide, elem): elif sub_type == "chart": self._add_chart(slide, sub_elem) + def _sanitize_injected_xml(self, xml_el): + """Replace OLE graphicFrames in injected raw XML with their fallback picture. + + Embedded OLE objects carry an r:id pointing at an oleObject part that + does not exist in the rebuilt package — PowerPoint then reports the + presentation as damaged and strips the slide. The mc:Fallback branch + holds a plain picture of the object, so swap the whole graphicFrame + for that pic (its r:embed is remapped by _reattach_xml_images). + """ + ns_p = 'http://schemas.openxmlformats.org/presentationml/2006/main' + ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main' + for gf in list(xml_el.iter(f'{{{ns_p}}}graphicFrame')): + if gf.find(f'.//{{{ns_p}}}oleObj') is None: + continue + parent = gf.getparent() + if parent is None: + continue + pic = gf.find(f'.//{{{ns_p}}}pic') + if pic is None: + parent.remove(gf) + continue + # Position: the graphicFrame's p:xfrm is authoritative + frame_xfrm = gf.find(f'{{{ns_p}}}xfrm') + sp_pr = pic.find(f'{{{ns_p}}}spPr') + if frame_xfrm is not None and sp_pr is not None: + from lxml import etree + a_xfrm = sp_pr.find(f'{{{ns_a}}}xfrm') + if a_xfrm is None: + a_xfrm = etree.Element(f'{{{ns_a}}}xfrm') + sp_pr.insert(0, a_xfrm) + for tag in ('off', 'ext'): + src = frame_xfrm.find(f'{{{ns_a}}}{tag}') + dst = a_xfrm.find(f'{{{ns_a}}}{tag}') + if src is not None: + if dst is None: + dst = etree.SubElement(a_xfrm, f'{{{ns_a}}}{tag}') + for k, v in src.attrib.items(): + dst.set(k, v) + parent.replace(gf, pic) + def _reattach_xml_images(self, slide, xml_el, images_map): """Rewrite r:embed/r:link ids in injected XML to freshly added parts.""" + self._sanitize_injected_xml(xml_el) if not images_map: return r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index d575fa55..fe95113c 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -962,3 +962,45 @@ def test_builder_suppresses_theme_shadow(self): assert marked._element.spPr.find(f"{ns_a}effectLst") is not None # default behavior unchanged for AI-authored decks assert unmarked._element.find(f"{ns_p}style") is not None + + +class TestOleSanitization: + """Injected raw group XML carried embedded OLE objects whose r:id + pointed at parts that don't exist in the rebuilt package — PowerPoint + reported the file as damaged and stripped the slide. OLE frames are + swapped for their mc:Fallback picture.""" + + def test_ole_graphicframe_replaced_by_fallback_pic(self): + from lxml import etree + + from sdpm.builder import PPTXBuilder + ns_p = "http://schemas.openxmlformats.org/presentationml/2006/main" + ns_a = "http://schemas.openxmlformats.org/drawingml/2006/main" + ns_r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + xml = f''' + + + + + + + + + + + + + + + ''' + el = etree.fromstring(xml) + b = PPTXBuilder(_template(), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, + default_text_color="#000000") + b._sanitize_injected_xml(el) + assert el.find(f".//{{{ns_p}}}oleObj") is None + assert el.find(f".//{{{ns_p}}}graphicFrame") is None + pic = el.find(f".//{{{ns_p}}}pic") + assert pic is not None + off = pic.find(f".//{{{ns_a}}}xfrm/{{{ns_a}}}off") + assert off is not None and off.get("x") == "100" From 4ed25de8a1517e1ccdbcc8f4392310c242bcbf58 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 16:49:30 +0900 Subject: [PATCH 18/22] fix(converter): lines in scaled groups + srgb line color transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs behind a misdrawn Excel-pasted roadmap chart: - The group child transform only rewrote x/y/width/height, but line elements carry x1/y1/x2/y2 endpoints — connectors inside scaled groups (this deck: scale_y 1.59) kept child-space coordinates and drew far from their true position. Endpoints are now mapped through the group xfrm (top-level and nested groups). - Line solidFill srgbClr ignored lumMod/lumOff transforms (fill side already applied them): light-gray dashed gridlines came back near black. Reuse apply_element_transforms. 1 new regression test. --- skill/sdpm/converter/elements.py | 24 +++++++++++++++++ skill/sdpm/converter/xml_helpers.py | 3 ++- tests/test_converter_robustness.py | 40 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index 5ca58b77..fb81aa5a 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -1326,6 +1326,22 @@ def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_st abs_x = group_off_x + (child_x - ch_off_x) * scale_x abs_y = group_off_y + (child_y - ch_off_y) * scale_y + if sub_elem.get("type") == "line" and "x1" in sub_elem: + # Lines carry endpoints, not x/y/width/height — + # transform x1/y1/x2/y2 through the group xfrm. + def _gx(px_): + return round((group_off_x + (px_ * EMU_PER_PX - ch_off_x) * scale_x) / EMU_PER_PX) + def _gy(py_): + return round((group_off_y + (py_ * EMU_PER_PX - ch_off_y) * scale_y) / EMU_PER_PX) + for k in ("x1", "x2"): + if sub_elem.get(k) is not None: + sub_elem[k] = _gx(sub_elem[k]) + for k in ("y1", "y2"): + if sub_elem.get(k) is not None: + sub_elem[k] = _gy(sub_elem[k]) + elem["elements"].append(sub_elem) + continue + sub_elem["x"] = round(abs_x / EMU_PER_PX) sub_elem["y"] = round(abs_y / EMU_PER_PX) sub_elem["width"] = round(sub_shape.width * scale_x / EMU_PER_PX) @@ -1343,6 +1359,14 @@ def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_st if sub_elem.get("type") == "group" and sub_elem.get("elements"): def _apply_group_transform(elements, gox, goy, gcx, gcy, sx, sy): for el in elements: + if el.get("type") == "line" and "x1" in el: + for k in ("x1", "x2"): + if el.get(k) is not None: + el[k] = round((gox + (el[k] * EMU_PER_PX - gcx) * sx) / EMU_PER_PX) + for k in ("y1", "y2"): + if el.get(k) is not None: + el[k] = round((goy + (el[k] * EMU_PER_PX - gcy) * sy) / EMU_PER_PX) + continue if "x" in el and "y" in el: old_x = el["x"] * EMU_PER_PX old_y = el["y"] * EMU_PER_PX diff --git a/skill/sdpm/converter/xml_helpers.py b/skill/sdpm/converter/xml_helpers.py index afab9411..ef2c10d5 100644 --- a/skill/sdpm/converter/xml_helpers.py +++ b/skill/sdpm/converter/xml_helpers.py @@ -454,7 +454,8 @@ def _extract_line_from_xml(sp_pr, theme_colors=None, color_mapping=None): srgb = solid.find('a:srgbClr', _NS) scheme = solid.find('a:schemeClr', _NS) if srgb is not None: - result["line"] = _hex(srgb) + from .color import apply_element_transforms + result["line"] = apply_element_transforms(_hex(srgb), srgb) elif scheme is not None: resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) if resolved: diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index fe95113c..788e7e46 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -1004,3 +1004,43 @@ def test_ole_graphicframe_replaced_by_fallback_pic(self): assert pic is not None off = pic.find(f".//{{{ns_a}}}xfrm/{{{ns_a}}}off") assert off is not None and off.get("x") == "100" + + +class TestGroupLineTransform: + """Connectors inside scaled groups kept child-space endpoints. + + The group transform only rewrote x/y/width/height, but line elements + carry x1/y1/x2/y2 — so step lines in an Excel-pasted roadmap group + (scale_y 1.59) drew far from their true position. + """ + + def test_line_endpoints_transformed(self): + import tempfile + + from lxml import etree + from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE + from pptx.util import Emu as E + + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(0), E(0), E(635000), E(635000)) + conn = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, E(635000), E(635000), E(1270000), E(1270000)) + group = slide.shapes.add_group_shape([sp, conn]) + # Force a non-identity transform: double the group extent (scale 2x) + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + nsp = "{http://schemas.openxmlformats.org/presentationml/2006/main}" + xfrm = group._element.find(f"{nsp}grpSpPr/{ns}xfrm") + ext = xfrm.find(f"{ns}ext") + ext.set("cx", str(int(ext.get("cx")) * 2)) + ext.set("cy", str(int(ext.get("cy")) * 2)) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + grp = next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "group") + line = next(e for e in grp["elements"] if e.get("type") == "line") + # Child endpoints (100,100)->(200,200)px scaled 2x from group origin (0,0) + assert (line["x1"], line["y1"]) == (200, 200) + assert (line["x2"], line["y2"]) == (400, 400) + # The buggy path also left width/height keys on lines — must not + assert "width" not in line and "height" not in line From 584ebbc2e10460dc9e384460ce7a429376878fc6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 21:07:06 +0900 Subject: [PATCH 19/22] fix(converter): icons in sub-pixel nested group spaces vanished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vector icon art (here: a cell tower and a network switch) nests groups whose child coordinate space is a few hundred EMU per unit — scaled up ~274x by the parent xfrm. Flattening rounds child coords to px first, collapsing every element to 0x0; the icons disappeared and left stray 12x12 artifacts. Groups with a sub-pixel chExt now fall back to raw XML injection, and the existing freeform-based fallback is applied recursively (icon freeforms live several levels deep). Verified: all 12 corpus-deck slides same or better pixel scores. 2 new regression tests. --- skill/sdpm/converter/elements.py | 32 +++++++++++++++++---- tests/test_converter_robustness.py | 46 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index fb81aa5a..ebf6b7aa 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -1230,11 +1230,33 @@ def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_st if rot is not None: elem["rotation"] = rot - # Save raw XML for groups that can't be losslessly flattened - # (rotated groups, or groups with many freeforms like SVG icons) - has_freeforms = any(child._element.tag.endswith('}sp') and - child.shape_type == 5 for child in shape.shapes) - if rot is not None or has_freeforms: + # Save raw XML for groups that can't be losslessly flattened: + # rotated groups, groups containing freeforms (recursively — vector + # icon art nests them deep), and groups whose child coordinate space + # is sub-pixel (px-rounded flattening collapses everything to 0x0). + def _has_freeforms(g): + for child in g.shapes: + if child.shape_type == 6 and _has_freeforms(child): + return True + if child._element.tag.endswith('}sp') and child.shape_type == 5: + return True + return False + + def _subpixel_children(g): + xf = g._element.find(f'{{{_NS["p"]}}}grpSpPr/{{{_NS["a"]}}}xfrm') + che = xf.find(f'{{{_NS["a"]}}}chExt') if xf is not None else None + if che is None: + return False + # chExt in EMU: below ~1px per unit means children live in a + # miniature coordinate system that px rounding destroys. + try: + return 0 < int(che.get('cx', '0')) < int(EMU_PER_PX * 10) or \ + 0 < int(che.get('cy', '0')) < int(EMU_PER_PX * 10) + except Exception: + return False + + has_freeforms = _has_freeforms(shape) + if rot is not None or has_freeforms or _subpixel_children(shape): try: from lxml import etree as _et elem["_groupXml"] = _et.tostring(shape._element, encoding='unicode') diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 788e7e46..7f8b5339 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -1044,3 +1044,49 @@ def test_line_endpoints_transformed(self): assert (line["x2"], line["y2"]) == (400, 400) # The buggy path also left width/height keys on lines — must not assert "width" not in line and "height" not in line + + +class TestSubpixelGroupRawFallback: + """Icons in miniature nested coordinate systems vanished (0x0). + + Vector icon art often nests groups whose chExt is a few hundred EMU + (sub-pixel units, scaled up ~274x by the parent xfrm). Px-rounded + flattening collapses every child to 0x0. Such groups now fall back + to raw XML injection; freeform detection is also recursive. + """ + + @staticmethod + def _make_deck(mutate_group): + import tempfile + + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + a = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(0), E(0), E(635000), E(635000)) + b = slide.shapes.add_shape(MSO_SHAPE.OVAL, E(635000), E(0), E(635000), E(635000)) + group = slide.shapes.add_group_shape([a, b]) + mutate_group(group) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "t.pptx" + prs.save(p) + result = pptx_to_json(p, Path(td) / "out") + return next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "group") + + def test_subpixel_child_space_uses_raw_xml(self): + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + nsp = "{http://schemas.openxmlformats.org/presentationml/2006/main}" + + def mutate(group): + xfrm = group._element.find(f"{nsp}grpSpPr/{ns}xfrm") + # Miniature child space: 1000x1000 EMU (≪ 1px) like icon art + xfrm.find(f"{ns}chOff").set("x", "0") + xfrm.find(f"{ns}chOff").set("y", "0") + xfrm.find(f"{ns}chExt").set("cx", "1000") + xfrm.find(f"{ns}chExt").set("cy", "1000") + g = self._make_deck(mutate) + assert "_groupXml" in g + + def test_normal_group_still_flattened(self): + g = self._make_deck(lambda group: None) + assert "_groupXml" not in g From 28cd56672b4e4a559ebcb1a5552ac12830aacee8 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 25 Jul 2026 22:35:15 +0900 Subject: [PATCH 20/22] fix(converter): slide order broke past 99 slides; pictures lost flipH Two issues found while round-tripping a 21-deck corpus: - slide-{NN}.json used fixed 2-digit padding, so slide-100 sorted before slide-11 for every consumer that orders by filename (shared/ingest, mcp-local upload_tools, diff). A 352-slide deck imported with slides 11+ shuffled. Pad width now follows the slide count (>=2 digits). - Mirrored pictures dropped flipH/flipV on round-trip: converter didn't extract them for pictures and the builder didn't apply them. A flipped cutout photo rendered its subject on the wrong side (PROFILE deck p4 score 36.9 -> 1.8). 3 new regression tests. --- skill/sdpm/builder/elements/image.py | 11 ++++++ skill/sdpm/converter/elements.py | 4 ++ skill/sdpm/converter/pipeline.py | 7 +++- tests/test_converter_robustness.py | 56 ++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/builder/elements/image.py b/skill/sdpm/builder/elements/image.py index 0d94fb2c..d15a352d 100644 --- a/skill/sdpm/builder/elements/image.py +++ b/skill/sdpm/builder/elements/image.py @@ -249,6 +249,17 @@ def _add_image(self, slide, elem): ) pic.line.width = Pt(elem.get("lineWidth", 1)) + # Apply flip (mirrored pictures — cutout photos rely on this to face + # the right way). pic may be a Picture or a raw XML element (SVG path). + if (elem.get("flipH") or elem.get("flipV")) and pic is not None: + pic_el = pic._element if hasattr(pic, '_element') else pic + xfrm = pic_el.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm') + if xfrm is not None: + if elem.get("flipH"): + xfrm.set('flipH', '1') + if elem.get("flipV"): + xfrm.set('flipV', '1') + # Apply rotation if rotation != 0: if is_svg: diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index ebf6b7aa..2a473b7e 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -1017,6 +1017,10 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them # Extract hyperlink if hasattr(shape, 'click_action') and shape.click_action.hyperlink: elem["link"] = shape.click_action.hyperlink.address + + # Mirrored pictures (flipH/flipV) — without this a cutout photo shows + # its subject on the wrong side of the frame. + _add_flip(elem, shape) # Extract image effects into _originalEffects (underscore-prefixed so builder # ignores them by default). When reusing images in new slides, agents should diff --git a/skill/sdpm/converter/pipeline.py b/skill/sdpm/converter/pipeline.py index a9cddf53..84e7b07f 100644 --- a/skill/sdpm/converter/pipeline.py +++ b/skill/sdpm/converter/pipeline.py @@ -109,9 +109,12 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo deck_meta["autoSpacing"] = False write_json(output_dir / "deck.json", deck_meta) - # Write slides/slide-{NN}.json (1-based, zero-padded, hyphen separator to match parse_outline_slugs) + # Write slides/slide-{NN}.json (1-based, zero-padded, hyphen separator to match parse_outline_slugs). + # Pad width follows the slide count so lexicographic order == numeric order + # (consumers sort by filename; 2-digit padding breaks past 99 slides). + pad = max(2, len(str(len(result["slides"])))) for idx, slide_dict in enumerate(result["slides"], start=1): - slug = f"slide-{idx:02d}" + slug = f"slide-{idx:0{pad}d}" slide_path = slides_dir / f"{slug}.json" write_json(slide_path, slide_dict) diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 7f8b5339..93bb7d7a 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -1090,3 +1090,59 @@ def mutate(group): def test_normal_group_still_flattened(self): g = self._make_deck(lambda group: None) assert "_groupXml" not in g + + +class TestPictureFlip: + """Mirrored pictures (flipH) lost their flip on round-trip. + + A cutout photo whose subject sits on one side of the canvas showed the + subject on the wrong side. Converter now extracts flipH/flipV for + pictures and the builder applies them. + """ + + def test_flip_roundtrip(self, tmp_path): + from pptx.util import Emu as E + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + from PIL import Image as PILImage + img_file = tmp_path / "img.png" + PILImage.new("RGB", (40, 20), "red").save(img_file) + pic = slide.shapes.add_picture(str(img_file), E(0), E(0), E(914400), E(457200)) + pic._element.spPr.xfrm.set("flipH", "1") + src = tmp_path / "t.pptx" + prs.save(src) + result = pptx_to_json(src, tmp_path / "out") + el = next(e for s in result["slides"] for e in s["elements"] if e.get("type") == "image") + assert el.get("flipH") is True + + # Builder applies it back + from sdpm.builder import PPTXBuilder + b = PPTXBuilder(str(_template()), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, default_text_color="#FFFFFF") + out_slide = b.prs.slides.add_slide(b.prs.slide_layouts[-1]) + el["src"] = str((tmp_path / "out" / el["src"]).resolve()) + b._add_image(out_slide, el) + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + xfrms = out_slide.shapes[-1]._element.findall(f".//{ns}xfrm") + assert any(x.get("flipH") == "1" for x in xfrms) + + +class TestSlideFilenamePadding: + """Decks with 100+ slides broke lexicographic ordering. + + slide-100.json sorted before slide-11.json, shuffling every consumer + that sorts by filename. Pad width now follows the slide count. + """ + + def test_pad_width_follows_count(self, tmp_path): + prs = Presentation(str(_template())) + for _ in range(101): + prs.slides.add_slide(prs.slide_layouts[-1]) + src = tmp_path / "big.pptx" + prs.save(src) + pptx_to_json(src, tmp_path / "out") + names = sorted(p.name for p in (tmp_path / "out" / "slides").glob("*.json")) + assert names[0] == "slide-001.json" + assert "slide-101.json" in names + # Lexicographic == numeric + nums = [int(n.split("-")[1].split(".")[0]) for n in names] + assert nums == sorted(nums) From 36add404e3f753e825a080882e9b2b3100c8668b Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 26 Jul 2026 02:05:25 +0900 Subject: [PATCH 21/22] fix(converter): 5 fidelity gaps found on a template deck's feature slide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - p:style fontRef text color: shapes styled via theme (white text on accent bars) painted with the deck default. Resolve fontRef schemeClr into elem fontColor when it differs from the default. - Line spacing: a:lnSpc was dropped. Extract spcPct (textbox+shape) and spcPts (new lineSpacingPt, builder support added) — a 48pt title with 31.2pt fixed spacing sat visibly lower without it. - Pattern backgrounds (a:pattFill, e.g. dotGrid graph paper) vanished. Emit a full-slide patternFill rectangle at the bottom of the z-order. - Pictures now round-trip with fit=stretch (OOXML stretch semantics); builder default contain shrank a full-width wave into a blob. - SVG pictures: srcRect crops are baked into the viewBox (crop was silently dropped) and aspect-mismatched frames bake the distortion into the SVG since LibreOffice ignores preserveAspectRatio=none. PROFILE deck mean score 11.5 -> 1.2; no regressions on 3 other decks. 5 new regression tests. --- skill/sdpm/builder/elements/textbox.py | 11 ++- skill/sdpm/converter/elements.py | 34 ++++++++++ skill/sdpm/converter/slide.py | 34 +++++++++- skill/sdpm/converter/text.py | 30 ++++++++ skill/sdpm/utils/svg.py | 28 ++++++++ tests/test_converter_robustness.py | 94 ++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 4 deletions(-) diff --git a/skill/sdpm/builder/elements/textbox.py b/skill/sdpm/builder/elements/textbox.py index 21f90174..536832ac 100644 --- a/skill/sdpm/builder/elements/textbox.py +++ b/skill/sdpm/builder/elements/textbox.py @@ -231,14 +231,19 @@ def _add_textbox(self, slide, elem): # Apply line spacing (single-text) - must be before buNone in pPr lnSpcPct = elem.get("lineSpacingPct") - if lnSpcPct: + lnSpcPt = elem.get("lineSpacingPt") + if lnSpcPct or lnSpcPt: from lxml import etree as _et from pptx.oxml.ns import qn as _qn for p in tf.paragraphs: pPr = p._element.get_or_add_pPr() lnSpc = _et.Element(_qn('a:lnSpc')) - spcPct = _et.SubElement(lnSpc, _qn('a:spcPct')) - spcPct.set('val', str(lnSpcPct)) + if lnSpcPt: + spcEl = _et.SubElement(lnSpc, _qn('a:spcPts')) + spcEl.set('val', str(int(lnSpcPt * 100))) + else: + spcEl = _et.SubElement(lnSpc, _qn('a:spcPct')) + spcEl.set('val', str(lnSpcPct)) pPr.insert(0, lnSpc) # Apply character spacing diff --git a/skill/sdpm/converter/elements.py b/skill/sdpm/converter/elements.py index 2a473b7e..eac6ae5a 100644 --- a/skill/sdpm/converter/elements.py +++ b/skill/sdpm/converter/elements.py @@ -838,6 +838,11 @@ def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_ lnSpc_pct = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}lnSpc/{http://schemas.openxmlformats.org/drawingml/2006/main}spcPct') if lnSpc_pct is not None: elem["lineSpacingPct"] = int(lnSpc_pct.get('val')) + # Fixed-point spacing (spcPts) — e.g. a 48pt title with 31.2pt + # spacing renders much higher/tighter than the default. + lnSpc_pts = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}lnSpc/{http://schemas.openxmlformats.org/drawingml/2006/main}spcPts') + if lnSpc_pts is not None: + elem["lineSpacingPt"] = int(lnSpc_pts.get('val')) / 100 # Extract visual effects try: @@ -978,6 +983,28 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them # Check for SVG (asvg:svgBlip) svg_bytes = _extract_svg_blob(shape) if svg_bytes is not None: + # PowerPoint crops via blipFill srcRect; SVG frames lose it in the + # builder path, so bake the crop into the viewBox instead. + src_rect = shape._element.find( + f'{{{_NS["p"]}}}blipFill/{{{_NS["a"]}}}srcRect') + if src_rect is not None: + try: + from lxml import etree as _et + root = _et.fromstring(svg_bytes) + vb = root.get('viewBox') + if vb: + mx, my, vw, vh = [float(v) for v in vb.replace(',', ' ').split()] + pct = {k: int(src_rect.get(k, '0')) / 100000 for k in ('l', 't', 'r', 'b')} + if any(pct.values()) and vw > 0 and vh > 0: + nx = mx + pct['l'] * vw + ny = my + pct['t'] * vh + nw = vw * (1 - pct['l'] - pct['r']) + nh = vh * (1 - pct['t'] - pct['b']) + if nw > 0 and nh > 0: + root.set('viewBox', f'{nx:g} {ny:g} {nw:g} {nh:g}') + svg_bytes = _et.tostring(root) + except Exception: + pass if output_dir: images_dir = Path(output_dir) / "images" images_dir.mkdir(exist_ok=True) @@ -987,6 +1014,8 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them # Imported artwork keeps its own colors — opt out of the builder's # theme-icon recolor (which repainted e.g. green wave shapes black). elem["iconColor"] = "none" + _add_flip(elem, shape) + elem["fit"] = "stretch" return elem # Save image to file @@ -1021,6 +1050,11 @@ def extract_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, them # Mirrored pictures (flipH/flipV) — without this a cutout photo shows # its subject on the wrong side of the frame. _add_flip(elem, shape) + + # PowerPoint's fills the frame exactly, + # distorting aspect if needed. The builder default (contain) would + # shrink e.g. a full-width wave band into a left-anchored blob. + elem["fit"] = "stretch" # Extract image effects into _originalEffects (underscore-prefixed so builder # ignores them by default). When reusing images in new slides, agents should diff --git a/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index f9f378a1..c7a1fb82 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -184,7 +184,39 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non slide_w, slide_h = 1920, 1080 grad = bgPr.find(f'{{{_NS["a"]}}}gradFill') blip_fill = bgPr.find(f'{{{_NS["a"]}}}blipFill') - if grad is not None: + patt = bgPr.find(f'{{{_NS["a"]}}}pattFill') + if patt is not None: + # Pattern background (e.g. dotGrid graph paper): the + # builder supports patternFill on shapes, so emit a + # full-slide rectangle at the bottom of the z-order. + from .color import _resolve_color_with_transforms + + def _patt_color(tag, default): + clr = patt.find(f'{{{_NS["a"]}}}{tag}') + if clr is None: + return default + srgb_c = clr.find(f'{{{_NS["a"]}}}srgbClr') + if srgb_c is not None: + return f"#{srgb_c.get('val')}" + scheme_c = clr.find(f'{{{_NS["a"]}}}schemeClr') + if scheme_c is not None: + return _resolve_color_with_transforms( + scheme_c, theme_colors, color_mapping) or default + return default + + bg_extra_element = { + "type": "shape", + "shape": "rectangle", + "x": 0, "y": 0, "width": slide_w, "height": slide_h, + "patternFill": { + "pattern": patt.get('prst', 'dotGrid'), + "fgColor": _patt_color('fgClr', '#D9D9D9'), + "bgColor": _patt_color('bgClr', '#FFFFFF'), + }, + "line": "none", + "_noEffects": True, + } + elif grad is not None: from .xml_helpers import _extract_fill_from_xml fill_info = _extract_fill_from_xml(bgPr, theme_colors, color_mapping) if fill_info.get("gradient"): diff --git a/skill/sdpm/converter/text.py b/skill/sdpm/converter/text.py index bfc3bc21..dadd4704 100644 --- a/skill/sdpm/converter/text.py +++ b/skill/sdpm/converter/text.py @@ -172,6 +172,22 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t if body_pr.get('wrap') == 'none': elem["autoWidth"] = True + # Line spacing (a:lnSpc) — fixed-point spacing repositions text visibly + # (e.g. a 48pt title with 31.2pt spacing sits much higher than default). + if tf.paragraphs: + first_pPr = tf.paragraphs[0]._element.find(f'{{{_NS["a"]}}}pPr') + if first_pPr is not None: + lnSpc = first_pPr.find(f'{{{_NS["a"]}}}lnSpc') + if lnSpc is not None: + spcPts = lnSpc.find(f'{{{_NS["a"]}}}spcPts') + spcPct = lnSpc.find(f'{{{_NS["a"]}}}spcPct') + if spcPts is not None and spcPts.get('val'): + elem["lineSpacingPt"] = int(spcPts.get('val')) / 100 + elif spcPct is not None and spcPct.get('val'): + val = int(spcPct.get('val')) + if val != 100000: + elem["lineSpacingPct"] = val + default_text_color = builder_text_color if not default_text_color and color_mapping and theme_colors: tx1 = color_mapping.get('tx1', 'dk1') @@ -191,6 +207,20 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t if _builder_text_color: default_text_color = _builder_text_color + # p:style/a:fontRef: shapes styled via theme (e.g. white text on an + # accent-filled bar) inherit their text color from the style, not from + # run properties. Resolve it so the builder doesn't paint them with the + # deck default. + font_ref = shape._element.find(f'{{{_NS["p"]}}}style/{{{_NS["a"]}}}fontRef') + if font_ref is not None and theme_colors: + scheme_el = font_ref.find(f'{{{_NS["a"]}}}schemeClr') + if scheme_el is not None: + from .color import _resolve_color_with_transforms + ref_color = _resolve_color_with_transforms(scheme_el, theme_colors, color_mapping) + if ref_color and ref_color.lower() != (default_text_color or '').lower(): + elem["fontColor"] = ref_color + default_text_color = ref_color + paragraphs_with_text = [p for p in tf.paragraphs if p.text.strip()] all_paragraphs = list(tf.paragraphs) # Skip default_font_size if shape has lstStyle (sizes handled by lstStyle) diff --git a/skill/sdpm/utils/svg.py b/skill/sdpm/utils/svg.py index 68873765..92bda214 100644 --- a/skill/sdpm/utils/svg.py +++ b/skill/sdpm/utils/svg.py @@ -182,6 +182,34 @@ def add_svg_to_slide(slide, svg_bytes: bytes, x, y, width, height): from pptx.opc.package import Part from pptx.opc.packuri import PackURI + # OOXML fills the frame exactly (distorting aspect), but + # SVG renderers letterbox by default — and LibreOffice ignores + # preserveAspectRatio="none". Bake the distortion into the SVG itself: + # reshape the viewBox to the frame aspect and scale the content. + try: + root = etree.fromstring(svg_bytes) + vb = root.get('viewBox') + if vb and width and height: + parts = [float(v) for v in vb.replace(',', ' ').split()] + if len(parts) == 4 and parts[2] > 0 and parts[3] > 0: + vb_ratio = parts[2] / parts[3] + frame_ratio = width / height + if abs(vb_ratio - frame_ratio) / max(vb_ratio, frame_ratio) > 0.02: + new_h = parts[2] / frame_ratio + sy = new_h / parts[3] + g = etree.SubElement(root, '{http://www.w3.org/2000/svg}g') + g.set('transform', f'translate(0 {-parts[1] * sy + parts[1]:g}) scale(1 {sy:g})') + for child in list(root): + if child is not g: + root.remove(child) + g.append(child) + root.set('viewBox', f'{parts[0]:g} {parts[1]:g} {parts[2]:g} {new_h:g}') + if root.get('preserveAspectRatio') is None: + root.set('preserveAspectRatio', 'none') + svg_bytes = etree.tostring(root) + except Exception: + pass + slide_part = slide.part idx = 1 diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 93bb7d7a..74b08336 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -1146,3 +1146,97 @@ def test_pad_width_follows_count(self, tmp_path): # Lexicographic == numeric nums = [int(n.split("-")[1].split(".")[0]) for n in names] assert nums == sorted(nums) + + +class TestP3FidelityFixes: + """Round-trip fixes found on a template deck's feature-cards slide.""" + + def _rt(self, mutate, tmp_path): + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + mutate(prs, slide) + src = tmp_path / "t.pptx" + prs.save(src) + return pptx_to_json(src, tmp_path / "out") + + def test_fixed_point_line_spacing_extracted(self, tmp_path): + from pptx.util import Emu as E, Pt + from lxml import etree + from pptx.oxml.ns import qn + + def mutate(prs, slide): + tb = slide.shapes.add_textbox(E(0), E(0), E(914400), E(457200)) + tb.text_frame.text = "title" + tb.text_frame.paragraphs[0].font.size = Pt(48) + pPr = tb.text_frame.paragraphs[0]._p.get_or_add_pPr() + lnSpc = etree.SubElement(pPr, qn('a:lnSpc')) + etree.SubElement(lnSpc, qn('a:spcPts')).set('val', '3120') + pPr.insert(0, lnSpc) + result = self._rt(mutate, tmp_path) + el = next(e for s in result["slides"] for e in s["elements"] + if e.get("type") == "textbox" and "title" in str(e.get("text", ""))) + assert el.get("lineSpacingPt") == 31.2 + + def test_style_fontref_color_extracted(self, tmp_path): + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + from lxml import etree + from pptx.oxml.ns import qn + + def mutate(prs, slide): + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(0), E(0), E(914400), E(457200)) + sp.text_frame.text = "header" + # python-pptx's add_shape already writes a p:style — point its + # fontRef at accent1 (differs from the default text color). + fr = sp._element.find(qn('p:style') + '/' + qn('a:fontRef')) + for child in list(fr): + fr.remove(child) + etree.SubElement(fr, qn('a:schemeClr')).set('val', 'accent1') + result = self._rt(mutate, tmp_path) + el = next(e for s in result["slides"] for e in s["elements"] + if "header" in str(e.get("text", ""))) + # accent1 differs from the default text color, so it must be kept + assert el.get("fontColor", "").startswith("#") + + def test_pattern_background_emitted(self, tmp_path): + from lxml import etree + from pptx.oxml.ns import qn + + def mutate(prs, slide): + cSld = slide._element.find(qn('p:cSld')) + bg = etree.Element(qn('p:bg')) + bgPr = etree.SubElement(bg, qn('p:bgPr')) + patt = etree.SubElement(bgPr, qn('a:pattFill')); patt.set('prst', 'dotGrid') + fg = etree.SubElement(patt, qn('a:fgClr')) + etree.SubElement(fg, qn('a:srgbClr')).set('val', 'D9D9D9') + bgc = etree.SubElement(patt, qn('a:bgClr')) + etree.SubElement(bgc, qn('a:srgbClr')).set('val', 'FFFFFF') + etree.SubElement(bgPr, qn('a:effectLst')) + cSld.insert(0, bg) + result = self._rt(mutate, tmp_path) + els = [e for s in result["slides"] for e in s["elements"] if e.get("patternFill")] + assert els and els[0]["patternFill"]["pattern"] == "dotGrid" + assert els[0]["patternFill"]["fgColor"].upper() == "#D9D9D9" + + def test_svg_srcrect_baked_into_viewbox(self, tmp_path): + # srcRect crop on an svgBlip pic must survive as a viewBox crop + svg = (b'' + b'') + + def mutate(prs, slide): + from sdpm.utils.svg import add_svg_to_slide + from lxml import etree + from pptx.oxml.ns import qn + add_svg_to_slide(slide, svg, 0, 0, 4114800, 914400) # ratio 4.5 = viewBox + pic = slide.shapes._spTree[-1] + bf = pic.find(qn('p:blipFill')) + sr = etree.SubElement(bf, qn('a:srcRect')) + sr.set('b', '43426') + blip = bf.find(qn('a:blip')) + blip.addnext(sr) + result = self._rt(mutate, tmp_path) + el = next(e for s in result["slides"] for e in s["elements"] + if e.get("type") == "image" and str(e.get("src", "")).endswith(".svg")) + out_svg = (tmp_path / "out" / el["src"]).read_bytes().decode() + assert 'viewBox="0 0 1440 181' in out_svg # 320 * (1 - 0.43426) ≈ 181 + assert el.get("fit") == "stretch" From 2a0a64b58874ca64ce34ef9e1314beab59f523fb Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 26 Jul 2026 13:01:05 +0900 Subject: [PATCH 22/22] fix: two PowerPoint-only rendering bugs LibreOffice masked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fontRef text color was still overridden: runs without their own solidFill got the inherited tx1 fallback (#000000) baked into the styled-text tags, beating the elem-level fontColor. Suppress the inherited fallback when fontRef supplies the color (explicit run colors still win). - patternFill was appended at the end of spPr, after a:ln/a:effectLst. CT_ShapeProperties is an ordered sequence — PowerPoint silently ignores out-of-order fills (LibreOffice renders them, hiding the bug). Insert the fill right after the geometry element. PROFILE deck mean 1.2 -> 0.9; ZTO/medical decks unchanged. 2 new regression tests (incl. spPr child-order assertion). --- skill/sdpm/builder/elements/shape.py | 16 +++++++++- skill/sdpm/converter/text.py | 28 ++++++++++------- tests/test_converter_robustness.py | 46 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/skill/sdpm/builder/elements/shape.py b/skill/sdpm/builder/elements/shape.py index 7851cd4c..442356be 100644 --- a/skill/sdpm/builder/elements/shape.py +++ b/skill/sdpm/builder/elements/shape.py @@ -183,7 +183,7 @@ def _add_shape(self, slide, elem): for tag in ('a:solidFill', 'a:gradFill', 'a:noFill', 'a:pattFill'): for el in sp_pr.findall(qn(tag)): sp_pr.remove(el) - patt = etree.SubElement(sp_pr, qn('a:pattFill')) + patt = etree.Element(qn('a:pattFill')) patt.set('prst', pattern_fill.get("pattern", "dkDnDiag")) fg = etree.SubElement(patt, qn('a:fgClr')) fg_srgb = etree.SubElement(fg, qn('a:srgbClr')) @@ -191,6 +191,20 @@ def _add_shape(self, slide, elem): bg = etree.SubElement(patt, qn('a:bgClr')) bg_srgb = etree.SubElement(bg, qn('a:srgbClr')) bg_srgb.set('val', pattern_fill.get("bgColor", "#000000").lstrip("#")) + # CT_ShapeProperties is an ordered sequence: the fill must come + # BEFORE a:ln/a:effectLst. PowerPoint silently ignores fills that + # appear out of order (LibreOffice is lenient, so renders hid the + # bug). Insert after prstGeom/custGeom when present. + anchor = None + for tag in ('a:prstGeom', 'a:custGeom', 'a:xfrm'): + el = sp_pr.find(qn(tag)) + if el is not None: + anchor = el + break + if anchor is not None: + anchor.addnext(patt) + else: + sp_pr.insert(0, patt) # Apply line color or gradient line_gradient = elem.get("lineGradient") diff --git a/skill/sdpm/converter/text.py b/skill/sdpm/converter/text.py index dadd4704..475cf78c 100644 --- a/skill/sdpm/converter/text.py +++ b/skill/sdpm/converter/text.py @@ -4,7 +4,7 @@ from .constants import _NS, EMU_PER_PX, _serialize_lstStyle from .color import extract_text_color -def _extract_styled_text(runs, theme_colors=None, color_mapping=None, default_font_size=None, default_text_color=None, is_placeholder=False, paragraph=None): +def _extract_styled_text(runs, theme_colors=None, color_mapping=None, default_font_size=None, default_text_color=None, is_placeholder=False, paragraph=None, suppress_inherited=False): """Convert a list of runs to styled text string. If paragraph is provided, handles (soft line breaks).""" parts = [] # If paragraph element is available, iterate children to capture elements @@ -18,7 +18,7 @@ def _extract_styled_text(runs, theme_colors=None, color_mapping=None, default_fo elif tag == 'r' and run_idx < len(runs): run = runs[run_idx] run_idx += 1 - formatted = _format_run(run, theme_colors, color_mapping, default_font_size, default_text_color, is_placeholder) + formatted = _format_run(run, theme_colors, color_mapping, default_font_size, default_text_color, is_placeholder, suppress_inherited) if pending_br: # Insert \u000b before the run (outside link tags) if formatted.startswith('{{') and 'link:' in formatted: @@ -36,10 +36,10 @@ def _extract_styled_text(runs, theme_colors=None, color_mapping=None, default_fo return ''.join(parts) # Fallback: runs only for run in runs: - parts.append(_format_run(run, theme_colors, color_mapping, default_font_size, default_text_color, is_placeholder)) + parts.append(_format_run(run, theme_colors, color_mapping, default_font_size, default_text_color, is_placeholder, suppress_inherited)) return ''.join(parts) -def _format_run(run, theme_colors=None, color_mapping=None, default_font_size=None, default_text_color=None, is_placeholder=False): +def _format_run(run, theme_colors=None, color_mapping=None, default_font_size=None, default_text_color=None, is_placeholder=False, suppress_inherited=False): """Format a single run with styled text markup.""" if not run.text: return '' @@ -62,9 +62,14 @@ def _format_run(run, theme_colors=None, color_mapping=None, default_font_size=No if default_font_size is None or pt != default_font_size: styles.append(f"{pt}pt") try: - hex_color = extract_text_color(run, theme_colors, color_mapping, is_placeholder=is_placeholder) - if hex_color and hex_color != default_text_color: - styles.append(hex_color) + # When the shape's p:style fontRef supplies the text color (emitted + # as elem fontColor), runs without their own solidFill must NOT get + # the inherited tx1 fallback baked in — it would override fontRef. + has_own_color = run.font.color is not None and run.font.color.type is not None + if not (suppress_inherited and not has_own_color): + hex_color = extract_text_color(run, theme_colors, color_mapping, is_placeholder=is_placeholder) + if hex_color and hex_color != default_text_color: + styles.append(hex_color) except Exception: pass if run.font.name: @@ -220,6 +225,7 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t if ref_color and ref_color.lower() != (default_text_color or '').lower(): elem["fontColor"] = ref_color default_text_color = ref_color + _suppress_inherited = "fontColor" in elem paragraphs_with_text = [p for p in tf.paragraphs if p.text.strip()] all_paragraphs = list(tf.paragraphs) @@ -239,7 +245,7 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t if len(non_bullet) == 0: items = [] for para in paragraphs_with_text: - t = _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para) + t = _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para, suppress_inherited=_suppress_inherited) if t.strip(): items.append(t) if items: @@ -249,7 +255,7 @@ def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_t paras = [] for para in all_paragraphs: p = {} - t = _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para) + t = _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para, suppress_inherited=_suppress_inherited) p["text"] = t pPr = para._element.find(f'{{{ns_a}}}pPr') if pPr is not None: @@ -287,7 +293,7 @@ def _end_sz(para): if sized_empties: paras = [] for para in all_paragraphs: - p = {"text": _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para)} + p = {"text": _extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para, suppress_inherited=_suppress_inherited)} a = _get_alignment(para) if a: p["align"] = a @@ -303,7 +309,7 @@ def _end_sz(para): for i, para in enumerate(all_paragraphs): if i > 0: parts.append('\n') - parts.append(_extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para)) + parts.append(_extract_styled_text(para.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, paragraph=para, suppress_inherited=_suppress_inherited)) elem["text"] = ''.join(parts) # Extract indent/marL from first paragraph for single-text shapes if paragraphs_with_text: diff --git a/tests/test_converter_robustness.py b/tests/test_converter_robustness.py index 74b08336..d55feb2b 100644 --- a/tests/test_converter_robustness.py +++ b/tests/test_converter_robustness.py @@ -1240,3 +1240,49 @@ def mutate(prs, slide): out_svg = (tmp_path / "out" / el["src"]).read_bytes().decode() assert 'viewBox="0 0 1440 181' in out_svg # 320 * (1 - 0.43426) ≈ 181 assert el.get("fit") == "stretch" + + +class TestPowerPointStrictness: + """Bugs invisible in LibreOffice (lenient) but visible in PowerPoint.""" + + def test_fontref_runs_not_overridden_by_inherited_black(self, tmp_path): + # Runs WITHOUT their own color must not get the inherited tx1 + # fallback baked in when the style fontRef supplies the color. + from pptx.enum.shapes import MSO_SHAPE + from pptx.util import Emu as E + from lxml import etree + from pptx.oxml.ns import qn + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, E(0), E(0), E(914400), E(457200)) + sp.text_frame.text = "header" + fr = sp._element.find(qn('p:style') + '/' + qn('a:fontRef')) + for child in list(fr): + fr.remove(child) + etree.SubElement(fr, qn('a:schemeClr')).set('val', 'accent1') + src = tmp_path / "t.pptx" + prs.save(src) + result = pptx_to_json(src, tmp_path / "out") + el = next(e for s in result["slides"] for e in s["elements"] + if "header" in str(e.get("text", ""))) + assert el.get("fontColor", "").startswith("#") + # No color tag baked into the run text + assert "#" not in str(el["text"]).replace(el.get("fontColor"), "") + + def test_pattern_fill_schema_order(self, tmp_path): + # PowerPoint ignores fills that appear after a:ln in spPr. + from sdpm.builder import PPTXBuilder + from pptx.oxml.ns import qn + b = PPTXBuilder(str(_template()), fonts={"fullwidth": "Meiryo", "halfwidth": "Arial"}, default_text_color="#FFFFFF") + slide = b.prs.slides.add_slide(b.prs.slide_layouts[-1]) + b._add_shape(slide, { + "type": "shape", "shape": "rectangle", + "x": 0, "y": 0, "width": 100, "height": 100, + "patternFill": {"pattern": "dotGrid", "fgColor": "#D9D9D9", "bgColor": "#FFFFFF"}, + "line": "none", + }) + sp_pr = slide.shapes[-1]._element.spPr + tags = [c.tag.split('}')[-1] for c in sp_pr] + assert 'pattFill' in tags + assert tags.index('pattFill') < tags.index('ln') + assert tags.index('prstGeom') < tags.index('pattFill')