"""EMU <-> pixel conversion and DrawingML unit constants. Mirrors svg_to_pptx/drawingml/utils.py and pptx_package/dimensions.py, in reverse. DrawingML unit conventions: - Coordinates / sizes: EMU (English Metric Unit). 914400 EMU = 1 inch = 96 px. - Font size: hundredths of a point. 1 px = 0.75 pt = 75 hundredths-of-a-point. - Angle: 60000ths of a degree. - Color tint/shade/lumMod/lumOff/satMod: percent in 1000ths (100% = 100000). - srcRect / fillRect: percent in 1000ths of the unit rect. """ from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP, localcontext from xml.etree import ElementTree as ET EMU_PER_INCH = 914400 EMU_PER_PX = 9525 # 96 dpi HUNDREDTHS_PT_PER_PX = 75 # 1 px = 0.75 pt = 75 hundredths ANGLE_UNIT = 60000 # 1 degree = 60000 angle units PERCENT_UNIT = 100000 # 100% = 100000 (DrawingML "ST_PositivePercentage") SRCRECT_UNIT = 100000 # srcRect l/t/r/b are in 1000ths of percent (i.e. 100000 = 100%) # Namespaces used throughout OOXML 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", "rel": "http://schemas.openxmlformats.org/package/2006/relationships", "ct": "http://schemas.openxmlformats.org/package/2006/content-types", "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", } # Register so ET output emits clean prefixes (writers normally don't need this # since the SVG output uses the SVG namespace, but keep it consistent). for prefix, uri in NS.items(): try: ET.register_namespace(prefix, uri) except (ValueError, AttributeError): pass # --------------------------------------------------------------------------- # Length conversions # --------------------------------------------------------------------------- def emu_to_px(emu: float | int | None, default: float = 0.0) -> float: """Convert EMU to SVG px (96 dpi). None / unparsable -> default.""" if emu is None: return default try: return float(emu) / EMU_PER_PX except (ValueError, TypeError): return default def emu_attr_to_px(elem: ET.Element | None, attr: str, default: float = 0.0) -> float: """Read EMU integer attribute and return px.""" if elem is None: return default return emu_to_px(elem.get(attr), default) def hundredths_pt_to_px(val: float | int | str | None, default: float = 0.0) -> float: """Convert font size (a:rPr@sz) to px. 100 = 1 pt = 4/3 px.""" if val is None: return default try: return float(val) / HUNDREDTHS_PT_PER_PX except (ValueError, TypeError): return default def angle_to_deg(val: float | int | str | None, default: float = 0.0) -> float: """Convert DrawingML angle (1/60000 deg) to plain degrees.""" if val is None: return default try: return float(val) / ANGLE_UNIT except (ValueError, TypeError): return default def percent_to_ratio(val: float | int | str | None, default: float = 0.0) -> float: """Convert DrawingML percentage units (100000 = 100%) to a ratio.""" if val is None: return default try: return float(val) / PERCENT_UNIT except (ValueError, TypeError): return default def ooxml_bool(value: str | None, default: bool = False) -> bool: """Parse the boolean lexical forms accepted by OOXML.""" if value is None: return default normalized = value.strip().lower() if normalized in {"1", "true", "on"}: return True if normalized in {"0", "false", "off"}: return False return default # --------------------------------------------------------------------------- # xfrm / transform parsing # --------------------------------------------------------------------------- class Xfrm: """Resolved in pixel space. Attributes: x, y: top-left position (px). w, h: size (px). rot: rotation in degrees, clockwise around the shape center. flip_h: bool — horizontal flip. flip_v: bool — vertical flip. ch_x, ch_y, ch_w, ch_h: only set when this is a group 's xfrm; describes the child coordinate frame (a:chOff / a:chExt). None on leaf shapes. """ __slots__ = ("x", "y", "w", "h", "rot", "flip_h", "flip_v", "ch_x", "ch_y", "ch_w", "ch_h") def __init__( self, x: float = 0.0, y: float = 0.0, w: float = 0.0, h: float = 0.0, rot: float = 0.0, flip_h: bool = False, flip_v: bool = False, ch_x: float | None = None, ch_y: float | None = None, ch_w: float | None = None, ch_h: float | None = None, ) -> None: self.x = x self.y = y self.w = w self.h = h self.rot = rot self.flip_h = flip_h self.flip_v = flip_v self.ch_x = ch_x self.ch_y = ch_y self.ch_w = ch_w self.ch_h = ch_h def __repr__(self) -> str: parts = [f"x={self.x:.1f}", f"y={self.y:.1f}", f"w={self.w:.1f}", f"h={self.h:.1f}"] if self.rot: parts.append(f"rot={self.rot:.2f}") if self.flip_h: parts.append("flipH") if self.flip_v: parts.append("flipV") return f"Xfrm({', '.join(parts)})" def to_svg_transform(self) -> str | None: """Build SVG transform attribute for rotation / flip around the center. Returns None if no rotation / flip is needed. """ if not self.rot and not self.flip_h and not self.flip_v: return None cx = self.x + self.w / 2.0 cy = self.y + self.h / 2.0 parts: list[str] = [] if self.rot: parts.append( f"rotate({_fmt(self.rot, 8)} {_fmt(cx, 8)} {_fmt(cy, 8)})" ) if self.flip_h or self.flip_v: sx = -1 if self.flip_h else 1 sy = -1 if self.flip_v else 1 # scale around shape center parts.append(f"translate({_fmt(cx, 8)} {_fmt(cy, 8)})") parts.append(f"scale({sx} {sy})") parts.append(f"translate({_fmt(-cx, 8)} {_fmt(-cy, 8)})") return " ".join(parts) if parts else None def parse_xfrm(xfrm_elem: ET.Element | None) -> Xfrm: """Parse into an Xfrm object. None -> zero Xfrm.""" if xfrm_elem is None: return Xfrm() rot = angle_to_deg(xfrm_elem.get("rot")) flip_h = ooxml_bool(xfrm_elem.get("flipH")) flip_v = ooxml_bool(xfrm_elem.get("flipV")) off = xfrm_elem.find("a:off", NS) ext = xfrm_elem.find("a:ext", NS) ch_off = xfrm_elem.find("a:chOff", NS) ch_ext = xfrm_elem.find("a:chExt", NS) x = emu_attr_to_px(off, "x") y = emu_attr_to_px(off, "y") w = emu_attr_to_px(ext, "cx") h = emu_attr_to_px(ext, "cy") ch_x = emu_attr_to_px(ch_off, "x") if ch_off is not None else None ch_y = emu_attr_to_px(ch_off, "y") if ch_off is not None else None ch_w = emu_attr_to_px(ch_ext, "cx") if ch_ext is not None else None ch_h = emu_attr_to_px(ch_ext, "cy") if ch_ext is not None else None return Xfrm(x=x, y=y, w=w, h=h, rot=rot, flip_h=flip_h, flip_v=flip_v, ch_x=ch_x, ch_y=ch_y, ch_w=ch_w, ch_h=ch_h) # --------------------------------------------------------------------------- # Number formatting for SVG output # --------------------------------------------------------------------------- def _fmt(val: float, ndigits: int = 2) -> str: """Format a number for SVG attributes: trim trailing zeros, keep ints clean.""" if val == 0: return "0" rounded = round(val, ndigits) if rounded == int(rounded): return str(int(rounded)) s = f"{rounded:.{ndigits}f}" # trim trailing zeros after decimal if "." in s: s = s.rstrip("0").rstrip(".") return s fmt_num = _fmt def format_ooxml_unit_ratio(value: float) -> str: """Format a normalized ratio without losing 1/100000 OOXML precision.""" return _fmt(value, 5) def format_ooxml_alpha(alpha: float) -> str: """Format one normalized OOXML alpha ratio.""" return format_ooxml_unit_ratio(alpha) def format_canvas_px_from_emu(emu: int) -> str: """Format a slide-size coordinate with enough precision to recover EMU.""" with localcontext() as context: context.prec = 32 value = Decimal(emu) / Decimal(EMU_PER_PX) rounded = value.quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP) token = format(rounded, "f").rstrip("0").rstrip(".") return token or "0"