Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
73af8dd
fix(converter): 3 robustness bugs found by real-world PPTX corpus tes…
Jul 24, 2026
768e01f
fix: 3 fidelity bugs found by visual roundtrip testing + diff blind spot
Jul 24, 2026
87c7568
fix: theme table styles + gradient/image slide backgrounds; faithful-…
Jul 24, 2026
ba77a94
fix: roundtrip showMasterSp=0 (Hide Background Graphics)
Jul 24, 2026
f1d3e6b
fix: eliminate remaining worst-fidelity slides (9 fixes)
Jul 24, 2026
de9a6db
refactor(converter): dedupe rId image saving, drop orphaned dead code
Jul 24, 2026
4cf2a68
fix(converter): rotated connectors lost orientation on roundtrip
Jul 25, 2026
4d2de77
fix(converter): textbox vertical anchor + sysClr color resolution
Jul 25, 2026
2a048c0
fix(converter): sysClr gradient stops were dropped
Jul 25, 2026
f387668
fix(converter): invert tint semantics + satMod support in srgb transf…
Jul 25, 2026
d5a58b1
fix(converter): justify align, verbatim import text, sized empty para…
Jul 25, 2026
46471f7
fix(converter): no-wrap labels re-wrapped + inherited font size lost
Jul 25, 2026
c634324
fix(converter): table cell lstStyle color, sysClr borders, cell bullets
Jul 25, 2026
6dfe2b9
feat(builder): bullet list support inside table cells
Jul 25, 2026
1362c13
fix(converter): preserve exact bullet indent in table cell paragraphs
Jul 25, 2026
e8b50cd
fix(converter): rebuilt shapes gained theme shadows the source never had
Jul 25, 2026
9a2c48e
fix(builder): OLE objects in injected raw XML corrupted the package
Jul 25, 2026
4ed25de
fix(converter): lines in scaled groups + srgb line color transforms
Jul 25, 2026
584ebbc
fix(converter): icons in sub-pixel nested group spaces vanished
Jul 25, 2026
28cd566
fix(converter): slide order broke past 99 slides; pictures lost flipH
Jul 25, 2026
36add40
fix(converter): 5 fidelity gaps found on a template deck's feature slide
Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions skill/references/guides/import-pptx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions skill/sdpm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -494,6 +495,7 @@ def _resolve_config(
base_dir=base_dir,
warnings=warnings,
lint_diagnostics=lint_diagnostics,
auto_spacing=data.get("autoSpacing", True),
)


Expand All @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion skill/sdpm/builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions skill/sdpm/builder/elements/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
21 changes: 19 additions & 2 deletions skill/sdpm/builder/elements/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 15 additions & 10 deletions skill/sdpm/builder/elements/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p:style> (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"])
Expand Down Expand Up @@ -280,17 +291,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):
Expand Down Expand Up @@ -337,7 +342,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

Expand Down
44 changes: 39 additions & 5 deletions skill/sdpm/builder/elements/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
41 changes: 36 additions & 5 deletions skill/sdpm/builder/elements/textbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
Loading