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/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 23509366..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"} @@ -198,6 +199,14 @@ 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") + + # 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) @@ -311,6 +320,12 @@ 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 " + 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/builder/elements/group.py b/skill/sdpm/builder/elements/group.py index ad7f5b96..805054aa 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,80 @@ 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' + 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 01a201bf..d15a352d 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: @@ -149,7 +153,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 @@ -243,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/builder/elements/shape.py b/skill/sdpm/builder/elements/shape.py index da7292cd..442356be 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"]) @@ -172,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')) @@ -180,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") @@ -280,17 +305,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 +356,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/elements/table.py b/skill/sdpm/builder/elements/table.py index e49653ed..99762d83 100644 --- a/skill/sdpm/builder/elements/table.py +++ b/skill/sdpm/builder/elements/table.py @@ -244,17 +244,51 @@ 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 — 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) + 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/builder/elements/textbox.py b/skill/sdpm/builder/elements/textbox.py index 4dfe59ed..536832ac 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"]) @@ -228,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 @@ -302,6 +310,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..89d86e9a 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) @@ -173,11 +183,23 @@ def _apply_gradient_fill(self, shape, gradient): grad_fill = _build_grad_fill_element(gradient) - ln_el = sp_pr.find(qn('a:ln')) - if ln_el is not None: - sp_pr.insert(list(sp_pr).index(ln_el), grad_fill) + # CT_ShapeProperties is an ordered sequence (fill before a:ln / + # a:effectLst). PowerPoint silently ignores out-of-order fills, so + # anchor on the geometry element rather than appending. + 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(grad_fill) else: - sp_pr.append(grad_fill) + ln_el = sp_pr.find(qn('a:ln')) + if ln_el is not None: + sp_pr.insert(list(sp_pr).index(ln_el), grad_fill) + else: + sp_pr.insert(0, grad_fill) def _set_fill_opacity(self, shape, opacity): """Set fill opacity using low-level XML manipulation. @@ -215,7 +237,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 @@ -241,6 +263,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')): @@ -249,6 +286,8 @@ def _apply_styled_text(self, paragraph, text, default_color=None, default_font_s rPr = run._r.get_or_add_rPr() sym = _et.SubElement(rPr, _qn('a:sym')) sym.set('typeface', actual_font) + if seg.get("baseline") is not None: + run._r.get_or_add_rPr().set('baseline', str(seg["baseline"])) # Apply all styles per run so multi-line labels render consistently font_size = seg.get("fontSize") or default_font_size if font_size: 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/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/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/elements.py b/skill/sdpm/converter/elements.py index d432b79a..eac6ae5a 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" @@ -463,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 @@ -532,6 +566,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') @@ -573,7 +614,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] @@ -772,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: @@ -800,20 +871,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.""" @@ -863,6 +920,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' @@ -887,12 +983,39 @@ 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) 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" + _add_flip(elem, shape) + elem["fit"] = "stretch" return elem # Save image to file @@ -923,6 +1046,15 @@ 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) + + # 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 @@ -996,13 +1128,96 @@ 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 _image_ext(part) + filename = f"slide{slide_idx + 1}_image{img_counter + 1}.{ext}" + elem = _base_element(shape, "image") + elem["src"] = _save_image_part(part, output_dir, 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') + 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 + + 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 +1239,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: @@ -1049,14 +1268,41 @@ 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') + # 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 @@ -1140,6 +1386,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) @@ -1157,6 +1419,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/pipeline.py b/skill/sdpm/converter/pipeline.py index c1b5cfdb..84e7b07f 100644 --- a/skill/sdpm/converter/pipeline.py +++ b/skill/sdpm/converter/pipeline.py @@ -105,11 +105,16 @@ 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) + # 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/skill/sdpm/converter/slide.py b/skill/sdpm/converter/slide.py index 87072622..22a49249 100644 --- a/skill/sdpm/converter/slide.py +++ b/skill/sdpm/converter/slide.py @@ -128,8 +128,17 @@ 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 + # 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 try: bg = slide.background._element.find(f'{{{_NS["p"]}}}bg') if bg is not None: @@ -139,18 +148,117 @@ 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 + # 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') + 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"): + 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 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').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) + (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", + # keep artwork colors (SVG bg would otherwise be + # recolored to the theme text color) + "iconColor": "none", + } except Exception: pass # 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 @@ -160,7 +268,21 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non if ph_type in (10, 11, 12, 13, 14, 15, 16): # DATE, SLIDE_NUMBER, FOOTER, HEADER, etc. continue str_idx = str(idx) + # Orphaned placeholder: the layout has no placeholder with this idx + # (idx 0xFFFFFFFF, or a slide that outlived its layout). The builder + # can never match it, so rescue it as a positioned textbox instead. + try: + layout_idxs = {ph.placeholder_format.idx for ph in slide.slide_layout.placeholders} + except Exception: + layout_idxs = None + if layout_idxs is not None and idx not in layout_idxs: + dup_placeholder_ids.add(shape.shape_id) + continue 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 +292,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) @@ -219,6 +350,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): @@ -301,6 +434,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/converter/table.py b/skill/sdpm/converter/table.py index 9eae405e..9285497d 100644 --- a/skill/sdpm/converter/table.py +++ b/skill/sdpm/converter/table.py @@ -10,16 +10,71 @@ 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 = [] + bullet_chars = [] 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) + pPr = para._element.find('a:pPr', _NS) + bu = pPr.find('a:buChar', _NS) if pPr is not None else None + 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) + 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 if tc_pr is not None: # Fill → background @@ -30,9 +85,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: @@ -65,6 +122,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: @@ -77,6 +139,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 = {} @@ -174,18 +240,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 +282,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 +364,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/skill/sdpm/converter/text.py b/skill/sdpm/converter/text.py index 9b61a69a..eee72e3f 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,24 @@ 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 + # Sub/superscript (a:rPr baseline) — renderers auto-shrink offset runs, + # so dropping it makes e.g. an 80pt decorative "01" render full-size. + try: + rPr_bl = run._r.find(f'{{{_NS["a"]}}}rPr') + if rPr_bl is not None and rPr_bl.get('baseline'): + bl = int(rPr_bl.get('baseline')) + if bl != 0: + styles.append(f"baseline={bl}") except Exception: pass if run.font.name: @@ -106,6 +121,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): @@ -150,6 +187,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') @@ -169,11 +222,31 @@ 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 + _suppress_inherited = "fontColor" in elem + 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) 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 @@ -182,7 +255,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: @@ -192,7 +265,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: @@ -213,12 +286,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, suppress_inherited=_suppress_inherited)} + 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, suppress_inherited=_suppress_inherited)) + 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/converter/xml_helpers.py b/skill/sdpm/converter/xml_helpers.py index dbcbf077..ef2c10d5 100644 --- a/skill/sdpm/converter/xml_helpers.py +++ b/skill/sdpm/converter/xml_helpers.py @@ -12,8 +12,30 @@ 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.""" + """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: @@ -23,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): @@ -245,6 +275,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) @@ -276,6 +314,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: @@ -366,6 +412,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: @@ -401,11 +454,16 @@ 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: 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/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/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/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/skill/sdpm/utils/text.py b/skill/sdpm/utils/text.py index 82568001..e20eab5c 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: @@ -157,14 +164,27 @@ def _parse_non_link_styles(text): pass elif attr.startswith("font="): segment["fontName"] = attr[5:] + elif attr.startswith("baseline="): + # Sub/superscript offset in 1000ths of a percent (OOXML raw). + # Renderers auto-shrink offset runs, so dropping this made + # e.g. an 80pt "01" render full-size and wrap. + try: + segment["baseline"] = int(attr[9:]) + except ValueError: + pass segments.append(segment) 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 new file mode 100644 index 00000000..f1607e2f --- /dev/null +++ b/tests/test_converter_robustness.py @@ -0,0 +1,1355 @@ +# 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" + + +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}" + + +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]}" + + +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" + + +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() + + +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) + + +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 + + 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")] + + +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 + + +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] + + +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 + + +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_roundtrip(self): + import tempfile + + 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() + pPr.set("marL", "93663") + pPr.set("indent", "-93663") + 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] + 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"}, + 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] == ["•", "•"] + 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の仕様理解"] + + +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 + + +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" + + +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 + + +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 + + +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) + + +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" + + +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') + + +class TestSalesDeckFixes: + """Fixes from the sales-template deck (p1 fade, p3 numbers/title).""" + + def test_baseline_roundtrip(self, tmp_path): + # Sub/superscript baseline must survive: renderers auto-shrink + # offset runs, so dropping it made an 80pt "01" wrap vertically. + from pptx.util import Emu as E, Pt + from pptx.oxml.ns import qn + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + tb = slide.shapes.add_textbox(E(0), E(0), E(914400), E(914400)) + tb.text_frame.text = "01" + run = tb.text_frame.paragraphs[0].runs[0] + run.font.size = Pt(80) + run._r.get_or_add_rPr().set('baseline', '-15079') + 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 "01" in str(e.get("text", ""))) + assert "baseline=-15079" in el["text"] + # Parser understands the tag + from sdpm.utils.text import parse_styled_text + segs = parse_styled_text(el["text"]) + assert any(s.get("baseline") == -15079 for s in segs) + + def test_orphaned_placeholder_rescued(self, tmp_path): + # idx=0xFFFFFFFF placeholders (no layout counterpart) were stored in + # the placeholders dict where the builder can never match them. + from pptx.oxml.ns import qn + prs = Presentation(str(_template())) + slide = prs.slides.add_slide(prs.slide_layouts[-1]) + tb = slide.shapes.add_textbox(0, 0, 914400, 457200) + tb.text_frame.text = "orphan title" + # Turn the textbox into an orphaned placeholder + nvPr = tb._element.find(qn('p:nvSpPr') + '/' + qn('p:nvPr')) + from lxml import etree + ph = etree.SubElement(nvPr, qn('p:ph')) + ph.set('type', 'title') + ph.set('idx', '4294967295') + src = tmp_path / "t.pptx" + prs.save(src) + result = pptx_to_json(src, tmp_path / "out") + found = any("orphan title" in str(e.get("text", "")) + for s in result["slides"] for e in s["elements"]) + assert found, "orphaned placeholder must be rescued as a textbox element" + + def test_gradient_fill_schema_order(self, tmp_path): + # PowerPoint ignores gradFill placed after a:ln (LibreOffice renders + # it, hiding the bug) — the p1 fade shape vanished in PowerPoint. + from sdpm.builder import PPTXBuilder + 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, + "gradient": {"stops": [{"position": 0.27, "color": "#F6F8FB"}, + {"position": 1.0, "color": "#FFFFFF", "opacity": 0.0}], + "angle": 0.0, "type": "linear"}, + "line": "none", "_noEffects": True, + }) + sp_pr = slide.shapes[-1]._element.spPr + tags = [c.tag.split('}')[-1] for c in sp_pr] + assert 'gradFill' in tags + assert tags.index('gradFill') < tags.index('ln')