diff --git a/.kiro/steering/principles.md b/.kiro/steering/principles.md index 943f9a32..c9a3eca7 100644 --- a/.kiro/steering/principles.md +++ b/.kiro/steering/principles.md @@ -61,8 +61,9 @@ Rules that follow from this: touches only `sdpm/sdpm/tools/`. Known debt against this philosophy (tracked for v0.5.x): -`converter/elements.py` is a low-cohesion monolith -inside the core; `api/index.py` has no test coverage. +`api/index.py` has no test coverage. +(v0.5.2 resolved: `converter/elements.py` monolith → `converter/elements/` +package with an enforced dependency DAG; scale state → ContextVar scope.) ## Engine & Knowledge (`sdpm/sdpm/`) diff --git a/pyproject.toml b/pyproject.toml index ab680ad5..0e010da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,11 @@ src = ["sdpm", "servers/local", "servers/remote"] [tool.pytest.ini_options] testpaths = ["tests"] +filterwarnings = [ + # Our own code must never emit FutureWarning (e.g. lxml element truth-testing) + "error::FutureWarning", + # Upstream-only: defusedxml's own __init__ imports its deprecated + # cElementTree shim on Python 3.12. Do NOT widen this filter — the + # security monkey patch (defuse_stdlib) must stay under scrutiny. + "ignore:defusedxml.cElementTree is deprecated:DeprecationWarning:defusedxml", +] diff --git a/sdpm/sdpm/engine/converter/constants.py b/sdpm/sdpm/engine/converter/constants.py index 6e8a2855..103da021 100644 --- a/sdpm/sdpm/engine/converter/constants.py +++ b/sdpm/sdpm/engine/converter/constants.py @@ -6,28 +6,45 @@ import xml.etree.ElementTree as ET +from contextlib import contextmanager +from contextvars import ContextVar _NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main', 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'} +# Default scale: EMU per px on the 1920px basis of a standard 16:9 deck +# (12192000 EMU / 1920 px). Kept as a constant for import compatibility; +# converter internals must call get_emu_per_px() instead, which resolves +# the scale of the *current* conversion scope. EMU_PER_PX = 6350 +# Current conversion scale. A ContextVar — not a module global — so nested +# and concurrent conversions are isolated per execution context, and no +# non-standard scale can leak out of a conversion. +_CURRENT_EMU_PER_PX: ContextVar[float] = ContextVar( + "converter_emu_per_px", default=float(EMU_PER_PX)) -def set_emu_per_px(slide_width_emu): - """Set EMU_PER_PX based on actual slide width. Call before extraction.""" - import sdpm.engine.converter.constants as _c - _c.EMU_PER_PX = slide_width_emu / 1920 - # Update all modules that imported EMU_PER_PX - for mod_name in ('sdpm.engine.converter.elements', 'sdpm.engine.converter.slide', - 'sdpm.engine.converter.xml_helpers', 'sdpm.engine.converter.text', - 'sdpm.engine.converter.table', 'sdpm.engine.converter.chart', - 'sdpm.engine.converter.pipeline', 'sdpm.engine.converter'): - import sys - mod = sys.modules.get(mod_name) - if mod and hasattr(mod, 'EMU_PER_PX'): - mod.EMU_PER_PX = _c.EMU_PER_PX + +def get_emu_per_px() -> float: + """EMU-per-px scale of the current conversion scope (default 6350.0).""" + return _CURRENT_EMU_PER_PX.get() + + +@contextmanager +def conversion_scale(slide_width_emu): + """Scope the converter's px scale to ``slide_width_emu / 1920``. + + The previous scale is always restored on exit — normal return, + exception, or nested use — so error paths and reentrant conversions + cannot poison later conversions in the same process. + """ + token = _CURRENT_EMU_PER_PX.set(slide_width_emu / 1920) + try: + yield + finally: + _CURRENT_EMU_PER_PX.reset(token) def _serialize_lstStyle(source): """Extract lstStyle XML string from a shape/element with text frame. Returns XML string or None.""" @@ -63,25 +80,27 @@ def _hex(el): def _position_diff(shape, layout_ph): """Return dict of _x/_y/_width/_height where shape differs from layout placeholder.""" + emu_per_px = get_emu_per_px() diff = {} if shape.left != layout_ph.left: - diff["_x"] = round(shape.left / EMU_PER_PX) + diff["_x"] = round(shape.left / emu_per_px) if shape.top != layout_ph.top: - diff["_y"] = round(shape.top / EMU_PER_PX) + diff["_y"] = round(shape.top / emu_per_px) if shape.width != layout_ph.width: - diff["_width"] = round(shape.width / EMU_PER_PX) + diff["_width"] = round(shape.width / emu_per_px) if shape.height != layout_ph.height: - diff["_height"] = round(shape.height / EMU_PER_PX) + diff["_height"] = round(shape.height / emu_per_px) return diff def _base_element(shape, type_name, **extra): """Create base element dict with position, size, rotation.""" + emu_per_px = get_emu_per_px() elem = { "type": type_name, - "x": round(shape.left / EMU_PER_PX), - "y": round(shape.top / EMU_PER_PX), - "width": round(shape.width / EMU_PER_PX), - "height": round(shape.height / EMU_PER_PX), + "x": round(shape.left / emu_per_px), + "y": round(shape.top / emu_per_px), + "width": round(shape.width / emu_per_px), + "height": round(shape.height / emu_per_px), **extra, } if shape.rotation != 0: diff --git a/sdpm/sdpm/engine/converter/elements.py b/sdpm/sdpm/engine/converter/elements.py deleted file mode 100644 index 7ac9fcd1..00000000 --- a/sdpm/sdpm/engine/converter/elements.py +++ /dev/null @@ -1,1519 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: MIT-0 -"""Element extraction (shape, textbox, line, freeform, picture, group).""" -import json -import sys -from pathlib import Path - -from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE - -from .constants import _NS, EMU_PER_PX, _base_element, _add_flip, _serialize_lstStyle, _hex -from .color import _resolve_color_with_transforms -from .xml_helpers import (extract_line_dash, _resolve_line_from_style, _extract_fill_from_xml, - _extract_line_from_xml, _extract_effects_from_xml, _extract_visual_effects) -from .text import _extract_styled_text, _detect_font_size, _get_alignment, _extract_shape_text - -_SHAPE_MAP = { - MSO_SHAPE.RECTANGLE: "rectangle", MSO_SHAPE.ROUNDED_RECTANGLE: "rounded_rectangle", - MSO_SHAPE.OVAL: "oval", MSO_SHAPE.RIGHT_ARROW: "arrow_right", - MSO_SHAPE.LEFT_ARROW: "arrow_left", MSO_SHAPE.UP_ARROW: "arrow_up", - MSO_SHAPE.DOWN_ARROW: "arrow_down", MSO_SHAPE.ISOSCELES_TRIANGLE: "triangle", - MSO_SHAPE.DIAMOND: "diamond", MSO_SHAPE.PENTAGON: "pentagon", - MSO_SHAPE.HEXAGON: "hexagon", MSO_SHAPE.CHEVRON: "chevron", - MSO_SHAPE.RIGHT_BRACE: "right_brace", MSO_SHAPE.LEFT_BRACE: "left_brace", - 60: "arrow_circular", -} -_PRESET_MAP = { - 'roundRect': 'rounded_rectangle', 'rect': 'rectangle', 'ellipse': 'oval', - 'triangle': 'triangle', 'diamond': 'diamond', 'pentagon': 'pentagon', - 'hexagon': 'hexagon', 'chevron': 'chevron', 'homePlate': 'pentagon', - 'heart': 'heart', 'cloud': 'cloud', 'lightningBolt': 'lightning_bolt', - 'star5': 'star_5_point', 'noSmoking': 'no_symbol', 'cross': 'cross', 'plus': 'cross', - 'trapezoid': 'trapezoid', 'parallelogram': 'parallelogram', - 'donut': 'donut', 'arc': 'arc', 'blockArc': 'block_arc', 'chord': 'chord', - 'pie': 'pie', 'pieWedge': 'pie_wedge', - 'leftRightArrow': 'arrow_left_right', 'upDownArrow': 'arrow_up_down', - 'curvedRightArrow': 'arrow_curved_right', 'curvedLeftArrow': 'arrow_curved_left', - 'curvedUpArrow': 'arrow_curved_up', 'curvedDownArrow': 'arrow_curved_down', - 'circularArrow': 'arrow_circular', 'leftCircularArrow': 'arrow_circular_left', - 'leftRightCircularArrow': 'arrow_circular_left_right', - 'calloutRoundRect': 'callout_rounded_rectangle', 'wedgeRoundRectCallout': 'callout_rounded_rectangle', - 'calloutRect': 'callout_rectangle', 'wedgeRectCallout': 'callout_rectangle', - 'calloutEllipse': 'callout_oval', 'wedgeEllipseCallout': 'callout_oval', - 'flowChartProcess': 'flowchart_process', 'flowChartDecision': 'flowchart_decision', - 'flowChartTerminator': 'flowchart_terminator', - 'leftBracket': 'left_bracket', 'rightBracket': 'right_bracket', - 'can': 'cylinder', 'mathNotEqual': 'math_not_equal', -} - -def _resolve_shape_name(shape): - """Resolve shape preset name from python-pptx or XML.""" - if shape.shape_type == 5: # MSO_SHAPE_TYPE.FREEFORM - return "rounded_rectangle" - name = None - try: - if hasattr(shape, 'auto_shape_type') and shape.auto_shape_type in _SHAPE_MAP: - name = _SHAPE_MAP[shape.auto_shape_type] - except Exception: - pass - if not name: - try: - prst = shape._element.spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') - if prst is not None: - prst_val = prst.get('prst') - name = _PRESET_MAP.get(prst_val, prst_val) # Use raw prst value as fallback - except Exception: - pass - if name == "oval" and shape.width == shape.height: - return "circle" - return name or "rounded_rectangle" - -def extract_line_element(shape, theme_colors=None, color_mapping=None, theme_styles=None): - """Extract line/connector as element dict.""" - try: - # Build x1/y1/x2/y2 from bounding box + flip - x = round(shape.left / EMU_PER_PX) - y = round(shape.top / EMU_PER_PX) - w = round(shape.width / EMU_PER_PX) - h = round(shape.height / EMU_PER_PX) - x1, y1, x2, y2 = x, y, x + w, y + h - - # 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') - if xfrm is not None: - if xfrm.get('flipH') == '1': - 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} - - # Extract connector type from XML - try: - sp_pr = shape._element.spPr - prst_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') - if prst_geom is not None: - prst = prst_geom.get('prst') - if prst: - # Save exact preset type - elem["preset"] = prst - - # Map to general connector type - if 'straight' in prst.lower(): - 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" - - # Extract adjustments - av_lst = prst_geom.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}avLst') - if av_lst is not None: - adjustments = [] - for gd in av_lst.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/main}gd'): - fmla = gd.get('fmla', '') - if fmla.startswith('val '): - adj_val = int(fmla.split()[1]) - adjustments.append(adj_val / 100000.0) - if adjustments: - elem["adjustments"] = adjustments - except Exception: - elem["connectorType"] = "straight" # default - - # Extract arrow heads from XML - try: - ln = shape._element.spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}ln') - if ln is not None: - head_end = ln.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}headEnd') - if head_end is not None: - head_type = head_end.get('type') - if head_type: - elem["arrowStart"] = head_type - - tail_end = ln.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}tailEnd') - if tail_end is not None: - tail_type = tail_end.get('type') - if tail_type: - elem["arrowEnd"] = tail_type - except Exception: - pass - - # Extract line color or gradient (use XML helper) - sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') - line_info = _extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping) - if "line" in line_info and line_info["line"] != "none": - elem["color"] = line_info["line"] - elif line_info.get("line") == "none": - elem["color"] = "none" - else: - # Resolve from style reference - style_info = _resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles) - if style_info.get("line"): - elem["color"] = style_info["line"] - if style_info.get("lineWidth"): - elem["lineWidth"] = style_info["lineWidth"] - if "lineGradient" in line_info: - elem["lineGradient"] = line_info["lineGradient"] - if "lineWidth" in line_info: - elem["lineWidth"] = line_info["lineWidth"] - - # Extract dash style - dash = extract_line_dash(shape) - if dash: - elem["dashStyle"] = dash - - # No effects in source → say so explicitly (same rule as shapes). - # python-pptx's add_connector default has effectRef idx=1 - # (theme shadow), which painted a shadow under plain lines. - 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 - has_own_effects = False - sp_pr_el = shape._element.find(f'{{{_NS["p"]}}}spPr') - if sp_pr_el is not None: - eff_lst = sp_pr_el.find(f'{{{_NS["a"]}}}effectLst') - has_own_effects = eff_lst is not None and len(eff_lst) > 0 - if not has_own_effects and ( - eff_ref is None or int(eff_ref.get('idx', '0') or 0) == 0): - elem["_noEffects"] = True - except Exception: - pass - - return elem - except Exception as e: - print(f"Warning: Failed to extract line: {e}", file=sys.stderr) - return None - -def extract_freeform_element(shape, theme_colors=None, color_mapping=None, builder_text_color=None): - """Extract freeform/curve shape as element dict with path commands in px.""" - try: - sp_pr = shape._element.spPr - cust_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom') - if cust_geom is None: - return None - - elem = _base_element(shape, "freeform") - _add_flip(elem, shape) - # Preserve exact EMU size for freeform roundtrip fidelity - elem["_widthEmu"] = shape.width - elem["_heightEmu"] = shape.height - - # Extract all paths - path_elements = cust_geom.findall('.//a:pathLst/a:path', _NS) - if not path_elements: - return None - - def _extract_path_commands(path_el): - """Extract commands from a single path element.""" - path_w = int(path_el.get('w', shape.width)) - path_h = int(path_el.get('h', shape.height)) - sx = shape.width / path_w if path_w else 1 - sy = shape.height / path_h if path_h else 1 - - def to_px(x, y): - return round(int(x) * sx / EMU_PER_PX, 1), round(int(y) * sy / EMU_PER_PX, 1) - - path = [] - for child in path_el: - tag = child.tag.split('}')[-1] - pts = child.findall('a:pt', _NS) - if tag == 'moveTo' and pts: - px, py = to_px(pts[0].get('x'), pts[0].get('y')) - path.append({"cmd": "M", "x": px, "y": py}) - elif tag == 'lnTo' and pts: - px, py = to_px(pts[0].get('x'), pts[0].get('y')) - path.append({"cmd": "L", "x": px, "y": py}) - elif tag == 'cubicBezTo' and len(pts) == 3: - coords = [to_px(p.get('x'), p.get('y')) for p in pts] - path.append({"cmd": "C", "pts": [[c[0], c[1]] for c in coords]}) - elif tag == 'quadBezTo' and len(pts) == 2: - coords = [to_px(p.get('x'), p.get('y')) for p in pts] - path.append({"cmd": "Q", "pts": [[c[0], c[1]] for c in coords]}) - elif tag == 'arcTo': - wR = int(child.get('wR', 0)) - hR = int(child.get('hR', 0)) - stAng = int(child.get('stAng', 0)) - swAng = int(child.get('swAng', 0)) - path.append({ - "cmd": "A", - "wR": round(wR * sx / EMU_PER_PX, 1), - "hR": round(hR * sy / EMU_PER_PX, 1), - "stAng": round(stAng / 60000, 2), - "swAng": round(swAng / 60000, 2), - }) - elif tag == 'close': - path.append({"cmd": "Z"}) - return path - - if len(path_elements) == 1: - # Single path → "path" key (backward compatible) - elem["path"] = _extract_path_commands(path_elements[0]) - fill_attr = path_elements[0].get('fill') - if fill_attr and fill_attr != 'norm': - elem["pathFill"] = fill_attr - else: - # Multiple paths → "paths" key - paths = [] - for pe in path_elements: - p = {"commands": _extract_path_commands(pe)} - fill_attr = pe.get('fill') - if fill_attr and fill_attr != 'norm': - p["fill"] = fill_attr - paths.append(p) - elem["paths"] = paths - - # Preserve raw pathLst XML for lossless roundtrip - path_el_first = path_elements[0] - path_w = int(path_el_first.get('w', shape.width)) - path_h = int(path_el_first.get('h', shape.height)) - if path_w == shape.width and path_h == shape.height: - from lxml import etree as _et - pathLst = cust_geom.find('.//a:pathLst', _NS) - if pathLst is not None: - elem["_pathLstXml"] = _et.tostring(pathLst, encoding='unicode') - - # Fill - elem.update(_extract_fill_from_xml(sp_pr, theme_colors, color_mapping)) - - # Line - line_info = _extract_line_from_xml(sp_pr, theme_colors, color_mapping) - elem.update(line_info) - - # Line opacity - ln = sp_pr.find(f'.//{{{_NS["a"]}}}ln') - if ln is not None: - solid = ln.find(f'{{{_NS["a"]}}}solidFill') - if solid is not None: - for clr_tag in ('srgbClr', 'schemeClr'): - clr = solid.find(f'{{{_NS["a"]}}}{clr_tag}') - if clr is not None: - alpha = clr.find(f'{{{_NS["a"]}}}alpha') - if alpha is not None: - elem["lineOpacity"] = round(int(alpha.get('val')) / 100000, 2) - break - - # Arrow heads - try: - if ln is not None: - for attr, tag in [("headEnd", "headEnd"), ("tailEnd", "tailEnd")]: - el = ln.find(f'{{{_NS["a"]}}}{tag}') - if el is not None and el.get('type'): - elem[attr] = el.get('type') - except Exception: - pass - - # Effects - elem.update(_extract_visual_effects(sp_pr, theme_colors, color_mapping)) - - # Text (if freeform contains text) - if shape.has_text_frame and shape.text_frame.text.strip(): - _extract_shape_text(shape, elem, theme_colors, color_mapping, builder_text_color=builder_text_color) - - return elem - except Exception as e: - print(f"Warning: Failed to extract freeform: {e}", file=sys.stderr) - return None - -def extract_shape_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, builder_text_color=None): - """Extract shape as element dict.""" - try: - elem = { - "type": "shape", - "x": round(shape.left / EMU_PER_PX), - "y": round(shape.top / EMU_PER_PX), - "width": round(shape.width / EMU_PER_PX), - "height": round(shape.height / EMU_PER_PX), - "shape": _resolve_shape_name(shape) - } - _add_flip(elem, shape) - - # Style references - style_fill_idx = None - style_fill_color = None - try: - style = shape._element.find(f'{{{_NS["p"]}}}style') - if style is not None: - fill_ref = style.find(f'{{{_NS["a"]}}}fillRef') - if fill_ref is not None: - style_fill_idx = int(fill_ref.get('idx', 0)) - sc = fill_ref.find(f'{{{_NS["a"]}}}schemeClr') - if sc is not None: - style_fill_color = sc.get('val') - except Exception: - pass - - # Rotation - if shape.rotation != 0: - elem["rotation"] = round(shape.rotation, 1) - - # Adjustments (only if explicitly set in XML avLst) - try: - sp_pr = shape._element.spPr - prst_geom = sp_pr.find(f'{{{_NS["a"]}}}prstGeom') - if prst_geom is not None: - av_lst = prst_geom.find(f'{{{_NS["a"]}}}avLst') - if av_lst is not None and len(av_lst) > 0: - adjs = [] - for gd in av_lst.findall(f'{{{_NS["a"]}}}gd'): - fmla = gd.get('fmla', '') - if fmla.startswith('val '): - adjs.append(round(int(fmla.split()[1]) / 100000, 5)) - prst_name = prst_geom.get('prst') - if prst_name == 'arc' and len(adjs) >= 2: - # Raw adj are angles in 60000ths of a degree, but the - # builder's arc API is [startDeg, sweepDeg] — feeding - # raw values drew a 353° ring as ~40%. - start_deg = round(adjs[0] * 100000 / 60000, 3) - end_deg = round(adjs[1] * 100000 / 60000, 3) - sweep = round((end_deg - start_deg) % 360, 3) - adjs = [start_deg, sweep] - if adjs: - elem["adjustments"] = adjs - except Exception: - pass - - # Extract fill and line from XML - sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') - elem.update(_extract_visual_effects(sp_pr_xml, theme_colors, color_mapping)) - - # Fill (XML first, python-pptx API fallback for style references) - try: - fill_info = _extract_fill_from_xml(sp_pr_xml, theme_colors, color_mapping) - # Check if spPr has explicit - has_explicit_no_fill = sp_pr_xml is not None and sp_pr_xml.find(f'{{{_NS["a"]}}}noFill') is not None - if fill_info.get("fill") != "none" or "gradient" in fill_info or "patternFill" in fill_info: - elem.update(fill_info) - elif has_explicit_no_fill: - elem["fill"] = "none" - else: - if shape.fill.type == 1: # SOLID - if shape.fill.fore_color.type == 1: # RGB - rgb = shape.fill.fore_color.rgb - elem["fill"] = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" - elif shape.fill.fore_color.type == 2: # SCHEME - theme_color = shape.fill.fore_color.theme_color - if theme_colors and theme_color in theme_colors: - elem["fill"] = theme_colors[theme_color] - alpha_el = sp_pr_xml.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') if sp_pr_xml is not None else None - if alpha_el is not None: - elem["opacity"] = round(int(alpha_el.get('val', 100000)) / 1000, 1) - elif shape.fill.type == 3: # GRADIENT - try: - stops = [] - for stop in shape.fill.gradient_stops: - s = {"position": round(stop.position, 3)} - if stop.color.type == 1: - rgb = stop.color.rgb - s["color"] = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" - elif stop.color.type == 2 and theme_colors and stop.color.theme_color in theme_colors: - s["color"] = theme_colors[stop.color.theme_color] - if "color" in s: - stops.append(s) - if stops: - angle = 0 - try: - # python-pptx returns CCW angle; convert to CW (PowerPoint UI) - ccw = round(shape.fill.gradient_angle, 1) - angle = round((360 - ccw) % 360, 1) - except Exception: - pass - elem["gradient"] = {"stops": stops, "angle": angle} - except Exception: - pass - elif shape.fill.type is None or shape.fill.type == 0 or shape.fill.type == 5: - # Resolve from style fillRef (unless useBgFill=1) - use_bg = shape._element.get('useBgFill') == '1' - if not use_bg and style_fill_idx and style_fill_idx > 0 and style_fill_color and theme_styles and theme_styles.get("fill"): - fill_idx = style_fill_idx - 1 - if 0 <= fill_idx < len(theme_styles["fill"]): - from lxml import etree as _et - fill_xml = _et.fromstring(theme_styles["fill"][fill_idx]) - scheme = fill_xml.find(f'.//{{{_NS["a"]}}}schemeClr') - if scheme is not None and scheme.get('val') == 'phClr': - resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping, override_scheme=style_fill_color) - if resolved: - elem["fill"] = resolved - if "fill" not in elem: - elem["fill"] = "none" - except Exception: - pass - - # Line (XML first, style reference fallback) - try: - line_info = _extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping) - ln_xml = sp_pr_xml.find('a:ln', _NS) if sp_pr_xml is not None else None - if line_info.get("line") not in (None, "none") or "lineGradient" in line_info: - # If lineWidth missing, try style reference - if "lineWidth" not in line_info: - style_info = _resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles) - if style_info.get("lineWidth"): - line_info["lineWidth"] = style_info["lineWidth"] - elem.update(line_info) - elif ln_xml is not None and len(ln_xml) > 0: - elem.update(line_info) # ln exists with noFill or explicit content - else: - elem.update(_resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles)) - dash = extract_line_dash(shape) - if dash: - elem["dashStyle"] = dash - # Arrow heads - if ln_xml is not None: - for attr, tag in [("headEnd", "headEnd"), ("tailEnd", "tailEnd")]: - el = ln_xml.find(f'{{{_NS["a"]}}}{tag}') - if el is not None and el.get('type') and el.get('type') != 'none': - elem[attr] = el.get('type') - except Exception: - if "line" not in elem and "lineGradient" not in elem: - elem["line"] = "none" - if "line" not in elem and "lineGradient" not in elem: - elem["line"] = "none" - - # Extract text with styles - if shape.has_text_frame and shape.text.strip(): - _extract_shape_text(shape, elem, theme_colors, color_mapping, builder_text_color=builder_text_color) - - # Extract hyperlink - try: - if hasattr(shape, 'click_action') and shape.click_action.hyperlink and shape.click_action.hyperlink.address: - elem["link"] = shape.click_action.hyperlink.address - else: - # Remove null link - if "link" in elem and elem["link"] is None: - del elem["link"] - except Exception: - pass - - # 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 - if _lst: - elem["_lstStyle"] = _lst - - return elem - except Exception as e: - print(f"Warning: Failed to extract shape details: {e}", file=sys.stderr) - return None - -def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, is_placeholder=False, builder_text_color=None): - """Extract textbox as element dict.""" - # Check if it's actually a shape with preset geometry (not a plain textbox) - try: - sp_pr = shape._element.spPr - prst_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') - if prst_geom is not None: - prst = prst_geom.get('prst') - # If it has any preset geometry (not just 'rect'), treat as shape - if prst and prst != 'rect': - # This is a shape with text, not a plain textbox - return extract_shape_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) - except Exception: - pass - - elem = { - "type": "textbox", - "x": round(shape.left / EMU_PER_PX), # px (1920x1080 basis) - "y": round(shape.top / EMU_PER_PX), - "width": round(shape.width / EMU_PER_PX), - } - - # Extract height (for TEXT_TO_FIT_SHAPE auto-shrink) - if shape.height: - h_px = round(shape.height / EMU_PER_PX) - if h_px > 10: - elem["height"] = h_px - # Extract rotation - if shape.rotation != 0: - elem["rotation"] = round(shape.rotation, 1) - - # Extract flip - _add_flip(elem, shape) - - # Extract autoWidth - try: - body_pr = shape._element.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}bodyPr') - if body_pr is not None: - if body_pr.get('wrap') == 'none': - elem["autoWidth"] = True - vert = body_pr.get('vert') - if vert: - elem["textDirection"] = vert - except Exception: - pass - - # Extract margins (EMU → px) - # Builder default for textbox: left/right=0, top/bottom=PowerPoint default - tf = shape.text_frame - if tf.margin_left is not None and tf.margin_left != 0: - elem["marginLeft"] = round(tf.margin_left / EMU_PER_PX) - if tf.margin_top is not None and tf.margin_top != 45720: - elem["marginTop"] = round(tf.margin_top / EMU_PER_PX) - if tf.margin_right is not None and tf.margin_right != 0: - elem["marginRight"] = round(tf.margin_right / EMU_PER_PX) - 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') - elem.update(_extract_fill_from_xml(sp_pr_xml, theme_colors, color_mapping)) - elem.update(_extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping)) - elem.update(_extract_visual_effects(sp_pr_xml, theme_colors, color_mapping)) - except Exception: - pass - - # Extract textGradient from runs with gradFill - try: - grad_runs = [] - for para in shape.text_frame.paragraphs: - for run in para.runs: - rpr = run._r.find(f'{{{_NS["a"]}}}rPr') - if rpr is not None: - grad = rpr.find(f'{{{_NS["a"]}}}gradFill') - if grad is not None: - stops = [] - for gs in grad.findall(f'.//{{{_NS["a"]}}}gs'): - pos = round(int(gs.get('pos', '0')) / 100000, 2) - srgb = gs.find(f'{{{_NS["a"]}}}srgbClr') - if srgb is not None: - stops.append({"position": pos, "color": _hex(srgb)}) - if stops: - angle = 0 - lin = grad.find(f'{{{_NS["a"]}}}lin') - if lin is not None: - angle = round(int(lin.get('ang', '0')) / 60000) - grad_runs.append({"text": run.text, "gradient": {"angle": angle, "stops": stops}}) - if grad_runs: - # Count total runs with text - total_runs = sum(1 for p in shape.text_frame.paragraphs for r in p.runs if r.text.strip()) - grads = [json.dumps(gr["gradient"], sort_keys=True) for gr in grad_runs] - # Promote to textGradient only if ALL runs have the same gradient - if len(set(grads)) == 1 and len(grad_runs) >= total_runs: - elem["textGradient"] = grad_runs[0]["gradient"] - else: - 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] - if _all_runs: - if all(r._r.find(f'{{{_NS["a"]}}}rPr') is not None and - r._r.find(f'{{{_NS["a"]}}}rPr').get('cap') == 'none' - for r in _all_runs): - elem["_capNone"] = True - if all(r._r.find(f'{{{_NS["a"]}}}rPr') is not None and - r._r.find(f'{{{_NS["a"]}}}rPr').get('b') == '0' - for r in _all_runs): - elem["_boldOff"] = True - except Exception: - pass - - # Extract text with styles - text_parts = [] - default_font_size = None - - # Determine default text color (must match builder's theme_colors["text"]) - # For placeholders, don't set default_text_color — lstStyle defines the actual default - default_text_color = None - if not is_placeholder: - default_text_color = builder_text_color - if not default_text_color and color_mapping and theme_colors: - tx1_mapped = color_mapping.get('tx1', 'dk1') - default_text_color = theme_colors.get(tx1_mapped) - - # Check if multiple paragraphs (should be items array) - paragraphs_with_text = [p for p in shape.text_frame.paragraphs if p.text.strip()] - all_paragraphs = list(shape.text_frame.paragraphs) - has_lstStyle = _serialize_lstStyle(shape) is not None - - if len(all_paragraphs) > 1: - # Multiple paragraphs - extract as paragraphs with bullet info - default_font_size = None if (is_placeholder or has_lstStyle) else _detect_font_size(all_paragraphs) - paragraphs = [] - for paragraph in all_paragraphs: - - # Empty paragraph - if not paragraph.text.strip(): - paragraphs.append({"text": ""}) - continue - - # Check for bullet or numbering - has_bullet = False - numbering_type = None - bu_font = None - mar_l = None - indent = None - space_after = None - space_before = None - line_spacing = None - try: - pPr = paragraph._element.pPr - if pPr is not None: - bu_auto_num = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buAutoNum') - bu_char = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buChar') - - if bu_auto_num is not None: - numbering_type = bu_auto_num.get('type', 'arabicPeriod') - elif bu_char is not None: - has_bullet = True - - bu_font_elem = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buFont') - if bu_font_elem is not None: - bu_font = bu_font_elem.get('typeface') - mar_l = pPr.get('marL') - indent = pPr.get('indent') - spc_aft = pPr.find('.//a:spcAft/a:spcPts', _NS) - if spc_aft is not None: - space_after = spc_aft.get('val') - spc_bef = pPr.find('.//a:spcBef/a:spcPts', _NS) - if spc_bef is not None: - space_before = spc_bef.get('val') - ln_spc = pPr.find('.//a:lnSpc/a:spcPts', _NS) - if ln_spc is not None: - line_spacing = ('pts', ln_spc.get('val')) - else: - ln_spc_pct = pPr.find('.//a:lnSpc/a:spcPct', _NS) - if ln_spc_pct is not None: - line_spacing = ('pct', ln_spc_pct.get('val')) - except Exception: - pass - - item_text = _extract_styled_text(paragraph.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, is_placeholder=is_placeholder, paragraph=paragraph) - para_info = {"text": item_text} - # Explicit paragraph alignment — without it a shape-level - # lstStyle default (e.g. centered) silently wins. - _algn = _get_alignment(paragraph) - if _algn: - para_info["align"] = _algn - if has_bullet or numbering_type: - list_def = {} - if numbering_type: - list_def["type"] = numbering_type - else: - list_def["type"] = "disc" - level = paragraph.level if paragraph.level else 0 - if level > 0: - list_def["level"] = level - para_info["list"] = list_def - if bu_font: - para_info["buFont"] = bu_font - if mar_l is not None: - para_info["marL"] = int(mar_l) - if indent is not None: - para_info["indent"] = int(indent) - if space_after is not None: - para_info["spaceAfter"] = int(space_after) - if space_before is not None: - para_info["spaceBefore"] = int(space_before) - if line_spacing: - if line_spacing[0] == 'pct': - para_info["lineSpacingPct"] = int(line_spacing[1]) - else: - para_info["lineSpacing"] = int(line_spacing[1]) - - # Paragraph level (for sub-bullets) - try: - pPr = paragraph._element.pPr - if pPr is not None: - lvl = pPr.get('lvl') - if lvl and lvl != '0': - para_info["level"] = int(lvl) - except Exception: - pass - - paragraphs.append(para_info) - - if paragraphs: - elem["paragraphs"] = paragraphs - - # Add fontSize if not default - if default_font_size and default_font_size != 18: - elem["fontSize"] = default_font_size - - # Get alignment - per paragraph if mixed, top-level if uniform - aligns = [_get_alignment(p) for p in paragraphs_with_text] - unique = set(a for a in aligns if a) - if len(unique) <= 1: - align = aligns[0] if aligns else None - if align and align != "left": - elem["align"] = align - else: - # Mixed alignment: set per-paragraph - for para_info, paragraph in zip(paragraphs, shape.text_frame.paragraphs): - a = _get_alignment(paragraph) - if a: - para_info["align"] = a - - # Preserve lstStyle for roundtrip fidelity - _lst = _serialize_lstStyle(shape) if shape.has_text_frame else None - if _lst: - elem["_lstStyle"] = _lst - - # Extract character spacing - _spc_vals = set() - for _p in shape.text_frame.paragraphs: - for _r in _p.runs: - _rPr = _r._r.find('{http://schemas.openxmlformats.org/drawingml/2006/main}rPr') - _s = _rPr.get('spc') if _rPr is not None else None - if _s: - _spc_vals.add(int(_s)) - if len(_spc_vals) == 1: - elem["_spc"] = _spc_vals.pop() - - return elem - - # Single paragraph - extract as text - default_font_size = None if (is_placeholder or has_lstStyle) else _detect_font_size(shape.text_frame.paragraphs) - for paragraph in shape.text_frame.paragraphs: - text_parts.append(_extract_styled_text(paragraph.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, is_placeholder=is_placeholder, paragraph=paragraph)) - - elem["text"] = ''.join(text_parts) - - # endParaRPr pins the paragraph line height (e.g. a full-size 80pt - # endParaRPr next to a baseline-shrunk run keeps the line tall; - # dropping it shifts the text up within the box). - if shape.text_frame.paragraphs: - _last_p = shape.text_frame.paragraphs[-1] - _endPr = _last_p._element.find(f'{{{_NS["a"]}}}endParaRPr') - if _endPr is not None and _endPr.get('sz'): - _end_sz = int(_endPr.get('sz')) / 100 - _last_runs = _last_p.runs - _last_run_sz = (_last_runs[-1].font.size.pt - if _last_runs and _last_runs[-1].font.size else None) - _has_baseline = any( - (r._r.find(f'{{{_NS["a"]}}}rPr') is not None - and r._r.find(f'{{{_NS["a"]}}}rPr').get('baseline')) - for r in _last_runs) - if _has_baseline or (_last_run_sz is not None and _end_sz != _last_run_sz): - elem["_endParaSize"] = _end_sz - - # Extract indent/marL from first paragraph - if shape.text_frame.paragraphs: - from pptx.oxml.ns import qn as _qn - pPr = shape.text_frame.paragraphs[0]._element.find(_qn('a:pPr')) - if pPr is not None: - _indent = pPr.get('indent') - if _indent is not None: - elem["indent"] = int(_indent) - _marL = pPr.get('marL') - if _marL is not None: - elem["marL"] = int(_marL) - - # Add fontSize if consistent - if default_font_size: - elem["fontSize"] = default_font_size - - # Detect alignment - if shape.text_frame.paragraphs: - align = _get_alignment(shape.text_frame.paragraphs[0]) - if align: - elem["align"] = align - # Line spacing from first paragraph - pPr = shape.text_frame.paragraphs[0]._element.find('{http://schemas.openxmlformats.org/drawingml/2006/main}pPr') - if pPr is not None: - 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: - sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') - if sp_pr_xml is None: - sp_pr_xml = shape._element.spPr if hasattr(shape._element, 'spPr') else None - elem.update(_extract_effects_from_xml(sp_pr_xml, theme_colors, color_mapping)) - except Exception: - pass - - # Preserve lstStyle for roundtrip fidelity - _lst = _serialize_lstStyle(shape) if shape.has_text_frame else None - if _lst: - elem["_lstStyle"] = _lst - - # Extract character spacing (spc) if uniform across all runs - if shape.has_text_frame: - spc_values = set() - for p in shape.text_frame.paragraphs: - for r in p.runs: - rPr = r._r.find('{http://schemas.openxmlformats.org/drawingml/2006/main}rPr') - spc = rPr.get('spc') if rPr is not None else None - if spc: - spc_values.add(int(spc)) - if len(spc_values) == 1: - elem["_spc"] = spc_values.pop() - - return elem - -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.""" - from pptx.oxml.ns import qn as _qn - elem = _base_element(shape, "video") - try: - r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' - nvPr = shape._element.find(f'{_qn("p:nvPicPr")}/{_qn("p:nvPr")}') - if nvPr is None: - return None - videoFile = nvPr.find(_qn('a:videoFile')) - if videoFile is None: - return None - - # Save video file - r_link = videoFile.get(f'{{{r_ns}}}link') - if r_link and output_dir: - slide_part = shape.part - rel = slide_part.rels[r_link] - ext = rel.target_ref.split('.')[-1] or 'mp4' - video_name = f"slide{slide_idx+1}_video{img_idx+1}.{ext}" - media_dir = Path(output_dir) / "media" - media_dir.mkdir(exist_ok=True) - # Get blob via p14:media embed (more reliable) - p14_ns = 'http://schemas.microsoft.com/office/powerpoint/2010/main' - media_el = nvPr.find(f'.//{{{p14_ns}}}media') - if media_el is not None: - r_embed = media_el.get(f'{{{r_ns}}}embed') - if r_embed: - (media_dir / video_name).write_bytes(slide_part.rels[r_embed].target_part.blob) - elem["src"] = f"media/{video_name}" - - # Save poster image - blip = shape._element.find(f'{_qn("p:blipFill")}/{_qn("a:blip")}') - if blip is not None and output_dir: - r_embed = blip.get(f'{{{r_ns}}}embed') - if r_embed: - poster_part = shape.part.rels[r_embed].target_part - poster_ext = poster_part.content_type.split('/')[-1].replace('jpeg', 'jpg') - poster_name = f"slide{slide_idx+1}_poster{img_idx+1}.{poster_ext}" - images_dir = Path(output_dir) / "images" - images_dir.mkdir(exist_ok=True) - (images_dir / poster_name).write_bytes(poster_part.blob) - elem["poster"] = f"images/{poster_name}" - except Exception as e: - print(f"Warning: Failed to extract video: {e}", file=sys.stderr) - 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' - 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_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, theme_colors=None, color_mapping=None): - """Extract picture as element dict and save image file.""" - elem = _base_element(shape, "image") - - # 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 - if output_dir: - try: - image = shape.image - image_bytes = image.blob - - # Determine format - ext = shape.image.ext or "png" - - # Create images directory - images_dir = Path(output_dir) / "images" - images_dir.mkdir(exist_ok=True) - - # Save image - image_filename = f"slide{slide_idx + 1}_image{img_idx + 1}.{ext}" - image_path = images_dir / image_filename - - with open(image_path, 'wb') as f: - f.write(image_bytes) - - # Store relative path - elem["src"] = f"images/{image_filename}" - except Exception as e: - print(f"Warning: Failed to save image: {e}", file=sys.stderr) - - # 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 - # NOT copy _originalEffects — this prevents unintended mask/crop/color changes. - # To faithfully reproduce the original slide, spread _originalEffects into the - # element: { ...elem, ...elem._originalEffects }. - try: - effects: dict = {} - pic_el = shape._element - sp_pr = pic_el.find(f'{{{_NS["p"]}}}spPr') - if sp_pr is not None: - # Mask (prstGeom != rect) - prst_geom = sp_pr.find(f'{{{_NS["a"]}}}prstGeom') - if prst_geom is not None: - prst = prst_geom.get('prst') - mask_rmap = {"ellipse": "circle", "roundRect": "rounded_rectangle", "hexagon": "hexagon", "diamond": "diamond", "triangle": "triangle", "pentagon": "pentagon", "star5": "star_5_point", "heart": "heart", "trapezoid": "trapezoid"} - if prst and prst != 'rect' and prst in mask_rmap: - effects["mask"] = mask_rmap[prst] - # Visual effects - effects.update(_extract_visual_effects(sp_pr, theme_colors, color_mapping)) - - blip_fill = pic_el.find(f'{{{_NS["p"]}}}blipFill') - if blip_fill is not None: - # Crop - src_rect = blip_fill.find(f'{{{_NS["a"]}}}srcRect') - if src_rect is not None: - crop = {} - for side in ('l', 't', 'r', 'b'): - v = src_rect.get(side) - if v and int(v) != 0: - key = {"l": "left", "t": "top", "r": "right", "b": "bottom"}[side] - crop[key] = int(v) / 1000 - if crop: - effects["crop"] = crop - # Brightness/Contrast/Saturation - blip = blip_fill.find(f'{{{_NS["a"]}}}blip') - if blip is not None: - lum = blip.find(f'{{{_NS["a"]}}}lum') - if lum is not None: - b = lum.get('bright') - c = lum.get('contrast') - if b: - effects["brightness"] = round(int(b) / 1000) - if c: - effects["contrast"] = round(int(c) / 1000) - sat = blip.find(f'{{{_NS["a"]}}}hsl') - if sat is not None: - v = sat.get('sat') - if v: - effects["saturation"] = round(int(v) / 1000) - duo = blip.find(f'{{{_NS["a"]}}}duotone') - if duo is not None: - colors = [] - for srgb in duo.findall(f'{{{_NS["a"]}}}srgbClr'): - colors.append(_hex(srgb)) - if len(colors) >= 2: - effects["duotone"] = colors[:2] - # Preserve blip effects XML for lossless roundtrip (biLevel, etc.) - from lxml import etree as _et - blip_effects = [] - for child in blip: - tag = child.tag.split('}')[-1] - if tag in ('biLevel', 'grayscl', 'clrChange', 'clrRepl'): - blip_effects.append(_et.tostring(child, encoding='unicode')) - if blip_effects: - effects["_blipEffects"] = blip_effects - if effects: - elem["_originalEffects"] = effects - except Exception: - pass - - 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_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: - img_counter += 1 - elif shape.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER and shape._element.tag.endswith('}pic'): - elem = extract_picture_element(shape, output_dir, slide_idx, img_counter, theme_colors, color_mapping) - if elem: - img_counter += 1 - elif shape.shape_type == MSO_SHAPE_TYPE.TABLE: - from .table import extract_table_element - elem = extract_table_element(shape, theme_colors, color_mapping, pptx_path) - elif hasattr(shape, 'has_chart') and shape.has_chart: - from .chart import extract_chart_element - elem = extract_chart_element(shape, theme_colors, color_mapping) - elif shape.shape_type == MSO_SHAPE_TYPE.LINE: - elem = extract_line_element(shape, theme_colors, color_mapping, theme_styles) - elif shape.shape_type == 16: # MEDIA (video) - elem = extract_video_element(shape, output_dir, slide_idx, img_counter) - 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: - # Check if AUTO_SHAPE is actually a line (prst=line) - try: - prst = shape._element.spPr.find(f'.//{{{_NS["a"]}}}prstGeom') - if prst is not None and prst.get('prst') == 'line': - elem = extract_line_element(shape, theme_colors, color_mapping, theme_styles) - except Exception: - pass - if not elem: - elem = extract_shape_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) - return elem, img_counter - -def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, output_dir=None, slide_idx=0, img_counter=0, builder_text_color=None): - """Extract group as element dict with nested elements. - - Note: python-pptx returns absolute slide coordinates for grouped shapes. - """ - elem = _base_element(shape, "group", elements=[]) - # Move rotation after elements for consistent key order - rot = elem.pop("rotation", None) - if rot is not None: - elem["rotation"] = rot - - # 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 - - # Extract group fill (for grpFill inheritance) - grp_fill_color = None - grp_fill_gradient = None - grp_sp_pr = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}grpSpPr') - if grp_sp_pr is not None: - sf = grp_sp_pr.find(f'{{{_NS["a"]}}}solidFill') - gf = grp_sp_pr.find(f'{{{_NS["a"]}}}gradFill') - if sf is not None: - srgb = sf.find(f'{{{_NS["a"]}}}srgbClr') - scheme = sf.find(f'{{{_NS["a"]}}}schemeClr') - if srgb is not None: - grp_fill_color = _hex(srgb) - elif scheme is not None: - from .color import _resolve_color_with_transforms - grp_fill_color = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) - elif gf is not None: - fill_info = _extract_fill_from_xml(grp_sp_pr, theme_colors, color_mapping) - grp_fill_gradient = fill_info.get("gradient") - # For gradient grpFill, preserve entire group XML for lossless roundtrip - from lxml import etree as _et - elem["_groupXml"] = _et.tostring(shape._element, encoding='unicode') - - # Extract each shape in the group - for sub_shape in shape.shapes: - try: - sub_elem = None - - # Handle nested groups recursively - sub_elem, img_counter = _dispatch_shape(sub_shape, theme_colors, color_mapping, theme_styles, output_dir, slide_idx, img_counter, builder_text_color=builder_text_color) - - if sub_elem: - # Resolve grpFill: if sub-element has fill=none but XML has grpFill, use group fill - if sub_elem.get("fill") in (None, "none"): - sub_sp = sub_shape._element.find(f'{{{_NS["p"]}}}spPr') - if sub_sp is not None and sub_sp.find(f'{{{_NS["a"]}}}grpFill') is not None: - if grp_fill_color: - sub_elem["fill"] = grp_fill_color - elif grp_fill_gradient: - sub_elem["gradient"] = grp_fill_gradient - # Propagate grpFill to nested group children - if sub_elem.get("type") == "group": - grp_sp = sub_shape._element.find(f'{{{_NS["p"]}}}grpSpPr') - if grp_sp is not None and grp_sp.find(f'{{{_NS["a"]}}}grpFill') is not None: - for child_el in sub_elem.get("elements", []): - if child_el.get("fill") in (None, "none"): - if grp_fill_color: - child_el["fill"] = grp_fill_color - elif grp_fill_gradient: - child_el["gradient"] = grp_fill_gradient - - # Transform coordinates from child coordinate system to slide coordinates - grp_sp_pr = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}grpSpPr') - xfrm = grp_sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm') if grp_sp_pr else None - - if xfrm is not None: - off = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}off') - ext = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}ext') - ch_off = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}chOff') - ch_ext = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}chExt') - - if off is not None and ch_off is not None and ext is not None and ch_ext is not None: - group_off_x = int(off.get('x')) - group_off_y = int(off.get('y')) - group_ext_cx = int(ext.get('cx')) - group_ext_cy = int(ext.get('cy')) - ch_off_x = int(ch_off.get('x')) - ch_off_y = int(ch_off.get('y')) - ch_ext_cx = int(ch_ext.get('cx')) - ch_ext_cy = int(ch_ext.get('cy')) - - # Transform: abs = group_off + (child - chOff) * (group_ext / ch_ext) - child_x = sub_shape.left - child_y = sub_shape.top - - scale_x = group_ext_cx / ch_ext_cx if ch_ext_cx != 0 else 1 - scale_y = group_ext_cy / ch_ext_cy if ch_ext_cy != 0 else 1 - - 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) - sub_elem["height"] = round(sub_shape.height * scale_y / EMU_PER_PX) - # For freeform in group: drop raw path XML, let builder reconstruct - # from px coords (which match the group-scaled shape size) - if sub_elem.get("type") == "freeform": - sub_elem["_xEmu"] = round(abs_x) - sub_elem["_yEmu"] = round(abs_y) - sub_elem["_widthEmu"] = round(sub_shape.width * scale_x) - sub_elem["_heightEmu"] = round(sub_shape.height * scale_y) - sub_elem.pop("_pathLstXml", None) - - # For nested groups, also transform all children recursively - 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 - new_x = gox + (old_x - gcx) * sx - new_y = goy + (old_y - gcy) * sy - el["x"] = round(new_x / EMU_PER_PX) - el["y"] = round(new_y / EMU_PER_PX) - if el.get("type") == "freeform": - el["_xEmu"] = round(new_x) - el["_yEmu"] = round(new_y) - if "width" in el: - el["width"] = round(el["width"] * sx) - if "height" in el: - el["height"] = round(el["height"] * sy) - if el.get("type") == "freeform": - if el.get("_widthEmu"): - el["_widthEmu"] = round(el["_widthEmu"] * sx) - else: - el["_widthEmu"] = round(el["width"] * EMU_PER_PX) - if el.get("_heightEmu"): - el["_heightEmu"] = round(el["_heightEmu"] * sy) - else: - el["_heightEmu"] = round(el["height"] * EMU_PER_PX) - el.pop("_pathLstXml", None) - if el.get("type") == "group" and el.get("elements"): - _apply_group_transform(el["elements"], gox, goy, gcx, gcy, sx, sy) - _apply_group_transform(sub_elem["elements"], group_off_x, group_off_y, ch_off_x, ch_off_y, scale_x, scale_y) - else: - # Fallback: use python-pptx coordinates as-is - pass - - # Propagate group rotation to child elements - if shape.rotation != 0: - child_rot = sub_elem.get("rotation", 0) - sub_elem["rotation"] = round(child_rot + shape.rotation, 1) - - elem["elements"].append(sub_elem) - except Exception as e: - print(f"Warning: Failed to extract grouped shape: {e}", file=sys.stderr) - - return elem, img_counter diff --git a/sdpm/sdpm/engine/converter/elements/__init__.py b/sdpm/sdpm/engine/converter/elements/__init__.py new file mode 100644 index 00000000..322521d4 --- /dev/null +++ b/sdpm/sdpm/engine/converter/elements/__init__.py @@ -0,0 +1,27 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Element extraction (shape, textbox, line, freeform, picture, group). + +Compatibility facade over the elements package. Import surface is frozen: +the seven public extractors plus ``_dispatch_shape`` (used by +``converter.slide``) — see tests/test_converter_elements.py. +""" + +from .shapes import (extract_line_element as extract_line_element, + extract_freeform_element as extract_freeform_element, + extract_shape_element as extract_shape_element) +from .textbox import extract_textbox_element as extract_textbox_element +from .media import (extract_video_element as extract_video_element, + extract_picture_element as extract_picture_element) +from .dispatch import (extract_group_element as extract_group_element, + _dispatch_shape as _dispatch_shape) + +__all__ = [ + "extract_line_element", + "extract_freeform_element", + "extract_shape_element", + "extract_textbox_element", + "extract_video_element", + "extract_picture_element", + "extract_group_element", +] diff --git a/sdpm/sdpm/engine/converter/elements/dispatch.py b/sdpm/sdpm/engine/converter/elements/dispatch.py new file mode 100644 index 00000000..29da75a3 --- /dev/null +++ b/sdpm/sdpm/engine/converter/elements/dispatch.py @@ -0,0 +1,311 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Shape-type routing, raw passthrough, and group recursion.""" +import sys + +from pptx.enum.shapes import MSO_SHAPE_TYPE + +from ..constants import _NS, get_emu_per_px, _base_element, _hex +from ..xml_helpers import _extract_fill_from_xml +from .shapes import extract_line_element, extract_freeform_element, extract_shape_element +from .textbox import extract_textbox_element +from .media import (extract_video_element, extract_picture_element, + _extract_blipfill_image, _save_referenced_images) + +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_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: + img_counter += 1 + elif shape.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER and shape._element.tag.endswith('}pic'): + elem = extract_picture_element(shape, output_dir, slide_idx, img_counter, theme_colors, color_mapping) + if elem: + img_counter += 1 + elif shape.shape_type == MSO_SHAPE_TYPE.TABLE: + from ..table import extract_table_element + elem = extract_table_element(shape, theme_colors, color_mapping, pptx_path) + elif hasattr(shape, 'has_chart') and shape.has_chart: + from ..chart import extract_chart_element + elem = extract_chart_element(shape, theme_colors, color_mapping) + elif shape.shape_type == MSO_SHAPE_TYPE.LINE: + elem = extract_line_element(shape, theme_colors, color_mapping, theme_styles) + elif shape.shape_type == 16: # MEDIA (video) + elem = extract_video_element(shape, output_dir, slide_idx, img_counter) + 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: + # Check if AUTO_SHAPE is actually a line (prst=line) + try: + prst = shape._element.spPr.find(f'.//{{{_NS["a"]}}}prstGeom') + if prst is not None and prst.get('prst') == 'line': + elem = extract_line_element(shape, theme_colors, color_mapping, theme_styles) + except Exception: + pass + if not elem: + elem = extract_shape_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) + return elem, img_counter + +def extract_group_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, output_dir=None, slide_idx=0, img_counter=0, builder_text_color=None): + """Extract group as element dict with nested elements. + + Note: python-pptx returns absolute slide coordinates for grouped shapes. + """ + emu_per_px = get_emu_per_px() + elem = _base_element(shape, "group", elements=[]) + # Move rotation after elements for consistent key order + rot = elem.pop("rotation", None) + if rot is not None: + elem["rotation"] = rot + + # 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 + + # Extract group fill (for grpFill inheritance) + grp_fill_color = None + grp_fill_gradient = None + grp_sp_pr = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}grpSpPr') + if grp_sp_pr is not None: + sf = grp_sp_pr.find(f'{{{_NS["a"]}}}solidFill') + gf = grp_sp_pr.find(f'{{{_NS["a"]}}}gradFill') + if sf is not None: + srgb = sf.find(f'{{{_NS["a"]}}}srgbClr') + scheme = sf.find(f'{{{_NS["a"]}}}schemeClr') + if srgb is not None: + grp_fill_color = _hex(srgb) + elif scheme is not None: + from ..color import _resolve_color_with_transforms + grp_fill_color = _resolve_color_with_transforms(scheme, theme_colors, color_mapping) + elif gf is not None: + fill_info = _extract_fill_from_xml(grp_sp_pr, theme_colors, color_mapping) + grp_fill_gradient = fill_info.get("gradient") + # For gradient grpFill, preserve entire group XML for lossless roundtrip + from lxml import etree as _et + elem["_groupXml"] = _et.tostring(shape._element, encoding='unicode') + + # Extract each shape in the group + for sub_shape in shape.shapes: + try: + sub_elem = None + + # Handle nested groups recursively + sub_elem, img_counter = _dispatch_shape(sub_shape, theme_colors, color_mapping, theme_styles, output_dir, slide_idx, img_counter, builder_text_color=builder_text_color) + + if sub_elem: + # Resolve grpFill: if sub-element has fill=none but XML has grpFill, use group fill + if sub_elem.get("fill") in (None, "none"): + sub_sp = sub_shape._element.find(f'{{{_NS["p"]}}}spPr') + if sub_sp is not None and sub_sp.find(f'{{{_NS["a"]}}}grpFill') is not None: + if grp_fill_color: + sub_elem["fill"] = grp_fill_color + elif grp_fill_gradient: + sub_elem["gradient"] = grp_fill_gradient + # Propagate grpFill to nested group children + if sub_elem.get("type") == "group": + grp_sp = sub_shape._element.find(f'{{{_NS["p"]}}}grpSpPr') + if grp_sp is not None and grp_sp.find(f'{{{_NS["a"]}}}grpFill') is not None: + for child_el in sub_elem.get("elements", []): + if child_el.get("fill") in (None, "none"): + if grp_fill_color: + child_el["fill"] = grp_fill_color + elif grp_fill_gradient: + child_el["gradient"] = grp_fill_gradient + + # Transform coordinates from child coordinate system to slide coordinates + grp_sp_pr = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}grpSpPr') + xfrm = grp_sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm') if grp_sp_pr is not None else None + + if xfrm is not None: + off = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}off') + ext = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}ext') + ch_off = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}chOff') + ch_ext = xfrm.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}chExt') + + if off is not None and ch_off is not None and ext is not None and ch_ext is not None: + group_off_x = int(off.get('x')) + group_off_y = int(off.get('y')) + group_ext_cx = int(ext.get('cx')) + group_ext_cy = int(ext.get('cy')) + ch_off_x = int(ch_off.get('x')) + ch_off_y = int(ch_off.get('y')) + ch_ext_cx = int(ch_ext.get('cx')) + ch_ext_cy = int(ch_ext.get('cy')) + + # Transform: abs = group_off + (child - chOff) * (group_ext / ch_ext) + child_x = sub_shape.left + child_y = sub_shape.top + + scale_x = group_ext_cx / ch_ext_cx if ch_ext_cx != 0 else 1 + scale_y = group_ext_cy / ch_ext_cy if ch_ext_cy != 0 else 1 + + 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) + sub_elem["height"] = round(sub_shape.height * scale_y / emu_per_px) + # For freeform in group: drop raw path XML, let builder reconstruct + # from px coords (which match the group-scaled shape size) + if sub_elem.get("type") == "freeform": + sub_elem["_xEmu"] = round(abs_x) + sub_elem["_yEmu"] = round(abs_y) + sub_elem["_widthEmu"] = round(sub_shape.width * scale_x) + sub_elem["_heightEmu"] = round(sub_shape.height * scale_y) + sub_elem.pop("_pathLstXml", None) + + # For nested groups, also transform all children recursively + 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 + new_x = gox + (old_x - gcx) * sx + new_y = goy + (old_y - gcy) * sy + el["x"] = round(new_x / emu_per_px) + el["y"] = round(new_y / emu_per_px) + if el.get("type") == "freeform": + el["_xEmu"] = round(new_x) + el["_yEmu"] = round(new_y) + if "width" in el: + el["width"] = round(el["width"] * sx) + if "height" in el: + el["height"] = round(el["height"] * sy) + if el.get("type") == "freeform": + if el.get("_widthEmu"): + el["_widthEmu"] = round(el["_widthEmu"] * sx) + else: + el["_widthEmu"] = round(el["width"] * emu_per_px) + if el.get("_heightEmu"): + el["_heightEmu"] = round(el["_heightEmu"] * sy) + else: + el["_heightEmu"] = round(el["height"] * emu_per_px) + el.pop("_pathLstXml", None) + if el.get("type") == "group" and el.get("elements"): + _apply_group_transform(el["elements"], gox, goy, gcx, gcy, sx, sy) + _apply_group_transform(sub_elem["elements"], group_off_x, group_off_y, ch_off_x, ch_off_y, scale_x, scale_y) + else: + # Fallback: use python-pptx coordinates as-is + pass + + # Propagate group rotation to child elements + if shape.rotation != 0: + child_rot = sub_elem.get("rotation", 0) + sub_elem["rotation"] = round(child_rot + shape.rotation, 1) + + elem["elements"].append(sub_elem) + except Exception as e: + print(f"Warning: Failed to extract grouped shape: {e}", file=sys.stderr) + + return elem, img_counter diff --git a/sdpm/sdpm/engine/converter/elements/media.py b/sdpm/sdpm/engine/converter/elements/media.py new file mode 100644 index 00000000..d1284765 --- /dev/null +++ b/sdpm/sdpm/engine/converter/elements/media.py @@ -0,0 +1,309 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Media extraction: video, pictures, SVG, image fills, referenced images.""" +import sys +from pathlib import Path + +from ..constants import _NS, _base_element, _add_flip, _hex +from ..xml_helpers import _extract_visual_effects + +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.""" + from pptx.oxml.ns import qn as _qn + elem = _base_element(shape, "video") + try: + r_ns = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + nvPr = shape._element.find(f'{_qn("p:nvPicPr")}/{_qn("p:nvPr")}') + if nvPr is None: + return None + videoFile = nvPr.find(_qn('a:videoFile')) + if videoFile is None: + return None + + # Save video file + r_link = videoFile.get(f'{{{r_ns}}}link') + if r_link and output_dir: + slide_part = shape.part + rel = slide_part.rels[r_link] + ext = rel.target_ref.split('.')[-1] or 'mp4' + video_name = f"slide{slide_idx+1}_video{img_idx+1}.{ext}" + media_dir = Path(output_dir) / "media" + media_dir.mkdir(exist_ok=True) + # Get blob via p14:media embed (more reliable) + p14_ns = 'http://schemas.microsoft.com/office/powerpoint/2010/main' + media_el = nvPr.find(f'.//{{{p14_ns}}}media') + if media_el is not None: + r_embed = media_el.get(f'{{{r_ns}}}embed') + if r_embed: + (media_dir / video_name).write_bytes(slide_part.rels[r_embed].target_part.blob) + elem["src"] = f"media/{video_name}" + + # Save poster image + blip = shape._element.find(f'{_qn("p:blipFill")}/{_qn("a:blip")}') + if blip is not None and output_dir: + r_embed = blip.get(f'{{{r_ns}}}embed') + if r_embed: + poster_part = shape.part.rels[r_embed].target_part + poster_ext = poster_part.content_type.split('/')[-1].replace('jpeg', 'jpg') + poster_name = f"slide{slide_idx+1}_poster{img_idx+1}.{poster_ext}" + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + (images_dir / poster_name).write_bytes(poster_part.blob) + elem["poster"] = f"images/{poster_name}" + except Exception as e: + print(f"Warning: Failed to extract video: {e}", file=sys.stderr) + 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' + 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_picture_element(shape, output_dir=None, slide_idx=0, img_idx=0, theme_colors=None, color_mapping=None): + """Extract picture as element dict and save image file.""" + elem = _base_element(shape, "image") + + # 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 + if output_dir: + try: + image = shape.image + image_bytes = image.blob + + # Determine format + ext = shape.image.ext or "png" + + # Create images directory + images_dir = Path(output_dir) / "images" + images_dir.mkdir(exist_ok=True) + + # Save image + image_filename = f"slide{slide_idx + 1}_image{img_idx + 1}.{ext}" + image_path = images_dir / image_filename + + with open(image_path, 'wb') as f: + f.write(image_bytes) + + # Store relative path + elem["src"] = f"images/{image_filename}" + except Exception as e: + print(f"Warning: Failed to save image: {e}", file=sys.stderr) + + # 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 + # NOT copy _originalEffects — this prevents unintended mask/crop/color changes. + # To faithfully reproduce the original slide, spread _originalEffects into the + # element: { ...elem, ...elem._originalEffects }. + try: + effects: dict = {} + pic_el = shape._element + sp_pr = pic_el.find(f'{{{_NS["p"]}}}spPr') + if sp_pr is not None: + # Mask (prstGeom != rect) + prst_geom = sp_pr.find(f'{{{_NS["a"]}}}prstGeom') + if prst_geom is not None: + prst = prst_geom.get('prst') + mask_rmap = {"ellipse": "circle", "roundRect": "rounded_rectangle", "hexagon": "hexagon", "diamond": "diamond", "triangle": "triangle", "pentagon": "pentagon", "star5": "star_5_point", "heart": "heart", "trapezoid": "trapezoid"} + if prst and prst != 'rect' and prst in mask_rmap: + effects["mask"] = mask_rmap[prst] + # Visual effects + effects.update(_extract_visual_effects(sp_pr, theme_colors, color_mapping)) + + blip_fill = pic_el.find(f'{{{_NS["p"]}}}blipFill') + if blip_fill is not None: + # Crop + src_rect = blip_fill.find(f'{{{_NS["a"]}}}srcRect') + if src_rect is not None: + crop = {} + for side in ('l', 't', 'r', 'b'): + v = src_rect.get(side) + if v and int(v) != 0: + key = {"l": "left", "t": "top", "r": "right", "b": "bottom"}[side] + crop[key] = int(v) / 1000 + if crop: + effects["crop"] = crop + # Brightness/Contrast/Saturation + blip = blip_fill.find(f'{{{_NS["a"]}}}blip') + if blip is not None: + lum = blip.find(f'{{{_NS["a"]}}}lum') + if lum is not None: + b = lum.get('bright') + c = lum.get('contrast') + if b: + effects["brightness"] = round(int(b) / 1000) + if c: + effects["contrast"] = round(int(c) / 1000) + sat = blip.find(f'{{{_NS["a"]}}}hsl') + if sat is not None: + v = sat.get('sat') + if v: + effects["saturation"] = round(int(v) / 1000) + duo = blip.find(f'{{{_NS["a"]}}}duotone') + if duo is not None: + colors = [] + for srgb in duo.findall(f'{{{_NS["a"]}}}srgbClr'): + colors.append(_hex(srgb)) + if len(colors) >= 2: + effects["duotone"] = colors[:2] + # Preserve blip effects XML for lossless roundtrip (biLevel, etc.) + from lxml import etree as _et + blip_effects = [] + for child in blip: + tag = child.tag.split('}')[-1] + if tag in ('biLevel', 'grayscl', 'clrChange', 'clrRepl'): + blip_effects.append(_et.tostring(child, encoding='unicode')) + if blip_effects: + effects["_blipEffects"] = blip_effects + if effects: + elem["_originalEffects"] = effects + except Exception: + pass + + 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 + + diff --git a/sdpm/sdpm/engine/converter/elements/shapes.py b/sdpm/sdpm/engine/converter/elements/shapes.py new file mode 100644 index 00000000..eaf128de --- /dev/null +++ b/sdpm/sdpm/engine/converter/elements/shapes.py @@ -0,0 +1,537 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Shape-name resolution and line / freeform / auto-shape extraction.""" +import sys + +from pptx.enum.shapes import MSO_SHAPE + +from ..constants import _NS, get_emu_per_px, _base_element, _add_flip, _serialize_lstStyle +from ..color import _resolve_color_with_transforms +from ..xml_helpers import (extract_line_dash, _resolve_line_from_style, _extract_fill_from_xml, + _extract_line_from_xml, _extract_visual_effects) +from ..text import _extract_shape_text + +_SHAPE_MAP = { + MSO_SHAPE.RECTANGLE: "rectangle", MSO_SHAPE.ROUNDED_RECTANGLE: "rounded_rectangle", + MSO_SHAPE.OVAL: "oval", MSO_SHAPE.RIGHT_ARROW: "arrow_right", + MSO_SHAPE.LEFT_ARROW: "arrow_left", MSO_SHAPE.UP_ARROW: "arrow_up", + MSO_SHAPE.DOWN_ARROW: "arrow_down", MSO_SHAPE.ISOSCELES_TRIANGLE: "triangle", + MSO_SHAPE.DIAMOND: "diamond", MSO_SHAPE.PENTAGON: "pentagon", + MSO_SHAPE.HEXAGON: "hexagon", MSO_SHAPE.CHEVRON: "chevron", + MSO_SHAPE.RIGHT_BRACE: "right_brace", MSO_SHAPE.LEFT_BRACE: "left_brace", + 60: "arrow_circular", +} +_PRESET_MAP = { + 'roundRect': 'rounded_rectangle', 'rect': 'rectangle', 'ellipse': 'oval', + 'triangle': 'triangle', 'diamond': 'diamond', 'pentagon': 'pentagon', + 'hexagon': 'hexagon', 'chevron': 'chevron', 'homePlate': 'pentagon', + 'heart': 'heart', 'cloud': 'cloud', 'lightningBolt': 'lightning_bolt', + 'star5': 'star_5_point', 'noSmoking': 'no_symbol', 'cross': 'cross', 'plus': 'cross', + 'trapezoid': 'trapezoid', 'parallelogram': 'parallelogram', + 'donut': 'donut', 'arc': 'arc', 'blockArc': 'block_arc', 'chord': 'chord', + 'pie': 'pie', 'pieWedge': 'pie_wedge', + 'leftRightArrow': 'arrow_left_right', 'upDownArrow': 'arrow_up_down', + 'curvedRightArrow': 'arrow_curved_right', 'curvedLeftArrow': 'arrow_curved_left', + 'curvedUpArrow': 'arrow_curved_up', 'curvedDownArrow': 'arrow_curved_down', + 'circularArrow': 'arrow_circular', 'leftCircularArrow': 'arrow_circular_left', + 'leftRightCircularArrow': 'arrow_circular_left_right', + 'calloutRoundRect': 'callout_rounded_rectangle', 'wedgeRoundRectCallout': 'callout_rounded_rectangle', + 'calloutRect': 'callout_rectangle', 'wedgeRectCallout': 'callout_rectangle', + 'calloutEllipse': 'callout_oval', 'wedgeEllipseCallout': 'callout_oval', + 'flowChartProcess': 'flowchart_process', 'flowChartDecision': 'flowchart_decision', + 'flowChartTerminator': 'flowchart_terminator', + 'leftBracket': 'left_bracket', 'rightBracket': 'right_bracket', + 'can': 'cylinder', 'mathNotEqual': 'math_not_equal', +} + +def _resolve_shape_name(shape): + """Resolve shape preset name from python-pptx or XML.""" + if shape.shape_type == 5: # MSO_SHAPE_TYPE.FREEFORM + return "rounded_rectangle" + name = None + try: + if hasattr(shape, 'auto_shape_type') and shape.auto_shape_type in _SHAPE_MAP: + name = _SHAPE_MAP[shape.auto_shape_type] + except Exception: + pass + if not name: + try: + prst = shape._element.spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') + if prst is not None: + prst_val = prst.get('prst') + name = _PRESET_MAP.get(prst_val, prst_val) # Use raw prst value as fallback + except Exception: + pass + if name == "oval" and shape.width == shape.height: + return "circle" + return name or "rounded_rectangle" + +def extract_line_element(shape, theme_colors=None, color_mapping=None, theme_styles=None): + """Extract line/connector as element dict.""" + emu_per_px = get_emu_per_px() + try: + # Build x1/y1/x2/y2 from bounding box + flip + x = round(shape.left / emu_per_px) + y = round(shape.top / emu_per_px) + w = round(shape.width / emu_per_px) + h = round(shape.height / emu_per_px) + x1, y1, x2, y2 = x, y, x + w, y + h + + # 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') + if xfrm is not None: + if xfrm.get('flipH') == '1': + 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} + + # Extract connector type from XML + try: + sp_pr = shape._element.spPr + prst_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') + if prst_geom is not None: + prst = prst_geom.get('prst') + if prst: + # Save exact preset type + elem["preset"] = prst + + # Map to general connector type + if 'straight' in prst.lower(): + 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" + + # Extract adjustments + av_lst = prst_geom.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}avLst') + if av_lst is not None: + adjustments = [] + for gd in av_lst.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/main}gd'): + fmla = gd.get('fmla', '') + if fmla.startswith('val '): + adj_val = int(fmla.split()[1]) + adjustments.append(adj_val / 100000.0) + if adjustments: + elem["adjustments"] = adjustments + except Exception: + elem["connectorType"] = "straight" # default + + # Extract arrow heads from XML + try: + ln = shape._element.spPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}ln') + if ln is not None: + head_end = ln.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}headEnd') + if head_end is not None: + head_type = head_end.get('type') + if head_type: + elem["arrowStart"] = head_type + + tail_end = ln.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}tailEnd') + if tail_end is not None: + tail_type = tail_end.get('type') + if tail_type: + elem["arrowEnd"] = tail_type + except Exception: + pass + + # Extract line color or gradient (use XML helper) + sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') + line_info = _extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping) + if "line" in line_info and line_info["line"] != "none": + elem["color"] = line_info["line"] + elif line_info.get("line") == "none": + elem["color"] = "none" + else: + # Resolve from style reference + style_info = _resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles) + if style_info.get("line"): + elem["color"] = style_info["line"] + if style_info.get("lineWidth"): + elem["lineWidth"] = style_info["lineWidth"] + if "lineGradient" in line_info: + elem["lineGradient"] = line_info["lineGradient"] + if "lineWidth" in line_info: + elem["lineWidth"] = line_info["lineWidth"] + + # Extract dash style + dash = extract_line_dash(shape) + if dash: + elem["dashStyle"] = dash + + # No effects in source → say so explicitly (same rule as shapes). + # python-pptx's add_connector default has effectRef idx=1 + # (theme shadow), which painted a shadow under plain lines. + 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 + has_own_effects = False + sp_pr_el = shape._element.find(f'{{{_NS["p"]}}}spPr') + if sp_pr_el is not None: + eff_lst = sp_pr_el.find(f'{{{_NS["a"]}}}effectLst') + has_own_effects = eff_lst is not None and len(eff_lst) > 0 + if not has_own_effects and ( + eff_ref is None or int(eff_ref.get('idx', '0') or 0) == 0): + elem["_noEffects"] = True + except Exception: + pass + + return elem + except Exception as e: + print(f"Warning: Failed to extract line: {e}", file=sys.stderr) + return None + +def extract_freeform_element(shape, theme_colors=None, color_mapping=None, builder_text_color=None): + """Extract freeform/curve shape as element dict with path commands in px.""" + emu_per_px = get_emu_per_px() + try: + sp_pr = shape._element.spPr + cust_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}custGeom') + if cust_geom is None: + return None + + elem = _base_element(shape, "freeform") + _add_flip(elem, shape) + # Preserve exact EMU size for freeform roundtrip fidelity + elem["_widthEmu"] = shape.width + elem["_heightEmu"] = shape.height + + # Extract all paths + path_elements = cust_geom.findall('.//a:pathLst/a:path', _NS) + if not path_elements: + return None + + def _extract_path_commands(path_el): + """Extract commands from a single path element.""" + path_w = int(path_el.get('w', shape.width)) + path_h = int(path_el.get('h', shape.height)) + sx = shape.width / path_w if path_w else 1 + sy = shape.height / path_h if path_h else 1 + + def to_px(x, y): + return round(int(x) * sx / emu_per_px, 1), round(int(y) * sy / emu_per_px, 1) + + path = [] + for child in path_el: + tag = child.tag.split('}')[-1] + pts = child.findall('a:pt', _NS) + if tag == 'moveTo' and pts: + px, py = to_px(pts[0].get('x'), pts[0].get('y')) + path.append({"cmd": "M", "x": px, "y": py}) + elif tag == 'lnTo' and pts: + px, py = to_px(pts[0].get('x'), pts[0].get('y')) + path.append({"cmd": "L", "x": px, "y": py}) + elif tag == 'cubicBezTo' and len(pts) == 3: + coords = [to_px(p.get('x'), p.get('y')) for p in pts] + path.append({"cmd": "C", "pts": [[c[0], c[1]] for c in coords]}) + elif tag == 'quadBezTo' and len(pts) == 2: + coords = [to_px(p.get('x'), p.get('y')) for p in pts] + path.append({"cmd": "Q", "pts": [[c[0], c[1]] for c in coords]}) + elif tag == 'arcTo': + wR = int(child.get('wR', 0)) + hR = int(child.get('hR', 0)) + stAng = int(child.get('stAng', 0)) + swAng = int(child.get('swAng', 0)) + path.append({ + "cmd": "A", + "wR": round(wR * sx / emu_per_px, 1), + "hR": round(hR * sy / emu_per_px, 1), + "stAng": round(stAng / 60000, 2), + "swAng": round(swAng / 60000, 2), + }) + elif tag == 'close': + path.append({"cmd": "Z"}) + return path + + if len(path_elements) == 1: + # Single path → "path" key (backward compatible) + elem["path"] = _extract_path_commands(path_elements[0]) + fill_attr = path_elements[0].get('fill') + if fill_attr and fill_attr != 'norm': + elem["pathFill"] = fill_attr + else: + # Multiple paths → "paths" key + paths = [] + for pe in path_elements: + p = {"commands": _extract_path_commands(pe)} + fill_attr = pe.get('fill') + if fill_attr and fill_attr != 'norm': + p["fill"] = fill_attr + paths.append(p) + elem["paths"] = paths + + # Preserve raw pathLst XML for lossless roundtrip + path_el_first = path_elements[0] + path_w = int(path_el_first.get('w', shape.width)) + path_h = int(path_el_first.get('h', shape.height)) + if path_w == shape.width and path_h == shape.height: + from lxml import etree as _et + pathLst = cust_geom.find('.//a:pathLst', _NS) + if pathLst is not None: + elem["_pathLstXml"] = _et.tostring(pathLst, encoding='unicode') + + # Fill + elem.update(_extract_fill_from_xml(sp_pr, theme_colors, color_mapping)) + + # Line + line_info = _extract_line_from_xml(sp_pr, theme_colors, color_mapping) + elem.update(line_info) + + # Line opacity + ln = sp_pr.find(f'.//{{{_NS["a"]}}}ln') + if ln is not None: + solid = ln.find(f'{{{_NS["a"]}}}solidFill') + if solid is not None: + for clr_tag in ('srgbClr', 'schemeClr'): + clr = solid.find(f'{{{_NS["a"]}}}{clr_tag}') + if clr is not None: + alpha = clr.find(f'{{{_NS["a"]}}}alpha') + if alpha is not None: + elem["lineOpacity"] = round(int(alpha.get('val')) / 100000, 2) + break + + # Arrow heads + try: + if ln is not None: + for attr, tag in [("headEnd", "headEnd"), ("tailEnd", "tailEnd")]: + el = ln.find(f'{{{_NS["a"]}}}{tag}') + if el is not None and el.get('type'): + elem[attr] = el.get('type') + except Exception: + pass + + # Effects + elem.update(_extract_visual_effects(sp_pr, theme_colors, color_mapping)) + + # Text (if freeform contains text) + if shape.has_text_frame and shape.text_frame.text.strip(): + _extract_shape_text(shape, elem, theme_colors, color_mapping, builder_text_color=builder_text_color) + + return elem + except Exception as e: + print(f"Warning: Failed to extract freeform: {e}", file=sys.stderr) + return None + +def extract_shape_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, builder_text_color=None): + """Extract shape as element dict.""" + emu_per_px = get_emu_per_px() + try: + elem = { + "type": "shape", + "x": round(shape.left / emu_per_px), + "y": round(shape.top / emu_per_px), + "width": round(shape.width / emu_per_px), + "height": round(shape.height / emu_per_px), + "shape": _resolve_shape_name(shape) + } + _add_flip(elem, shape) + + # Style references + style_fill_idx = None + style_fill_color = None + try: + style = shape._element.find(f'{{{_NS["p"]}}}style') + if style is not None: + fill_ref = style.find(f'{{{_NS["a"]}}}fillRef') + if fill_ref is not None: + style_fill_idx = int(fill_ref.get('idx', 0)) + sc = fill_ref.find(f'{{{_NS["a"]}}}schemeClr') + if sc is not None: + style_fill_color = sc.get('val') + except Exception: + pass + + # Rotation + if shape.rotation != 0: + elem["rotation"] = round(shape.rotation, 1) + + # Adjustments (only if explicitly set in XML avLst) + try: + sp_pr = shape._element.spPr + prst_geom = sp_pr.find(f'{{{_NS["a"]}}}prstGeom') + if prst_geom is not None: + av_lst = prst_geom.find(f'{{{_NS["a"]}}}avLst') + if av_lst is not None and len(av_lst) > 0: + adjs = [] + for gd in av_lst.findall(f'{{{_NS["a"]}}}gd'): + fmla = gd.get('fmla', '') + if fmla.startswith('val '): + adjs.append(round(int(fmla.split()[1]) / 100000, 5)) + prst_name = prst_geom.get('prst') + if prst_name == 'arc' and len(adjs) >= 2: + # Raw adj are angles in 60000ths of a degree, but the + # builder's arc API is [startDeg, sweepDeg] — feeding + # raw values drew a 353° ring as ~40%. + start_deg = round(adjs[0] * 100000 / 60000, 3) + end_deg = round(adjs[1] * 100000 / 60000, 3) + sweep = round((end_deg - start_deg) % 360, 3) + adjs = [start_deg, sweep] + if adjs: + elem["adjustments"] = adjs + except Exception: + pass + + # Extract fill and line from XML + sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') + elem.update(_extract_visual_effects(sp_pr_xml, theme_colors, color_mapping)) + + # Fill (XML first, python-pptx API fallback for style references) + try: + fill_info = _extract_fill_from_xml(sp_pr_xml, theme_colors, color_mapping) + # Check if spPr has explicit + has_explicit_no_fill = sp_pr_xml is not None and sp_pr_xml.find(f'{{{_NS["a"]}}}noFill') is not None + if fill_info.get("fill") != "none" or "gradient" in fill_info or "patternFill" in fill_info: + elem.update(fill_info) + elif has_explicit_no_fill: + elem["fill"] = "none" + else: + if shape.fill.type == 1: # SOLID + if shape.fill.fore_color.type == 1: # RGB + rgb = shape.fill.fore_color.rgb + elem["fill"] = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" + elif shape.fill.fore_color.type == 2: # SCHEME + theme_color = shape.fill.fore_color.theme_color + if theme_colors and theme_color in theme_colors: + elem["fill"] = theme_colors[theme_color] + alpha_el = sp_pr_xml.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}alpha') if sp_pr_xml is not None else None + if alpha_el is not None: + elem["opacity"] = round(int(alpha_el.get('val', 100000)) / 1000, 1) + elif shape.fill.type == 3: # GRADIENT + try: + stops = [] + for stop in shape.fill.gradient_stops: + s = {"position": round(stop.position, 3)} + if stop.color.type == 1: + rgb = stop.color.rgb + s["color"] = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" + elif stop.color.type == 2 and theme_colors and stop.color.theme_color in theme_colors: + s["color"] = theme_colors[stop.color.theme_color] + if "color" in s: + stops.append(s) + if stops: + angle = 0 + try: + # python-pptx returns CCW angle; convert to CW (PowerPoint UI) + ccw = round(shape.fill.gradient_angle, 1) + angle = round((360 - ccw) % 360, 1) + except Exception: + pass + elem["gradient"] = {"stops": stops, "angle": angle} + except Exception: + pass + elif shape.fill.type is None or shape.fill.type == 0 or shape.fill.type == 5: + # Resolve from style fillRef (unless useBgFill=1) + use_bg = shape._element.get('useBgFill') == '1' + if not use_bg and style_fill_idx and style_fill_idx > 0 and style_fill_color and theme_styles and theme_styles.get("fill"): + fill_idx = style_fill_idx - 1 + if 0 <= fill_idx < len(theme_styles["fill"]): + from lxml import etree as _et + fill_xml = _et.fromstring(theme_styles["fill"][fill_idx]) + scheme = fill_xml.find(f'.//{{{_NS["a"]}}}schemeClr') + if scheme is not None and scheme.get('val') == 'phClr': + resolved = _resolve_color_with_transforms(scheme, theme_colors, color_mapping, override_scheme=style_fill_color) + if resolved: + elem["fill"] = resolved + if "fill" not in elem: + elem["fill"] = "none" + except Exception: + pass + + # Line (XML first, style reference fallback) + try: + line_info = _extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping) + ln_xml = sp_pr_xml.find('a:ln', _NS) if sp_pr_xml is not None else None + if line_info.get("line") not in (None, "none") or "lineGradient" in line_info: + # If lineWidth missing, try style reference + if "lineWidth" not in line_info: + style_info = _resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles) + if style_info.get("lineWidth"): + line_info["lineWidth"] = style_info["lineWidth"] + elem.update(line_info) + elif ln_xml is not None and len(ln_xml) > 0: + elem.update(line_info) # ln exists with noFill or explicit content + else: + elem.update(_resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles)) + dash = extract_line_dash(shape) + if dash: + elem["dashStyle"] = dash + # Arrow heads + if ln_xml is not None: + for attr, tag in [("headEnd", "headEnd"), ("tailEnd", "tailEnd")]: + el = ln_xml.find(f'{{{_NS["a"]}}}{tag}') + if el is not None and el.get('type') and el.get('type') != 'none': + elem[attr] = el.get('type') + except Exception: + if "line" not in elem and "lineGradient" not in elem: + elem["line"] = "none" + if "line" not in elem and "lineGradient" not in elem: + elem["line"] = "none" + + # Extract text with styles + if shape.has_text_frame and shape.text.strip(): + _extract_shape_text(shape, elem, theme_colors, color_mapping, builder_text_color=builder_text_color) + + # Extract hyperlink + try: + if hasattr(shape, 'click_action') and shape.click_action.hyperlink and shape.click_action.hyperlink.address: + elem["link"] = shape.click_action.hyperlink.address + else: + # Remove null link + if "link" in elem and elem["link"] is None: + del elem["link"] + except Exception: + pass + + # 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 + if _lst: + elem["_lstStyle"] = _lst + + return elem + except Exception as e: + print(f"Warning: Failed to extract shape details: {e}", file=sys.stderr) + return None + diff --git a/sdpm/sdpm/engine/converter/elements/textbox.py b/sdpm/sdpm/engine/converter/elements/textbox.py new file mode 100644 index 00000000..86810336 --- /dev/null +++ b/sdpm/sdpm/engine/converter/elements/textbox.py @@ -0,0 +1,399 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Textbox extraction (delegates preset-geometry text boxes to shapes).""" +import json + +from ..constants import _NS, get_emu_per_px, _serialize_lstStyle, _hex, _add_flip +from ..xml_helpers import (_extract_fill_from_xml, _extract_line_from_xml, + _extract_effects_from_xml, _extract_visual_effects) +from ..text import _extract_styled_text, _detect_font_size, _get_alignment +from .shapes import extract_shape_element + +def extract_textbox_element(shape, theme_colors=None, color_mapping=None, theme_styles=None, is_placeholder=False, builder_text_color=None): + """Extract textbox as element dict.""" + emu_per_px = get_emu_per_px() + # Check if it's actually a shape with preset geometry (not a plain textbox) + try: + sp_pr = shape._element.spPr + prst_geom = sp_pr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}prstGeom') + if prst_geom is not None: + prst = prst_geom.get('prst') + # If it has any preset geometry (not just 'rect'), treat as shape + if prst and prst != 'rect': + # This is a shape with text, not a plain textbox + return extract_shape_element(shape, theme_colors, color_mapping, theme_styles, builder_text_color=builder_text_color) + except Exception: + pass + + elem = { + "type": "textbox", + "x": round(shape.left / emu_per_px), # px (1920x1080 basis) + "y": round(shape.top / emu_per_px), + "width": round(shape.width / emu_per_px), + } + + # Extract height (for TEXT_TO_FIT_SHAPE auto-shrink) + if shape.height: + h_px = round(shape.height / emu_per_px) + if h_px > 10: + elem["height"] = h_px + # Extract rotation + if shape.rotation != 0: + elem["rotation"] = round(shape.rotation, 1) + + # Extract flip + _add_flip(elem, shape) + + # Extract autoWidth + try: + body_pr = shape._element.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}bodyPr') + if body_pr is not None: + if body_pr.get('wrap') == 'none': + elem["autoWidth"] = True + vert = body_pr.get('vert') + if vert: + elem["textDirection"] = vert + except Exception: + pass + + # Extract margins (EMU → px) + # Builder default for textbox: left/right=0, top/bottom=PowerPoint default + tf = shape.text_frame + if tf.margin_left is not None and tf.margin_left != 0: + elem["marginLeft"] = round(tf.margin_left / emu_per_px) + if tf.margin_top is not None and tf.margin_top != 45720: + elem["marginTop"] = round(tf.margin_top / emu_per_px) + if tf.margin_right is not None and tf.margin_right != 0: + elem["marginRight"] = round(tf.margin_right / emu_per_px) + 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') + elem.update(_extract_fill_from_xml(sp_pr_xml, theme_colors, color_mapping)) + elem.update(_extract_line_from_xml(sp_pr_xml, theme_colors, color_mapping)) + elem.update(_extract_visual_effects(sp_pr_xml, theme_colors, color_mapping)) + except Exception: + pass + + # Extract textGradient from runs with gradFill + try: + grad_runs = [] + for para in shape.text_frame.paragraphs: + for run in para.runs: + rpr = run._r.find(f'{{{_NS["a"]}}}rPr') + if rpr is not None: + grad = rpr.find(f'{{{_NS["a"]}}}gradFill') + if grad is not None: + stops = [] + for gs in grad.findall(f'.//{{{_NS["a"]}}}gs'): + pos = round(int(gs.get('pos', '0')) / 100000, 2) + srgb = gs.find(f'{{{_NS["a"]}}}srgbClr') + if srgb is not None: + stops.append({"position": pos, "color": _hex(srgb)}) + if stops: + angle = 0 + lin = grad.find(f'{{{_NS["a"]}}}lin') + if lin is not None: + angle = round(int(lin.get('ang', '0')) / 60000) + grad_runs.append({"text": run.text, "gradient": {"angle": angle, "stops": stops}}) + if grad_runs: + # Count total runs with text + total_runs = sum(1 for p in shape.text_frame.paragraphs for r in p.runs if r.text.strip()) + grads = [json.dumps(gr["gradient"], sort_keys=True) for gr in grad_runs] + # Promote to textGradient only if ALL runs have the same gradient + if len(set(grads)) == 1 and len(grad_runs) >= total_runs: + elem["textGradient"] = grad_runs[0]["gradient"] + else: + 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] + if _all_runs: + if all(r._r.find(f'{{{_NS["a"]}}}rPr') is not None and + r._r.find(f'{{{_NS["a"]}}}rPr').get('cap') == 'none' + for r in _all_runs): + elem["_capNone"] = True + if all(r._r.find(f'{{{_NS["a"]}}}rPr') is not None and + r._r.find(f'{{{_NS["a"]}}}rPr').get('b') == '0' + for r in _all_runs): + elem["_boldOff"] = True + except Exception: + pass + + # Extract text with styles + text_parts = [] + default_font_size = None + + # Determine default text color (must match builder's theme_colors["text"]) + # For placeholders, don't set default_text_color — lstStyle defines the actual default + default_text_color = None + if not is_placeholder: + default_text_color = builder_text_color + if not default_text_color and color_mapping and theme_colors: + tx1_mapped = color_mapping.get('tx1', 'dk1') + default_text_color = theme_colors.get(tx1_mapped) + + # Check if multiple paragraphs (should be items array) + paragraphs_with_text = [p for p in shape.text_frame.paragraphs if p.text.strip()] + all_paragraphs = list(shape.text_frame.paragraphs) + has_lstStyle = _serialize_lstStyle(shape) is not None + + if len(all_paragraphs) > 1: + # Multiple paragraphs - extract as paragraphs with bullet info + default_font_size = None if (is_placeholder or has_lstStyle) else _detect_font_size(all_paragraphs) + paragraphs = [] + for paragraph in all_paragraphs: + + # Empty paragraph + if not paragraph.text.strip(): + paragraphs.append({"text": ""}) + continue + + # Check for bullet or numbering + has_bullet = False + numbering_type = None + bu_font = None + mar_l = None + indent = None + space_after = None + space_before = None + line_spacing = None + try: + pPr = paragraph._element.pPr + if pPr is not None: + bu_auto_num = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buAutoNum') + bu_char = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buChar') + + if bu_auto_num is not None: + numbering_type = bu_auto_num.get('type', 'arabicPeriod') + elif bu_char is not None: + has_bullet = True + + bu_font_elem = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}buFont') + if bu_font_elem is not None: + bu_font = bu_font_elem.get('typeface') + mar_l = pPr.get('marL') + indent = pPr.get('indent') + spc_aft = pPr.find('.//a:spcAft/a:spcPts', _NS) + if spc_aft is not None: + space_after = spc_aft.get('val') + spc_bef = pPr.find('.//a:spcBef/a:spcPts', _NS) + if spc_bef is not None: + space_before = spc_bef.get('val') + ln_spc = pPr.find('.//a:lnSpc/a:spcPts', _NS) + if ln_spc is not None: + line_spacing = ('pts', ln_spc.get('val')) + else: + ln_spc_pct = pPr.find('.//a:lnSpc/a:spcPct', _NS) + if ln_spc_pct is not None: + line_spacing = ('pct', ln_spc_pct.get('val')) + except Exception: + pass + + item_text = _extract_styled_text(paragraph.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, is_placeholder=is_placeholder, paragraph=paragraph) + para_info = {"text": item_text} + # Explicit paragraph alignment — without it a shape-level + # lstStyle default (e.g. centered) silently wins. + _algn = _get_alignment(paragraph) + if _algn: + para_info["align"] = _algn + if has_bullet or numbering_type: + list_def = {} + if numbering_type: + list_def["type"] = numbering_type + else: + list_def["type"] = "disc" + level = paragraph.level if paragraph.level else 0 + if level > 0: + list_def["level"] = level + para_info["list"] = list_def + if bu_font: + para_info["buFont"] = bu_font + if mar_l is not None: + para_info["marL"] = int(mar_l) + if indent is not None: + para_info["indent"] = int(indent) + if space_after is not None: + para_info["spaceAfter"] = int(space_after) + if space_before is not None: + para_info["spaceBefore"] = int(space_before) + if line_spacing: + if line_spacing[0] == 'pct': + para_info["lineSpacingPct"] = int(line_spacing[1]) + else: + para_info["lineSpacing"] = int(line_spacing[1]) + + # Paragraph level (for sub-bullets) + try: + pPr = paragraph._element.pPr + if pPr is not None: + lvl = pPr.get('lvl') + if lvl and lvl != '0': + para_info["level"] = int(lvl) + except Exception: + pass + + paragraphs.append(para_info) + + if paragraphs: + elem["paragraphs"] = paragraphs + + # Add fontSize if not default + if default_font_size and default_font_size != 18: + elem["fontSize"] = default_font_size + + # Get alignment - per paragraph if mixed, top-level if uniform + aligns = [_get_alignment(p) for p in paragraphs_with_text] + unique = set(a for a in aligns if a) + if len(unique) <= 1: + align = aligns[0] if aligns else None + if align and align != "left": + elem["align"] = align + else: + # Mixed alignment: set per-paragraph + for para_info, paragraph in zip(paragraphs, shape.text_frame.paragraphs): + a = _get_alignment(paragraph) + if a: + para_info["align"] = a + + # Preserve lstStyle for roundtrip fidelity + _lst = _serialize_lstStyle(shape) if shape.has_text_frame else None + if _lst: + elem["_lstStyle"] = _lst + + # Extract character spacing + _spc_vals = set() + for _p in shape.text_frame.paragraphs: + for _r in _p.runs: + _rPr = _r._r.find('{http://schemas.openxmlformats.org/drawingml/2006/main}rPr') + _s = _rPr.get('spc') if _rPr is not None else None + if _s: + _spc_vals.add(int(_s)) + if len(_spc_vals) == 1: + elem["_spc"] = _spc_vals.pop() + + return elem + + # Single paragraph - extract as text + default_font_size = None if (is_placeholder or has_lstStyle) else _detect_font_size(shape.text_frame.paragraphs) + for paragraph in shape.text_frame.paragraphs: + text_parts.append(_extract_styled_text(paragraph.runs, theme_colors, color_mapping, default_font_size=default_font_size, default_text_color=default_text_color, is_placeholder=is_placeholder, paragraph=paragraph)) + + elem["text"] = ''.join(text_parts) + + # endParaRPr pins the paragraph line height (e.g. a full-size 80pt + # endParaRPr next to a baseline-shrunk run keeps the line tall; + # dropping it shifts the text up within the box). + if shape.text_frame.paragraphs: + _last_p = shape.text_frame.paragraphs[-1] + _endPr = _last_p._element.find(f'{{{_NS["a"]}}}endParaRPr') + if _endPr is not None and _endPr.get('sz'): + _end_sz = int(_endPr.get('sz')) / 100 + _last_runs = _last_p.runs + _last_run_sz = (_last_runs[-1].font.size.pt + if _last_runs and _last_runs[-1].font.size else None) + _has_baseline = any( + (r._r.find(f'{{{_NS["a"]}}}rPr') is not None + and r._r.find(f'{{{_NS["a"]}}}rPr').get('baseline')) + for r in _last_runs) + if _has_baseline or (_last_run_sz is not None and _end_sz != _last_run_sz): + elem["_endParaSize"] = _end_sz + + # Extract indent/marL from first paragraph + if shape.text_frame.paragraphs: + from pptx.oxml.ns import qn as _qn + pPr = shape.text_frame.paragraphs[0]._element.find(_qn('a:pPr')) + if pPr is not None: + _indent = pPr.get('indent') + if _indent is not None: + elem["indent"] = int(_indent) + _marL = pPr.get('marL') + if _marL is not None: + elem["marL"] = int(_marL) + + # Add fontSize if consistent + if default_font_size: + elem["fontSize"] = default_font_size + + # Detect alignment + if shape.text_frame.paragraphs: + align = _get_alignment(shape.text_frame.paragraphs[0]) + if align: + elem["align"] = align + # Line spacing from first paragraph + pPr = shape.text_frame.paragraphs[0]._element.find('{http://schemas.openxmlformats.org/drawingml/2006/main}pPr') + if pPr is not None: + 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: + sp_pr_xml = shape._element.find('.//{http://schemas.openxmlformats.org/presentationml/2006/main}spPr') + if sp_pr_xml is None: + sp_pr_xml = shape._element.spPr if hasattr(shape._element, 'spPr') else None + elem.update(_extract_effects_from_xml(sp_pr_xml, theme_colors, color_mapping)) + except Exception: + pass + + # Preserve lstStyle for roundtrip fidelity + _lst = _serialize_lstStyle(shape) if shape.has_text_frame else None + if _lst: + elem["_lstStyle"] = _lst + + # Extract character spacing (spc) if uniform across all runs + if shape.has_text_frame: + spc_values = set() + for p in shape.text_frame.paragraphs: + for r in p.runs: + rPr = r._r.find('{http://schemas.openxmlformats.org/drawingml/2006/main}rPr') + spc = rPr.get('spc') if rPr is not None else None + if spc: + spc_values.add(int(spc)) + if len(spc_values) == 1: + elem["_spc"] = spc_values.pop() + + return elem + diff --git a/sdpm/sdpm/engine/converter/pipeline.py b/sdpm/sdpm/engine/converter/pipeline.py index 6884757b..dfc724fc 100644 --- a/sdpm/sdpm/engine/converter/pipeline.py +++ b/sdpm/sdpm/engine/converter/pipeline.py @@ -22,6 +22,7 @@ from pptx import Presentation from .color import extract_theme_colors_and_mapping +from .constants import conversion_scale from .slide import extract_slide from sdpm.utils.io import write_json from sdpm.engine.schema.defaults import sort_element_keys @@ -47,10 +48,6 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo actual_path = pptx_path prs = Presentation(str(actual_path)) - # Set EMU_PER_PX based on actual slide size - from .constants import set_emu_per_px - set_emu_per_px(int(prs.slide_width)) - # Create output directory if output_dir is None: output_dir = pptx_path.with_suffix('') @@ -81,23 +78,25 @@ def pptx_to_json(pptx_path: Path, output_dir: Path = None, use_layout_names: boo if builder_text_color: result["defaultTextColor"] = builder_text_color - for slide_idx, slide in enumerate(prs.slides): - # Get slide master index - slide_master = slide.slide_layout.slide_master - master_idx = list(prs.slide_masters).index(slide_master) - - # Extract theme colors and mapping for this master - theme_colors, color_mapping, theme_styles = extract_theme_colors_and_mapping(actual_path, master_idx) - - slide_dict = extract_slide( - slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx, - pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color, - ) - slide_dict["elements"] = [sort_element_keys(e) for e in slide_dict.get("elements", [])] - if minimal: - from sdpm.engine.schema.minimal import minimize - slide_dict["elements"] = minimize(slide_dict["elements"]) - result["slides"].append(slide_dict) + # Scope the px scale to this deck's actual slide width (restored on exit) + with conversion_scale(int(prs.slide_width)): + for slide_idx, slide in enumerate(prs.slides): + # Get slide master index + slide_master = slide.slide_layout.slide_master + master_idx = list(prs.slide_masters).index(slide_master) + + # Extract theme colors and mapping for this master + theme_colors, color_mapping, theme_styles = extract_theme_colors_and_mapping(actual_path, master_idx) + + slide_dict = extract_slide( + slide, theme_colors, color_mapping, theme_styles, master_idx, output_dir, slide_idx, + pptx_path=actual_path, use_layout_names=use_layout_names, builder_text_color=builder_text_color, + ) + slide_dict["elements"] = [sort_element_keys(e) for e in slide_dict.get("elements", [])] + if minimal: + from sdpm.engine.schema.minimal import minimize + slide_dict["elements"] = minimize(slide_dict["elements"]) + result["slides"].append(slide_dict) # Write deck.json (fonts + defaultTextColor; template is caller's responsibility) deck_meta: dict = {} diff --git a/sdpm/sdpm/engine/converter/slide.py b/sdpm/sdpm/engine/converter/slide.py index 22a49249..438ce439 100644 --- a/sdpm/sdpm/engine/converter/slide.py +++ b/sdpm/sdpm/engine/converter/slide.py @@ -10,7 +10,7 @@ -from .constants import _NS, EMU_PER_PX, _extract_autofit_props, _position_diff +from .constants import _NS, get_emu_per_px, _extract_autofit_props, _position_diff from .color import _resolve_scheme_color from .text import _extract_styled_text from .elements import (extract_textbox_element, extract_picture_element, _dispatch_shape) @@ -73,6 +73,7 @@ def detect_layout(slide): def _resolve_inherited_styles(elements, slide, theme_colors, color_mapping): """Resolve inherited text color, fontSize, fontFamily from shape's lstStyle/defRPr.""" + emu_per_px = get_emu_per_px() if not theme_colors or not color_mapping: return for elem in elements: @@ -83,7 +84,7 @@ def _resolve_inherited_styles(elements, slide, theme_colors, color_mapping): for shape in slide.shapes: if not shape.has_text_frame: continue - if round(shape.left / EMU_PER_PX) == elem.get("x") and round(shape.top / EMU_PER_PX) == elem.get("y"): + if round(shape.left / emu_per_px) == elem.get("x") and round(shape.top / emu_per_px) == elem.get("y"): elem.update(_extract_autofit_props(shape)) defRPr = shape.text_frame._txBody.find(f'.//{{{_NS["a"]}}}lstStyle//{{{_NS["a"]}}}defRPr') if defRPr is not None: @@ -120,6 +121,7 @@ def _resolve_inherited_styles(elements, slide, theme_colors, color_mapping): def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=None, master_idx=0, output_dir=None, slide_idx=0, pptx_path=None, use_layout_names=False, builder_text_color=None): """Extract slide content to dict.""" + emu_per_px = get_emu_per_px() if use_layout_names: layout_name = slide.slide_layout.name if slide.slide_layout.name else f"layout-{master_idx+1:02d}" else: @@ -178,8 +180,8 @@ def extract_slide(slide, theme_colors=None, color_mapping=None, theme_styles=Non # 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) + 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') @@ -426,9 +428,9 @@ def _patt_color(tag, default): lIns = mbp.get('lIns') rIns = mbp.get('rIns') if lIns is not None: - elem["marginLeft"] = round(int(lIns) / EMU_PER_PX) + elem["marginLeft"] = round(int(lIns) / emu_per_px) if rIns is not None: - elem["marginRight"] = round(int(rIns) / EMU_PER_PX) + elem["marginRight"] = round(int(rIns) / emu_per_px) break except Exception: pass @@ -442,10 +444,10 @@ def _patt_color(tag, default): 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) + 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 @@ -465,10 +467,10 @@ def _patt_color(tag, default): elem = { "type": "image", "src": f"images/{img_name}", - "x": round(shape.left / EMU_PER_PX), - "y": round(shape.top / EMU_PER_PX), - "width": round(shape.width / EMU_PER_PX), - "height": round(shape.height / EMU_PER_PX), + "x": round(shape.left / emu_per_px), + "y": round(shape.top / emu_per_px), + "width": round(shape.width / emu_per_px), + "height": round(shape.height / emu_per_px), } elements.append(elem) img_counter += 1 diff --git a/sdpm/sdpm/engine/converter/table.py b/sdpm/sdpm/engine/converter/table.py index 9285497d..8952954f 100644 --- a/sdpm/sdpm/engine/converter/table.py +++ b/sdpm/sdpm/engine/converter/table.py @@ -4,7 +4,7 @@ import sys import zipfile -from .constants import _NS, EMU_PER_PX, _base_element, _hex +from .constants import _NS, get_emu_per_px, _base_element, _hex from .color import _resolve_scheme_color, _apply_tint, _apply_shade, extract_text_color from .xml_helpers import _extract_fill_from_xml from .text import _extract_styled_text @@ -42,6 +42,7 @@ def _cell_lststyle_color(tc, theme_colors, color_mapping): def _extract_cell(cell, theme_colors=None, color_mapping=None): """Extract cell as string (text only) or dict (has extra properties).""" + emu_per_px = get_emu_per_px() tc = cell._tc tc_pr = tc.find('a:tcPr', _NS) tf = cell.text_frame @@ -149,7 +150,7 @@ def _extract_cell(cell, theme_colors=None, color_mapping=None): for attr, key in [('marL', 'left'), ('marR', 'right'), ('marT', 'top'), ('marB', 'bottom')]: v = tc_pr.get(attr) if v: - padding[key] = round(int(v) / EMU_PER_PX) + padding[key] = round(int(v) / emu_per_px) if padding: props["padding"] = padding @@ -319,14 +320,15 @@ def _apply_style_to_cell(cell_val, style_info): def extract_table_element(shape, theme_colors=None, color_mapping=None, pptx_path=None): """Extract table as element dict with CSS-style property names.""" + emu_per_px = get_emu_per_px() try: table = shape.table tbl_elem = table._tbl elem = _base_element(shape, "table") - elem["colWidths"] = [round(col.width / EMU_PER_PX) for col in table.columns] - elem["rowHeights"] = [round(row.height / EMU_PER_PX) for row in table.rows] + elem["colWidths"] = [round(col.width / emu_per_px) for col in table.columns] + elem["rowHeights"] = [round(row.height / emu_per_px) for row in table.rows] # Read table style properties for style resolution tbl_pr = tbl_elem.find('a:tblPr', _NS) diff --git a/sdpm/sdpm/engine/converter/text.py b/sdpm/sdpm/engine/converter/text.py index 9e1d2b5f..6fa97b0a 100644 --- a/sdpm/sdpm/engine/converter/text.py +++ b/sdpm/sdpm/engine/converter/text.py @@ -1,7 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 """Text extraction and processing.""" -from .constants import _NS, EMU_PER_PX, _serialize_lstStyle +from .constants import _NS, get_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, suppress_inherited=False): @@ -179,15 +179,16 @@ def _has_bullets(paragraphs): def _extract_shape_text(shape, elem, theme_colors, color_mapping=None, builder_text_color=None): """Extract text content from shape into elem dict (items/text, fontSize, align, margins).""" + emu_per_px = get_emu_per_px() tf = shape.text_frame if tf.margin_left is not None and tf.margin_left != 91440: - elem["marginLeft"] = round(tf.margin_left / EMU_PER_PX) + elem["marginLeft"] = round(tf.margin_left / emu_per_px) if tf.margin_top is not None and tf.margin_top != 45720: - elem["marginTop"] = round(tf.margin_top / EMU_PER_PX) + elem["marginTop"] = round(tf.margin_top / emu_per_px) if tf.margin_right is not None and tf.margin_right != 91440: - elem["marginRight"] = round(tf.margin_right / EMU_PER_PX) + elem["marginRight"] = round(tf.margin_right / emu_per_px) if tf.margin_bottom is not None and tf.margin_bottom != 45720: - elem["marginBottom"] = round(tf.margin_bottom / EMU_PER_PX) + elem["marginBottom"] = round(tf.margin_bottom / emu_per_px) if tf.vertical_anchor is not None: _va_reverse = {1: "top", 3: "middle", 4: "bottom"} va = _va_reverse.get(int(tf.vertical_anchor)) diff --git a/sdpm/sdpm/engine/converter/xml_helpers.py b/sdpm/sdpm/engine/converter/xml_helpers.py index a836a49c..e290aae6 100644 --- a/sdpm/sdpm/engine/converter/xml_helpers.py +++ b/sdpm/sdpm/engine/converter/xml_helpers.py @@ -8,7 +8,7 @@ -from .constants import _NS, _hex, EMU_PER_PX +from .constants import _NS, _hex, get_emu_per_px from .color import _resolve_scheme_color, _resolve_color_with_transforms, apply_color_transforms @@ -114,6 +114,7 @@ def _resolve_line_from_style(shape, theme_colors, color_mapping, theme_styles=No def _extract_effects_from_xml(sp_pr, theme_colors=None, color_mapping=None): """Extract visual effects (shadow, glow, softEdge) from spPr XML. Returns dict.""" + emu_per_px = get_emu_per_px() result = {} if sp_pr is None: return result @@ -126,9 +127,9 @@ def _extract_effects_from_xml(sp_pr, theme_colors=None, color_mapping=None): if outer is not None: s = {"type": "outer"} if outer.get('blurRad'): - s["blur"] = round(int(outer.get('blurRad')) / EMU_PER_PX) + s["blur"] = round(int(outer.get('blurRad')) / emu_per_px) if outer.get('dist'): - s["distance"] = round(int(outer.get('dist')) / EMU_PER_PX) + s["distance"] = round(int(outer.get('dist')) / emu_per_px) if outer.get('dir'): s["direction"] = round(int(outer.get('dir')) / 60000) clr = outer.find(f'{{{_NS["a"]}}}srgbClr') @@ -144,9 +145,9 @@ def _extract_effects_from_xml(sp_pr, theme_colors=None, color_mapping=None): if inner is not None: s = {"type": "inner"} if inner.get('blurRad'): - s["blur"] = round(int(inner.get('blurRad')) / EMU_PER_PX) + s["blur"] = round(int(inner.get('blurRad')) / emu_per_px) if inner.get('dist'): - s["distance"] = round(int(inner.get('dist')) / EMU_PER_PX) + s["distance"] = round(int(inner.get('dist')) / emu_per_px) if inner.get('dir'): s["direction"] = round(int(inner.get('dir')) / 60000) clr = inner.find(f'{{{_NS["a"]}}}srgbClr') @@ -162,7 +163,7 @@ def _extract_effects_from_xml(sp_pr, theme_colors=None, color_mapping=None): if glow is not None: g = {} if glow.get('rad'): - g["radius"] = round(int(glow.get('rad')) / EMU_PER_PX) + g["radius"] = round(int(glow.get('rad')) / emu_per_px) g["_radiusEmu"] = int(glow.get('rad')) clr = glow.find(f'{{{_NS["a"]}}}srgbClr') scheme = glow.find(f'{{{_NS["a"]}}}schemeClr') @@ -183,20 +184,20 @@ def _extract_effects_from_xml(sp_pr, theme_colors=None, color_mapping=None): # Soft Edge se = effect_lst.find(f'{{{_NS["a"]}}}softEdge') if se is not None and se.get('rad'): - result["softEdge"] = round(int(se.get('rad')) / EMU_PER_PX) + result["softEdge"] = round(int(se.get('rad')) / emu_per_px) # Reflection ref = effect_lst.find(f'{{{_NS["a"]}}}reflection') if ref is not None: r = {} if ref.get('blurRad'): - r["blur"] = round(int(ref.get('blurRad')) / EMU_PER_PX) + r["blur"] = round(int(ref.get('blurRad')) / emu_per_px) if ref.get('stA'): r["opacity"] = round(int(ref.get('stA')) / 100000, 2) if ref.get('endPos'): r["size"] = round(int(ref.get('endPos')) / 1000) if ref.get('dist') and int(ref.get('dist')) > 0: - r["distance"] = round(int(ref.get('dist')) / EMU_PER_PX) + r["distance"] = round(int(ref.get('dist')) / emu_per_px) result["reflection"] = r return result diff --git a/tests/test_converter_elements.py b/tests/test_converter_elements.py new file mode 100644 index 00000000..3fd41739 --- /dev/null +++ b/tests/test_converter_elements.py @@ -0,0 +1,609 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Characterization tests for converter element extraction (v0.5.2 split safety net). + +These tests pin the exact observable behavior of the element extractors and the +conversion pipeline *before* ``converter/elements.py`` is split into a package. + +RULES (from the v0.5.2 SPEC): +- Expected values below were captured from the pre-split implementation. + DO NOT update them to make a refactoring pass — a mismatch means the + refactoring changed behavior and must be reverted. +- Fixtures are generated programmatically with python-pptx (no opaque binary + fixtures in the repo). + +Covers: +- Direct output contract of the 7 public extractors (+ ``_dispatch_shape``) +- Image/media file naming and the image counter +- Pipeline JSON for a standard 16:9 deck and a non-standard 4:3 deck + (coordinates always normalize to the 1920px basis) +- Import-path and signature compatibility for the surface other modules use +""" + +import inspect +import io +import json + +import pytest +from lxml import etree +from PIL import Image +from pptx import Presentation +from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE +from pptx.util import Emu + +from sdpm.engine.converter.elements import ( + _dispatch_shape, + extract_freeform_element, + extract_group_element, + extract_line_element, + extract_picture_element, + extract_shape_element, + extract_textbox_element, + extract_video_element, +) + +# EMU per px on the 1920px basis for a standard 16:9 deck (12192000 / 1920) +EMU = 6350 + +_A = "http://schemas.openxmlformats.org/drawingml/2006/main" +_P = "http://schemas.openxmlformats.org/presentationml/2006/main" +_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_ASVG = "http://schemas.microsoft.com/office/drawing/2016/SVG/main" +_P14 = "http://schemas.microsoft.com/office/powerpoint/2010/main" + + +@pytest.fixture(autouse=True) +def _restore_converter_scale(): + """Undo any conversion-scale leak between tests. + + The legacy implementation mutates module globals via ``set_emu_per_px``; + a 4:3 pipeline run would poison later direct-extractor tests. Once the + scoped-scale implementation lands this becomes a no-op. + """ + yield + import sdpm.engine.converter.constants as _c + + if hasattr(_c, "set_emu_per_px"): # legacy global-state implementation + _c.set_emu_per_px(1920 * 6350) + + +def _blank_slide(width_emu=12192000, height_emu=6858000): + prs = Presentation() + prs.slide_width = Emu(width_emu) + prs.slide_height = Emu(height_emu) + return prs, prs.slides.add_slide(prs.slide_layouts[6]) + + +def _png_stream(size=(16, 12), color=(200, 30, 30)): + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PNG") + buf.seek(0) + return buf + + +# --------------------------------------------------------------------------- +# Direct extractor contracts +# --------------------------------------------------------------------------- + + +class TestLineExtractor: + def test_straight_connector(self): + _, slide = _blank_slide() + conn = slide.shapes.add_connector( + MSO_CONNECTOR.STRAIGHT, Emu(100 * EMU), Emu(200 * EMU), Emu(500 * EMU), Emu(400 * EMU)) + assert extract_line_element(conn) == { + "type": "line", "x1": 100, "y1": 200, "x2": 500, "y2": 400, + "preset": "line", "color": "none", + } + + def test_flip_and_rotation_baked_into_endpoints(self): + """flipH swaps x endpoints; rot is baked into coordinates (schema has no line rotation).""" + _, slide = _blank_slide() + conn = slide.shapes.add_connector( + MSO_CONNECTOR.STRAIGHT, Emu(100 * EMU), Emu(100 * EMU), Emu(300 * EMU), Emu(200 * EMU)) + xfrm = conn._element.spPr.find(f".//{{{_A}}}xfrm") + xfrm.set("flipH", "1") + xfrm.set("rot", str(90 * 60000)) + assert extract_line_element(conn) == { + "type": "line", "x1": 250, "y1": 250, "x2": 150, "y2": 50, + "preset": "line", "color": "none", + } + + +class TestShapeExtractor: + def test_rounded_rectangle_with_text(self): + _, slide = _blank_slide() + sh = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, Emu(100 * EMU), Emu(100 * EMU), Emu(300 * EMU), Emu(150 * EMU)) + sh.text_frame.text = "Hello" + assert extract_shape_element(sh) == { + "type": "shape", "x": 100, "y": 100, "width": 300, "height": 150, + "shape": "rounded_rectangle", "fill": "none", "line": "#41B3FF", + "lineWidth": 0.5, "verticalAlign": "middle", "text": "Hello", + "fontSize": 18, "align": "left", + } + + def test_rotation(self): + _, slide = _blank_slide() + sh = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Emu(100 * EMU), Emu(100 * EMU), Emu(200 * EMU), Emu(100 * EMU)) + sh.rotation = 45 + assert extract_shape_element(sh) == { + "type": "shape", "x": 100, "y": 100, "width": 200, "height": 100, + "shape": "rectangle", "rotation": 45.0, "fill": "none", + "line": "#41B3FF", "lineWidth": 0.5, + } + + def test_equal_sided_oval_reports_circle(self): + _, slide = _blank_slide() + sh = slide.shapes.add_shape( + MSO_SHAPE.OVAL, Emu(0), Emu(0), Emu(80 * EMU), Emu(80 * EMU)) + assert extract_shape_element(sh)["shape"] == "circle" + + +class TestTextboxExtractor: + def test_single_paragraph(self): + _, slide = _blank_slide() + tb = slide.shapes.add_textbox(Emu(50 * EMU), Emu(60 * EMU), Emu(400 * EMU), Emu(80 * EMU)) + tb.text_frame.text = "Single line" + assert extract_textbox_element(tb) == { + "type": "textbox", "x": 50, "y": 60, "width": 400, "height": 80, + "autoWidth": True, "marginLeft": 14, "marginRight": 14, + "fill": "none", "line": "none", "text": "Single line", + } + + def test_multi_paragraph_with_level(self): + _, slide = _blank_slide() + tb = slide.shapes.add_textbox(Emu(50 * EMU), Emu(200 * EMU), Emu(400 * EMU), Emu(160 * EMU)) + tf = tb.text_frame + tf.text = "First" + p2 = tf.add_paragraph() + p2.text = "Second" + p3 = tf.add_paragraph() + p3.text = "Third" + p3.level = 1 + assert extract_textbox_element(tb) == { + "type": "textbox", "x": 50, "y": 200, "width": 400, "height": 160, + "autoWidth": True, "marginLeft": 14, "marginRight": 14, + "fill": "none", "line": "none", + "paragraphs": [{"text": "First"}, {"text": "Second"}, {"text": "Third", "level": 1}], + } + + def test_preset_geometry_textbox_delegates_to_shape(self): + """A 'textbox' whose spPr carries non-rect preset geometry is really a shape.""" + _, slide = _blank_slide() + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + tb.text_frame.text = "shaped" + prst = tb._element.spPr.find(f"{{{_A}}}prstGeom") + prst.set("prst", "ellipse") + assert extract_textbox_element(tb)["type"] == "shape" + + +class TestFreeformExtractor: + def test_triangle_path(self): + _, slide = _blank_slide() + fb = slide.shapes.build_freeform(Emu(0), Emu(0), scale=1.0) + fb.add_line_segments( + [(Emu(100 * EMU), Emu(0)), (Emu(100 * EMU), Emu(100 * EMU)), (Emu(0), Emu(100 * EMU))], + close=True) + ff = fb.convert_to_shape() + expected_pathlst = ( + '' + '' + '' + '' + '' + ) + assert extract_freeform_element(ff) == { + "type": "freeform", "x": 0, "y": 0, "width": 100, "height": 100, + "_widthEmu": 635000, "_heightEmu": 635000, + "path": [ + {"cmd": "M", "x": 0.0, "y": 0.0}, + {"cmd": "L", "x": 100.0, "y": 0.0}, + {"cmd": "L", "x": 100.0, "y": 100.0}, + {"cmd": "L", "x": 0.0, "y": 100.0}, + {"cmd": "Z"}, + ], + "_pathLstXml": expected_pathlst, + "fill": "none", "line": "none", + } + + +class TestPictureExtractor: + def test_raster_picture_saved_and_named(self, tmp_path): + _, slide = _blank_slide() + pic = slide.shapes.add_picture( + _png_stream(size=(64, 48)), Emu(700 * EMU), Emu(100 * EMU), Emu(128 * EMU), Emu(96 * EMU)) + elem = extract_picture_element(pic, output_dir=tmp_path, slide_idx=0, img_idx=0) + assert elem == { + "type": "image", "x": 700, "y": 100, "width": 128, "height": 96, + "src": "images/slide1_image1.png", "link": None, "fit": "stretch", + } + assert (tmp_path / "images" / "slide1_image1.png").exists() + + def test_svg_picture(self, tmp_path): + _, slide = _blank_slide() + pic = slide.shapes.add_picture( + _png_stream(size=(10, 10)), Emu(100 * EMU), Emu(100 * EMU), Emu(200 * EMU), Emu(200 * EMU)) + svg_bytes = (b'' + b'') + from pptx.opc.package import Part + from pptx.opc.packuri import PackURI + part = Part(PackURI("/ppt/media/image_char.svg"), "image/svg+xml", + slide.part.package, svg_bytes) + rid = slide.part.relate_to(part, f"{_R}/image") + blip = pic._element.find(f".//{{{_A}}}blip") + ext_lst = etree.SubElement(blip, f"{{{_A}}}extLst") + ext = etree.SubElement(ext_lst, f"{{{_A}}}ext") + ext.set("uri", "{96DAC541-7B7A-43D3-8B79-37D633B846F1}") + svg_blip = etree.SubElement(ext, f"{{{_ASVG}}}svgBlip") + svg_blip.set(f"{{{_R}}}embed", rid) + + elem = extract_picture_element(pic, output_dir=tmp_path, slide_idx=0, img_idx=0) + assert elem == { + "type": "image", "x": 100, "y": 100, "width": 200, "height": 200, + "src": "images/slide1_image1.svg", "iconColor": "none", "fit": "stretch", + } + assert (tmp_path / "images" / "slide1_image1.svg").read_bytes() == svg_bytes + + +class _FakeRel: + def __init__(self, target_part=None, target_ref=None): + self.target_part = target_part + self.target_ref = target_ref + + +class _FakePart: + def __init__(self, blob, content_type="video/mp4"): + self.blob = blob + self.content_type = content_type + + +class _FakeVideoPart: + rels = { + "rId10": _FakeRel(target_ref="media/movie.mp4"), + "rId11": _FakeRel(target_part=_FakePart(b"FAKE_MP4_BYTES")), + "rId12": _FakeRel(target_part=_FakePart(b"FAKE_POSTER", "image/jpeg")), + } + + +class _FakeVideoShape: + """Minimal protocol fake — python-pptx has no video authoring API.""" + + shape_type = 16 # MSO_SHAPE_TYPE.MEDIA + left, top, width, height = 100 * EMU, 200 * EMU, 320 * EMU, 180 * EMU + rotation = 0 + part = _FakeVideoPart() + + def __init__(self): + self._element = etree.fromstring( + f'' + '' + '' + '' + "" + '' + f'' + f'' + ) + + +class TestVideoExtractor: + def test_video_file_and_poster_saved(self, tmp_path): + elem = extract_video_element(_FakeVideoShape(), output_dir=tmp_path, slide_idx=0, img_idx=0) + assert elem == { + "type": "video", "x": 100, "y": 200, "width": 320, "height": 180, + "src": "media/slide1_video1.mp4", "poster": "images/slide1_poster1.jpg", + } + assert (tmp_path / "media" / "slide1_video1.mp4").read_bytes() == b"FAKE_MP4_BYTES" + assert (tmp_path / "images" / "slide1_poster1.jpg").read_bytes() == b"FAKE_POSTER" + + +class TestGroupExtractor: + def test_flat_group_children_in_slide_coordinates(self): + _, slide = _blank_slide() + gr = slide.shapes.add_group_shape() + gr.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Emu(1000 * EMU), Emu(500 * EMU), Emu(100 * EMU), Emu(50 * EMU)) + gr.shapes.add_shape( + MSO_SHAPE.OVAL, Emu(1150 * EMU), Emu(500 * EMU), Emu(80 * EMU), Emu(80 * EMU)) + elem, counter = extract_group_element(gr) + assert counter == 0 + assert elem == { + "type": "group", "x": 1000, "y": 500, "width": 230, "height": 80, + "elements": [ + {"type": "shape", "x": 1000, "y": 500, "width": 100, "height": 50, + "shape": "rectangle", "fill": "none", "line": "#41B3FF", "lineWidth": 0.5}, + {"type": "shape", "x": 1150, "y": 500, "width": 80, "height": 80, + "shape": "circle", "fill": "none", "line": "#41B3FF", "lineWidth": 0.5}, + ], + } + + +# --------------------------------------------------------------------------- +# Dispatch routing + image counter +# --------------------------------------------------------------------------- + + +class TestDispatch: + def test_autoshape_routes_to_shape(self): + _, slide = _blank_slide() + sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + elem, counter = _dispatch_shape(sh) + assert elem["type"] == "shape" + assert counter == 0 + + def test_textbox_routes_to_textbox(self): + _, slide = _blank_slide() + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + tb.text_frame.text = "t" + elem, counter = _dispatch_shape(tb) + assert elem["type"] == "textbox" + assert counter == 0 + + def test_line_routes_to_line(self): + _, slide = _blank_slide() + conn = slide.shapes.add_connector( + MSO_CONNECTOR.STRAIGHT, Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + elem, _ = _dispatch_shape(conn) + assert elem["type"] == "line" + + def test_freeform_routes_to_freeform(self): + _, slide = _blank_slide() + fb = slide.shapes.build_freeform(Emu(0), Emu(0), scale=1.0) + fb.add_line_segments( + [(Emu(50 * EMU), Emu(0)), (Emu(50 * EMU), Emu(50 * EMU)), (Emu(0), Emu(50 * EMU))], + close=True) + ff = fb.convert_to_shape() + elem, _ = _dispatch_shape(ff) + assert elem["type"] == "freeform" + + def test_group_routes_recursively(self): + _, slide = _blank_slide() + gr = slide.shapes.add_group_shape() + gr.shapes.add_shape(MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + elem, _ = _dispatch_shape(gr) + assert elem["type"] == "group" + assert [e["type"] for e in elem["elements"]] == ["shape"] + + def test_picture_increments_counter_from_offset(self, tmp_path): + _, slide = _blank_slide() + pic = slide.shapes.add_picture( + _png_stream(), Emu(0), Emu(0), Emu(100 * EMU), Emu(50 * EMU)) + elem, counter = _dispatch_shape(pic, output_dir=tmp_path, slide_idx=0, img_counter=5) + assert elem["src"] == "images/slide1_image6.png" + assert counter == 6 + + def test_media_routes_to_video(self, tmp_path): + elem, counter = _dispatch_shape(_FakeVideoShape(), output_dir=tmp_path, slide_idx=0, img_counter=0) + assert elem["type"] == "video" + assert counter == 1 + + def test_table_routes_to_table(self): + _, slide = _blank_slide() + table_shape = slide.shapes.add_table( + 2, 2, Emu(0), Emu(0), Emu(400 * EMU), Emu(100 * EMU)) + elem, _ = _dispatch_shape(table_shape) + assert elem["type"] == "table" + + def test_chart_routes_to_chart(self): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + _, slide = _blank_slide() + cd = CategoryChartData() + cd.categories = ["c1"] + cd.add_series("s1", (1.0,)) + chart_shape = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, Emu(0), Emu(0), Emu(400 * EMU), Emu(300 * EMU), cd) + elem, _ = _dispatch_shape(chart_shape) + assert elem["type"] == "chart" + + def test_wordart_routes_to_raw_passthrough(self, tmp_path): + _, slide = _blank_slide() + tb = slide.shapes.add_textbox(Emu(10 * EMU), Emu(10 * EMU), Emu(100 * EMU), Emu(50 * EMU)) + tb.text_frame.text = "WordArt" + body_pr = tb._element.find(f".//{{{_A}}}bodyPr") + warp = etree.SubElement(body_pr, f"{{{_A}}}prstTxWarp") + warp.set("prst", "textArchUp") + elem, counter = _dispatch_shape(tb, output_dir=tmp_path, slide_idx=0, img_counter=0) + assert counter == 0 + shape_xml = elem.pop("_shapeXml") + assert shape_xml.startswith("" in chart_xml + assert elements == _EXPECTED_CORPUS_ELEMENTS + + # On-disk deck structure + deck_meta = json.loads((out_dir / "deck.json").read_text()) + assert deck_meta == { + "fonts": {"halfwidth": "Calibri", "fullwidth": ""}, + "defaultTextColor": "#000000", + "autoSpacing": False, + } + assert (out_dir / "slides" / "slide-001.json").exists() + assert sorted(p.name for p in (out_dir / "images").iterdir()) == ["slide1_image1.png"] + + def test_non_standard_4x3_normalizes_to_1920_basis(self, tmp_path): + """A 4:3 deck (9144000 EMU wide) must still map slide-width → 1920px.""" + from sdpm.engine.converter.pipeline import pptx_to_json + + prs, slide = _blank_slide(width_emu=9144000, height_emu=6858000) + emu43 = 9144000 / 1920 + tb = slide.shapes.add_textbox( + Emu(round(480 * emu43)), Emu(round(270 * emu43)), + Emu(round(960 * emu43)), Emu(round(100 * emu43))) + tb.text_frame.text = "centered43" + pptx_path = tmp_path / "deck43.pptx" + prs.save(pptx_path) + + result = pptx_to_json(pptx_path, tmp_path / "out43") + assert result["slides"][0]["elements"] == [{ + "type": "textbox", "x": 480, "y": 270, "width": 960, "height": 100, + "fill": "none", "line": "none", "text": "centered43", + "marginLeft": 19, "marginRight": 19, "autoWidth": True, "_spAutoFit": True, + }] + + +# --------------------------------------------------------------------------- +# Import-path & signature compatibility +# --------------------------------------------------------------------------- + +_PUBLIC_EXTRACTORS = { + "extract_line_element": ["shape", "theme_colors", "color_mapping", "theme_styles"], + "extract_freeform_element": ["shape", "theme_colors", "color_mapping", "builder_text_color"], + "extract_shape_element": ["shape", "theme_colors", "color_mapping", "theme_styles", "builder_text_color"], + "extract_textbox_element": ["shape", "theme_colors", "color_mapping", "theme_styles", + "is_placeholder", "builder_text_color"], + "extract_video_element": ["shape", "output_dir", "slide_idx", "img_idx"], + "extract_picture_element": ["shape", "output_dir", "slide_idx", "img_idx", + "theme_colors", "color_mapping"], + "extract_group_element": ["shape", "theme_colors", "color_mapping", "theme_styles", + "output_dir", "slide_idx", "img_counter", "builder_text_color"], + "_dispatch_shape": ["shape", "theme_colors", "color_mapping", "theme_styles", + "output_dir", "slide_idx", "img_counter", "builder_text_color", "pptx_path"], +} + +# The converter facade re-exports these six (video is deliberately not public there) +_FACADE_EXTRACTORS = [ + "extract_shape_element", "extract_textbox_element", "extract_line_element", + "extract_freeform_element", "extract_picture_element", "extract_group_element", +] + + +class TestImportCompatibility: + @pytest.mark.parametrize("name,params", sorted(_PUBLIC_EXTRACTORS.items())) + def test_elements_module_exposes_function_with_signature(self, name, params): + import sdpm.engine.converter.elements as elements + + fn = getattr(elements, name) + assert list(inspect.signature(fn).parameters) == params + + @pytest.mark.parametrize("name", _FACADE_EXTRACTORS) + def test_converter_facade_reexports_same_objects(self, name): + import sdpm.engine.converter as converter + import sdpm.engine.converter.elements as elements + + assert getattr(converter, name) is getattr(elements, name) + + def test_slide_module_import_surface(self): + """converter.slide imports these directly — they must stay importable.""" + from sdpm.engine.converter.elements import ( # noqa: F401 + _dispatch_shape, + extract_picture_element, + extract_textbox_element, + ) diff --git a/tests/test_converter_scale.py b/tests/test_converter_scale.py new file mode 100644 index 00000000..9ce2536a --- /dev/null +++ b/tests/test_converter_scale.py @@ -0,0 +1,172 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Conversion-scale isolation tests (v0.5.2). + +The converter's px scale (EMU per px on the 1920px basis) used to be a +process-global mutated through ``sys.modules`` patching — a silent-regression +trap for non-standard slide widths and concurrent conversions. It is now a +``ContextVar`` scoped by ``conversion_scale``. These tests pin: + +- default value and scoped set/restore (normal, exception, nested) +- thread isolation (two concurrent conversions with different widths) +- pipeline conversions never leak scale into later direct extractor calls +- AST guards: the legacy mechanisms must not be reintroduced +""" + +import ast +import threading +from pathlib import Path + +import pytest +from pptx import Presentation +from pptx.util import Emu + +from sdpm.engine.converter.constants import conversion_scale, get_emu_per_px + +_DEFAULT = 6350.0 +_W_16X9 = 12192000 # -> 6350.0 +_W_4X3 = 9144000 # -> 4762.5 + + +class TestScopedScale: + def test_default_scale(self): + assert get_emu_per_px() == _DEFAULT + + def test_scope_sets_and_restores(self): + with conversion_scale(_W_4X3): + assert get_emu_per_px() == 4762.5 + assert get_emu_per_px() == _DEFAULT + + def test_scope_restores_on_exception(self): + with pytest.raises(RuntimeError): + with conversion_scale(_W_4X3): + raise RuntimeError("boom") + assert get_emu_per_px() == _DEFAULT + + def test_nested_scopes_restore_outer(self): + with conversion_scale(_W_4X3): + assert get_emu_per_px() == 4762.5 + with conversion_scale(_W_16X9): + assert get_emu_per_px() == 6350.0 + assert get_emu_per_px() == 4762.5 + assert get_emu_per_px() == _DEFAULT + + def test_threads_are_isolated(self): + """Two conversions with different widths must not see each other's scale.""" + barrier = threading.Barrier(2, timeout=10) + results = {} + errors = [] + + def worker(idx, width): + try: + with conversion_scale(width): + barrier.wait() # both threads are inside their scopes now + results[idx] = get_emu_per_px() + barrier.wait() # hold the scope until both have read + except Exception as e: # pragma: no cover - failure reporting + errors.append(e) + + threads = [ + threading.Thread(target=worker, args=(0, _W_16X9)), + threading.Thread(target=worker, args=(1, _W_4X3)), + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + assert not errors + assert results == {0: 6350.0, 1: 4762.5} + + +class TestPipelineScaleIsolation: + def _make_4x3_deck(self, tmp_path): + prs = Presentation() + prs.slide_width = Emu(_W_4X3) + prs.slide_height = Emu(6858000) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + emu43 = _W_4X3 / 1920 + tb = slide.shapes.add_textbox( + Emu(round(100 * emu43)), Emu(round(50 * emu43)), + Emu(round(300 * emu43)), Emu(round(80 * emu43))) + tb.text_frame.text = "43" + path = tmp_path / "deck43.pptx" + prs.save(path) + return path + + def test_non_standard_pipeline_does_not_leak(self, tmp_path): + """After a 4:3 conversion, direct extractor calls are back on the 16:9 basis.""" + from sdpm.engine.converter.pipeline import pptx_to_json + from sdpm.engine.converter.elements import extract_textbox_element + + result = pptx_to_json(self._make_4x3_deck(tmp_path), tmp_path / "out") + elem43 = result["slides"][0]["elements"][0] + assert (elem43["x"], elem43["y"], elem43["width"]) == (100, 50, 300) + + # Scale must be fully restored... + assert get_emu_per_px() == _DEFAULT + # ...so a direct extraction now uses the default 16:9 basis. + prs = Presentation() + prs.slide_width = Emu(_W_16X9) + prs.slide_height = Emu(6858000) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + tb = slide.shapes.add_textbox(Emu(200 * 6350), Emu(100 * 6350), Emu(400 * 6350), Emu(50 * 6350)) + tb.text_frame.text = "after" + elem = extract_textbox_element(tb) + assert (elem["x"], elem["y"], elem["width"]) == (200, 100, 400) + + def test_pipeline_inside_outer_scope_restores_outer(self, tmp_path): + """A conversion nested inside another scale scope restores the outer scale.""" + from sdpm.engine.converter.pipeline import pptx_to_json + + deck = self._make_4x3_deck(tmp_path) + with conversion_scale(_W_16X9): + result = pptx_to_json(deck, tmp_path / "out_nested") + assert result["slides"][0]["elements"][0]["x"] == 100 # inner deck's own basis + assert get_emu_per_px() == 6350.0 # outer scope intact + assert get_emu_per_px() == _DEFAULT + + +# --------------------------------------------------------------------------- +# AST guards: the legacy scale mechanisms must never come back +# --------------------------------------------------------------------------- + +_CONVERTER_DIR = Path(__file__).resolve().parents[1] / "sdpm" / "sdpm" / "engine" / "converter" +_CONVERTER_FILES = sorted(_CONVERTER_DIR.rglob("*.py")) + + +def _is_facade(path: Path) -> bool: + """The package facade may re-export the EMU_PER_PX compat constant.""" + return path.name == "__init__.py" or path.name == "constants.py" + + +def test_converter_files_discovered(): + assert len(_CONVERTER_FILES) >= 10 + + +@pytest.mark.parametrize("path", _CONVERTER_FILES, ids=lambda p: str(p.relative_to(_CONVERTER_DIR))) +def test_no_static_emu_import_in_converter_internals(path): + """Internals must use get_emu_per_px(); an import-time EMU_PER_PX copy would + freeze the default scale and silently break non-standard slide widths.""" + if _is_facade(path): + pytest.skip("facade/constants keep the compat constant") + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + names = [a.name for a in node.names] + assert "EMU_PER_PX" not in names, ( + f"{path.name} line {node.lineno}: import EMU_PER_PX is forbidden — " + "use get_emu_per_px() (scoped scale) instead") + + +@pytest.mark.parametrize("path", _CONVERTER_FILES, ids=lambda p: str(p.relative_to(_CONVERTER_DIR))) +def test_no_sys_modules_patching_in_converter(path): + """The sys.modules module-name patch list (old set_emu_per_px) must not return.""" + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if (isinstance(node, ast.Attribute) and node.attr == "modules" + and isinstance(node.value, ast.Name) and node.value.id == "sys"): + raise AssertionError( + f"{path.name} line {node.lineno}: sys.modules access is forbidden in converter") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + assert node.name != "set_emu_per_px", ( + f"{path.name} line {node.lineno}: set_emu_per_px must not be reintroduced") diff --git a/tests/test_converter_structure.py b/tests/test_converter_structure.py new file mode 100644 index 00000000..55a29365 --- /dev/null +++ b/tests/test_converter_structure.py @@ -0,0 +1,89 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Structural guards for the converter/elements package (v0.5.2 split). + +The package must keep a one-way dependency DAG: + + shapes.py media.py (leaves) + ^ ^ + textbox.py | + ^ | + +---- dispatch.py + ^ + __init__.py (re-export facade only) + +- submodules never import the package facade (no cycles) +- edges outside the table below are a design violation, not a convenience +""" + +import ast +import importlib +from pathlib import Path + +import pytest + +_PKG_DIR = Path(__file__).resolve().parents[1] / "sdpm" / "sdpm" / "engine" / "converter" / "elements" +_PKG_NAME = "sdpm.engine.converter.elements" + +# module -> allowed intra-package imports +_ALLOWED_EDGES = { + "shapes": set(), + "media": set(), + "textbox": {"shapes"}, + "dispatch": {"shapes", "textbox", "media"}, + "__init__": {"shapes", "textbox", "media", "dispatch"}, +} + +_EXPECTED_MODULES = sorted(_ALLOWED_EDGES) + + +def _intra_package_imports(path: Path) -> set[str]: + """Names of sibling modules imported by *path* (resolving relative imports). + + Also returns the sentinel ``""`` if the module imports the + elements package facade itself. + """ + tree = ast.parse(path.read_text()) + hits: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level == 1: # from .x import y (x is a sibling) + if node.module: + hits.add(node.module.split(".")[0]) + else: # from . import x + hits.update(a.name for a in node.names) + elif node.level == 0 and node.module: + if node.module == _PKG_NAME: + hits.add("") + elif node.module.startswith(_PKG_NAME + "."): + hits.add(node.module[len(_PKG_NAME) + 1:].split(".")[0]) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == _PKG_NAME: + hits.add("") + elif alias.name.startswith(_PKG_NAME + "."): + hits.add(alias.name[len(_PKG_NAME) + 1:].split(".")[0]) + return hits + + +def test_package_layout_is_exactly_the_designed_split(): + found = sorted(p.stem for p in _PKG_DIR.glob("*.py")) + assert found == _EXPECTED_MODULES, ( + "converter/elements gained or lost a module — update the design DAG " + "(design.md of SPEC 20260731-2310) and _ALLOWED_EDGES together") + + +@pytest.mark.parametrize("module", _EXPECTED_MODULES) +def test_intra_package_dependencies_follow_the_dag(module): + imports = _intra_package_imports(_PKG_DIR / f"{module}.py") + assert "" not in imports, ( + f"{module}.py imports the elements package facade — that is a cycle") + unexpected = imports - _ALLOWED_EDGES[module] + assert not unexpected, ( + f"{module}.py imports {sorted(unexpected)} — not part of the designed DAG") + + +@pytest.mark.parametrize("module", _EXPECTED_MODULES) +def test_submodule_imports_cleanly(module): + name = _PKG_NAME if module == "__init__" else f"{_PKG_NAME}.{module}" + importlib.import_module(name)