返回 ppt-master
emu_units.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / emu_units.py
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
259 lines PYTHON