| 1 | """EMU <-> pixel conversion and DrawingML unit constants. |
| 2 | |
| 3 | Mirrors svg_to_pptx/drawingml/utils.py and pptx_package/dimensions.py, in reverse. |
| 4 | |
| 5 | DrawingML unit conventions: |
| 6 | - Coordinates / sizes: EMU (English Metric Unit). 914400 EMU = 1 inch = 96 px. |
| 7 | - Font size: hundredths of a point. 1 px = 0.75 pt = 75 hundredths-of-a-point. |
| 8 | - Angle: 60000ths of a degree. |
| 9 | - Color tint/shade/lumMod/lumOff/satMod: percent in 1000ths (100% = 100000). |
| 10 | - srcRect / fillRect: percent in 1000ths of the unit rect. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | from decimal import Decimal, ROUND_HALF_UP, localcontext |
| 16 | from xml.etree import ElementTree as ET |
| 17 | |
| 18 | EMU_PER_INCH = 914400 |
| 19 | EMU_PER_PX = 9525 # 96 dpi |
| 20 | HUNDREDTHS_PT_PER_PX = 75 # 1 px = 0.75 pt = 75 hundredths |
| 21 | ANGLE_UNIT = 60000 # 1 degree = 60000 angle units |
| 22 | PERCENT_UNIT = 100000 # 100% = 100000 (DrawingML "ST_PositivePercentage") |
| 23 | SRCRECT_UNIT = 100000 # srcRect l/t/r/b are in 1000ths of percent (i.e. 100000 = 100%) |
| 24 | |
| 25 | |
| 26 | # Namespaces used throughout OOXML |
| 27 | NS = { |
| 28 | "a": "http://schemas.openxmlformats.org/drawingml/2006/main", |
| 29 | "p": "http://schemas.openxmlformats.org/presentationml/2006/main", |
| 30 | "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", |
| 31 | "rel": "http://schemas.openxmlformats.org/package/2006/relationships", |
| 32 | "ct": "http://schemas.openxmlformats.org/package/2006/content-types", |
| 33 | "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", |
| 34 | "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", |
| 35 | } |
| 36 | |
| 37 | # Register so ET output emits clean prefixes (writers normally don't need this |
| 38 | # since the SVG output uses the SVG namespace, but keep it consistent). |
| 39 | for prefix, uri in NS.items(): |
| 40 | try: |
| 41 | ET.register_namespace(prefix, uri) |
| 42 | except (ValueError, AttributeError): |
| 43 | pass |
| 44 | |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Length conversions |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | def emu_to_px(emu: float | int | None, default: float = 0.0) -> float: |
| 51 | """Convert EMU to SVG px (96 dpi). None / unparsable -> default.""" |
| 52 | if emu is None: |
| 53 | return default |
| 54 | try: |
| 55 | return float(emu) / EMU_PER_PX |
| 56 | except (ValueError, TypeError): |
| 57 | return default |
| 58 | |
| 59 | |
| 60 | def emu_attr_to_px(elem: ET.Element | None, attr: str, default: float = 0.0) -> float: |
| 61 | """Read EMU integer attribute and return px.""" |
| 62 | if elem is None: |
| 63 | return default |
| 64 | return emu_to_px(elem.get(attr), default) |
| 65 | |
| 66 | |
| 67 | def hundredths_pt_to_px(val: float | int | str | None, default: float = 0.0) -> float: |
| 68 | """Convert font size (a:rPr@sz) to px. 100 = 1 pt = 4/3 px.""" |
| 69 | if val is None: |
| 70 | return default |
| 71 | try: |
| 72 | return float(val) / HUNDREDTHS_PT_PER_PX |
| 73 | except (ValueError, TypeError): |
| 74 | return default |
| 75 | |
| 76 | |
| 77 | def angle_to_deg(val: float | int | str | None, default: float = 0.0) -> float: |
| 78 | """Convert DrawingML angle (1/60000 deg) to plain degrees.""" |
| 79 | if val is None: |
| 80 | return default |
| 81 | try: |
| 82 | return float(val) / ANGLE_UNIT |
| 83 | except (ValueError, TypeError): |
| 84 | return default |
| 85 | |
| 86 | |
| 87 | def percent_to_ratio(val: float | int | str | None, default: float = 0.0) -> float: |
| 88 | """Convert DrawingML percentage units (100000 = 100%) to a ratio.""" |
| 89 | if val is None: |
| 90 | return default |
| 91 | try: |
| 92 | return float(val) / PERCENT_UNIT |
| 93 | except (ValueError, TypeError): |
| 94 | return default |
| 95 | |
| 96 | |
| 97 | def ooxml_bool(value: str | None, default: bool = False) -> bool: |
| 98 | """Parse the boolean lexical forms accepted by OOXML.""" |
| 99 | if value is None: |
| 100 | return default |
| 101 | normalized = value.strip().lower() |
| 102 | if normalized in {"1", "true", "on"}: |
| 103 | return True |
| 104 | if normalized in {"0", "false", "off"}: |
| 105 | return False |
| 106 | return default |
| 107 | |
| 108 | |
| 109 | # --------------------------------------------------------------------------- |
| 110 | # xfrm / transform parsing |
| 111 | # --------------------------------------------------------------------------- |
| 112 | |
| 113 | class Xfrm: |
| 114 | """Resolved <a:xfrm> in pixel space. |
| 115 | |
| 116 | Attributes: |
| 117 | x, y: top-left position (px). |
| 118 | w, h: size (px). |
| 119 | rot: rotation in degrees, clockwise around the shape center. |
| 120 | flip_h: bool — horizontal flip. |
| 121 | flip_v: bool — vertical flip. |
| 122 | ch_x, ch_y, ch_w, ch_h: only set when this is a group <p:grpSpPr>'s |
| 123 | xfrm; describes the child coordinate frame (a:chOff / a:chExt). |
| 124 | None on leaf shapes. |
| 125 | """ |
| 126 | |
| 127 | __slots__ = ("x", "y", "w", "h", "rot", "flip_h", "flip_v", |
| 128 | "ch_x", "ch_y", "ch_w", "ch_h") |
| 129 | |
| 130 | def __init__( |
| 131 | self, |
| 132 | x: float = 0.0, |
| 133 | y: float = 0.0, |
| 134 | w: float = 0.0, |
| 135 | h: float = 0.0, |
| 136 | rot: float = 0.0, |
| 137 | flip_h: bool = False, |
| 138 | flip_v: bool = False, |
| 139 | ch_x: float | None = None, |
| 140 | ch_y: float | None = None, |
| 141 | ch_w: float | None = None, |
| 142 | ch_h: float | None = None, |
| 143 | ) -> None: |
| 144 | self.x = x |
| 145 | self.y = y |
| 146 | self.w = w |
| 147 | self.h = h |
| 148 | self.rot = rot |
| 149 | self.flip_h = flip_h |
| 150 | self.flip_v = flip_v |
| 151 | self.ch_x = ch_x |
| 152 | self.ch_y = ch_y |
| 153 | self.ch_w = ch_w |
| 154 | self.ch_h = ch_h |
| 155 | |
| 156 | def __repr__(self) -> str: |
| 157 | parts = [f"x={self.x:.1f}", f"y={self.y:.1f}", |
| 158 | f"w={self.w:.1f}", f"h={self.h:.1f}"] |
| 159 | if self.rot: |
| 160 | parts.append(f"rot={self.rot:.2f}") |
| 161 | if self.flip_h: |
| 162 | parts.append("flipH") |
| 163 | if self.flip_v: |
| 164 | parts.append("flipV") |
| 165 | return f"Xfrm({', '.join(parts)})" |
| 166 | |
| 167 | def to_svg_transform(self) -> str | None: |
| 168 | """Build SVG transform attribute for rotation / flip around the center. |
| 169 | |
| 170 | Returns None if no rotation / flip is needed. |
| 171 | """ |
| 172 | if not self.rot and not self.flip_h and not self.flip_v: |
| 173 | return None |
| 174 | cx = self.x + self.w / 2.0 |
| 175 | cy = self.y + self.h / 2.0 |
| 176 | parts: list[str] = [] |
| 177 | if self.rot: |
| 178 | parts.append( |
| 179 | f"rotate({_fmt(self.rot, 8)} {_fmt(cx, 8)} {_fmt(cy, 8)})" |
| 180 | ) |
| 181 | if self.flip_h or self.flip_v: |
| 182 | sx = -1 if self.flip_h else 1 |
| 183 | sy = -1 if self.flip_v else 1 |
| 184 | # scale around shape center |
| 185 | parts.append(f"translate({_fmt(cx, 8)} {_fmt(cy, 8)})") |
| 186 | parts.append(f"scale({sx} {sy})") |
| 187 | parts.append(f"translate({_fmt(-cx, 8)} {_fmt(-cy, 8)})") |
| 188 | return " ".join(parts) if parts else None |
| 189 | |
| 190 | |
| 191 | def parse_xfrm(xfrm_elem: ET.Element | None) -> Xfrm: |
| 192 | """Parse <a:xfrm> into an Xfrm object. None -> zero Xfrm.""" |
| 193 | if xfrm_elem is None: |
| 194 | return Xfrm() |
| 195 | |
| 196 | rot = angle_to_deg(xfrm_elem.get("rot")) |
| 197 | flip_h = ooxml_bool(xfrm_elem.get("flipH")) |
| 198 | flip_v = ooxml_bool(xfrm_elem.get("flipV")) |
| 199 | |
| 200 | off = xfrm_elem.find("a:off", NS) |
| 201 | ext = xfrm_elem.find("a:ext", NS) |
| 202 | ch_off = xfrm_elem.find("a:chOff", NS) |
| 203 | ch_ext = xfrm_elem.find("a:chExt", NS) |
| 204 | |
| 205 | x = emu_attr_to_px(off, "x") |
| 206 | y = emu_attr_to_px(off, "y") |
| 207 | w = emu_attr_to_px(ext, "cx") |
| 208 | h = emu_attr_to_px(ext, "cy") |
| 209 | |
| 210 | ch_x = emu_attr_to_px(ch_off, "x") if ch_off is not None else None |
| 211 | ch_y = emu_attr_to_px(ch_off, "y") if ch_off is not None else None |
| 212 | ch_w = emu_attr_to_px(ch_ext, "cx") if ch_ext is not None else None |
| 213 | ch_h = emu_attr_to_px(ch_ext, "cy") if ch_ext is not None else None |
| 214 | |
| 215 | return Xfrm(x=x, y=y, w=w, h=h, rot=rot, |
| 216 | flip_h=flip_h, flip_v=flip_v, |
| 217 | ch_x=ch_x, ch_y=ch_y, ch_w=ch_w, ch_h=ch_h) |
| 218 | |
| 219 | |
| 220 | # --------------------------------------------------------------------------- |
| 221 | # Number formatting for SVG output |
| 222 | # --------------------------------------------------------------------------- |
| 223 | |
| 224 | def _fmt(val: float, ndigits: int = 2) -> str: |
| 225 | """Format a number for SVG attributes: trim trailing zeros, keep ints clean.""" |
| 226 | if val == 0: |
| 227 | return "0" |
| 228 | rounded = round(val, ndigits) |
| 229 | if rounded == int(rounded): |
| 230 | return str(int(rounded)) |
| 231 | s = f"{rounded:.{ndigits}f}" |
| 232 | # trim trailing zeros after decimal |
| 233 | if "." in s: |
| 234 | s = s.rstrip("0").rstrip(".") |
| 235 | return s |
| 236 | |
| 237 | |
| 238 | fmt_num = _fmt |
| 239 | |
| 240 | |
| 241 | def format_ooxml_unit_ratio(value: float) -> str: |
| 242 | """Format a normalized ratio without losing 1/100000 OOXML precision.""" |
| 243 | return _fmt(value, 5) |
| 244 | |
| 245 | |
| 246 | def format_ooxml_alpha(alpha: float) -> str: |
| 247 | """Format one normalized OOXML alpha ratio.""" |
| 248 | return format_ooxml_unit_ratio(alpha) |
| 249 | |
| 250 | |
| 251 | def format_canvas_px_from_emu(emu: int) -> str: |
| 252 | """Format a slide-size coordinate with enough precision to recover EMU.""" |
| 253 | with localcontext() as context: |
| 254 | context.prec = 32 |
| 255 | value = Decimal(emu) / Decimal(EMU_PER_PX) |
| 256 | rounded = value.quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP) |
| 257 | token = format(rounded, "f").rstrip("0").rstrip(".") |
| 258 | return token or "0" |
| 259 |