返回 ppt-master
pic_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / pic_to_svg.py
1 """DrawingML <p:pic> -> SVG <image> conversion.
2
3 Reverse of svg_to_pptx convert_image.
4
5 DrawingML structure:
6 <p:pic>
7 <p:blipFill>
8 <a:blip r:embed="rIdRaster">
9 <a:extLst>...<asvg:svgBlip r:embed="rIdSvg"/>...</a:extLst>
10 </a:blip>
11 <a:srcRect l/t/r/b="1/100000"/> (optional crop)
12 <a:stretch><a:fillRect/></a:stretch> (default: fill the shape)
13 </p:blipFill>
14 <p:spPr>
15 <a:xfrm/>
16 <a:prstGeom prst="rect"/> (usually rect; can be other)
17 </p:spPr>
18 </p:pic>
19
20 Strategy:
21 - Prefer the editable SVG relationship in asvg:svgBlip when Office also stores
22 a raster compatibility preview on a:blip; retain the raster as fallback.
23 - Default (no srcRect, plain stretch) -> a single <image> filling the box,
24 preserveAspectRatio="none".
25 - With srcRect, or with a single oversized tile that covers the frame -> wrap
26 the <image> in a nested <svg viewBox> in the unit rectangle [0,1] x [0,1],
27 with overflow hidden so cropping is expressed identically in browsers and
28 PowerPoint.
29 - Repeating tile fills still use the legacy plain-image fallback; a repeated
30 pattern cannot be represented by the project's native picture-crop subset.
31 - Image bytes are written through the result; the slide assembler decides
32 the href format (external file vs base64).
33 """
34
35 from __future__ import annotations
36
37 import base64
38 import hashlib
39 import io
40 import math
41 import mimetypes
42 import shutil
43 import subprocess
44 import tempfile
45 from dataclasses import dataclass, field
46 from pathlib import Path
47 from xml.etree import ElementTree as ET
48
49 try:
50 from PIL import Image, ImageEnhance
51 except ImportError: # pragma: no cover - optional visual enhancement dependency
52 Image = None
53 ImageEnhance = None
54
55 from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha
56 from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids
57
58
59 @dataclass(frozen=True)
60 class PictureDiagnostic:
61 """Recoverable loss while converting one DrawingML picture."""
62
63 code: str
64 message: str
65 fallback: str
66
67
68 @dataclass
69 class PictureResult:
70 """Resolved picture: SVG element string + extracted media bytes."""
71
72 svg: str = ""
73 # Map of {filename: bytes} that the assembler should emit alongside
74 # the SVG. Filename is the basename inside the package's media dir.
75 media: dict[str, bytes] = field(default_factory=dict)
76 diagnostics: tuple[PictureDiagnostic, ...] = ()
77
78
79 class MediaResolutionError(RuntimeError):
80 """Raised when a PPTX media relationship cannot be reproduced as SVG."""
81
82
83 def convert_blip_fill(
84 blip_fill_elem: ET.Element,
85 xfrm: Xfrm,
86 slide_part: PartRef,
87 pkg: OoxmlPackage,
88 *,
89 media_subdir: str = "assets",
90 embed_inline: bool = False,
91 asset_name_map: dict[str, str] | None = None,
92 strict: bool = False,
93 ) -> PictureResult:
94 """Convert an <a:blipFill> element to SVG <image>.
95
96 Handles image fill for both:
97 - <p:pic><p:blipFill> (standard picture elements)
98 - <p:sp><p:spPr><a:blipFill> (shape with image fill, e.g. Canva exports)
99 """
100 blip = blip_fill_elem.find("a:blip", NS)
101 if blip is None:
102 return PictureResult()
103
104 relationship_ids = blip_embed_relationship_ids(blip)
105 linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
106 if not relationship_ids:
107 if linked_rid:
108 raise MediaResolutionError(
109 "Linked image relationships are not supported; embed the image in PowerPoint first"
110 )
111 return PictureResult()
112
113 target: str | None = None
114 img_bytes: bytes | None = None
115 failures: list[str] = []
116 for rel_id in relationship_ids:
117 candidate = slide_part.resolve_rel(rel_id)
118 if not candidate:
119 failures.append(f"{rel_id}: unresolved")
120 continue
121 candidate_bytes = pkg.read_media(candidate)
122 if candidate_bytes is None:
123 failures.append(f"{rel_id}: missing {candidate}")
124 continue
125 if Path(candidate).suffix.lower() == ".svg" and not _is_valid_svg_media(candidate_bytes):
126 failures.append(f"{rel_id}: invalid SVG media {candidate}")
127 continue
128 target = candidate
129 img_bytes = candidate_bytes
130 break
131 if target is None or img_bytes is None:
132 details = "; ".join(failures)
133 raise MediaResolutionError(
134 f"No embedded image relationship can be read in {slide_part.path}: {details}"
135 )
136
137 filename = (asset_name_map or {}).get(target, pkg.media_filename(target))
138 filename, img_bytes = _normalize_office_media(filename, img_bytes)
139 tile_source_bytes = img_bytes
140 diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
141 filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
142 filename,
143 img_bytes,
144 blip,
145 )
146 diagnostics.extend(effect_diagnostics)
147 opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip)
148 diagnostics.extend(opacity_diagnostics)
149 if strict and diagnostics:
150 details = "; ".join(item.message for item in diagnostics)
151 raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}")
152 href = _build_href(filename, img_bytes, media_subdir, embed_inline)
153
154 # srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
155 src_rect = blip_fill_elem.find("a:srcRect", NS)
156 crop = _parse_src_rect(src_rect)
157
158 # A tile larger than its frame is visually a crop, not a stretch. Preserve
159 # that common PowerPoint background construction through the same nested
160 # SVG transport used for srcRect. True repeated patterns retain the legacy
161 # plain-image fallback because native export has no registered tile subset.
162 tile = blip_fill_elem.find("a:tile", NS)
163 if crop is None and tile is not None:
164 crop = _single_cover_tile_crop(
165 tile,
166 blip_fill_elem,
167 xfrm,
168 tile_source_bytes,
169 )
170
171 if crop is None:
172 # Plain unclipped image
173 svg = (
174 f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
175 f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
176 f'preserveAspectRatio="none"{opacity_attr}/>'
177 )
178 else:
179 # Crop expressed as a unit-rectangle viewBox on a nested <svg>.
180 vb_l, vb_t, vb_w, vb_h = crop
181 svg = (
182 f'<svg x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
183 f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
184 f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} '
185 f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
186 f'preserveAspectRatio="none" overflow="hidden">'
187 f'<image href="{href}" x="0" y="0" width="1" height="1" '
188 f'preserveAspectRatio="none"{opacity_attr}/>'
189 f"</svg>"
190 )
191
192 media: dict[str, bytes] = {}
193 if not embed_inline:
194 media[filename] = img_bytes
195 return PictureResult(
196 svg=svg,
197 media=media,
198 diagnostics=tuple(diagnostics),
199 )
200
201
202 def convert_picture(
203 pic_elem: ET.Element,
204 xfrm: Xfrm,
205 slide_part: PartRef,
206 pkg: OoxmlPackage,
207 *,
208 media_subdir: str = "assets",
209 embed_inline: bool = False,
210 asset_name_map: dict[str, str] | None = None,
211 strict: bool = False,
212 ) -> PictureResult:
213 """Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
214 blip_fill = pic_elem.find("p:blipFill", NS)
215 if blip_fill is None:
216 return PictureResult()
217
218 return convert_blip_fill(
219 blip_fill, xfrm, slide_part, pkg,
220 media_subdir=media_subdir,
221 embed_inline=embed_inline,
222 asset_name_map=asset_name_map,
223 strict=strict,
224 )
225
226
227 # ---------------------------------------------------------------------------
228 # Helpers
229 # ---------------------------------------------------------------------------
230
231
232 def _blip_opacity_attr(
233 blip: ET.Element,
234 ) -> tuple[str, tuple[PictureDiagnostic, ...]]:
235 """Translate DrawingML fixed image alpha to an SVG opacity attribute."""
236 alpha_effects = blip.findall("a:alphaModFix", NS)
237 if not alpha_effects:
238 return "", ()
239 if len(alpha_effects) > 1:
240 return "", (
241 _effect_diagnostic(
242 "duplicate a:alphaModFix effects cannot be reproduced safely"
243 ),
244 )
245 alpha = alpha_effects[0]
246 try:
247 opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0
248 except ValueError:
249 return "", (
250 _effect_diagnostic(
251 f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}"
252 ),
253 )
254 if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
255 return "", (
256 _effect_diagnostic(
257 f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}"
258 ),
259 )
260 if opacity >= 1.0:
261 return "", ()
262 return f' opacity="{format_ooxml_alpha(opacity)}"', ()
263
264
265 def _effect_diagnostic(message: str) -> PictureDiagnostic:
266 return PictureDiagnostic(
267 code="image-effect-omitted",
268 message=message,
269 fallback="retain the source image and omit only this image effect",
270 )
271
272
273 def _unsupported_blip_effect_diagnostics(
274 blip: ET.Element,
275 ) -> tuple[PictureDiagnostic, ...]:
276 """Report direct a:blip effects outside the implemented subset."""
277 supported_tags = {
278 f"{{{NS['a']}}}lum",
279 f"{{{NS['a']}}}alphaModFix",
280 f"{{{NS['a']}}}extLst",
281 }
282 unsupported = sorted({
283 child.tag.rsplit("}", 1)[-1]
284 for child in blip
285 if child.tag not in supported_tags
286 })
287 diagnostics = (
288 [
289 _effect_diagnostic(
290 "unsupported direct a:blip effect(s): "
291 + ", ".join(f"a:{name}" for name in unsupported)
292 )
293 ]
294 if unsupported
295 else []
296 )
297 if len(blip.findall("a:lum", NS)) > 1:
298 diagnostics.append(
299 _effect_diagnostic(
300 "duplicate a:lum effects cannot be reproduced safely"
301 )
302 )
303 return tuple(diagnostics)
304
305 _OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
306
307
308 def _is_valid_svg_media(data: bytes) -> bool:
309 """Return whether one media part is a namespaced SVG document."""
310 try:
311 root = ET.fromstring(data)
312 except ET.ParseError:
313 return False
314 return root.tag == "{http://www.w3.org/2000/svg}svg"
315
316
317 def _normalize_office_media(filename: str, img_bytes: bytes) -> tuple[str, bytes]:
318 """Convert Office-only vector image formats to browser-renderable PNG.
319
320 PPTX can contain EMF/WMF assets that PowerPoint renders natively but SVG
321 viewers generally do not. Keep the original asset in the manifest layer;
322 the SVG view uses a PNG preview when the local system can make one.
323 """
324 suffix = Path(filename).suffix.lower()
325 if suffix not in _OFFICE_VECTOR_EXTS:
326 return filename, img_bytes
327
328 converted = _convert_office_vector_to_png(filename, img_bytes)
329 if converted is None:
330 return filename, img_bytes
331 stem = Path(filename).stem
332 return f"{stem}_preview.png", converted
333
334
335 def _convert_office_vector_to_png(filename: str, img_bytes: bytes) -> bytes | None:
336 magick = shutil.which("magick")
337 if not magick:
338 return None
339 suffix = Path(filename).suffix.lower() or ".bin"
340 with tempfile.TemporaryDirectory() as tmp:
341 tmp_dir = Path(tmp)
342 src = tmp_dir / f"source{suffix}"
343 dst = tmp_dir / "preview.png"
344 src.write_bytes(img_bytes)
345 try:
346 subprocess.run(
347 [magick, str(src), str(dst)],
348 check=True,
349 stdout=subprocess.DEVNULL,
350 stderr=subprocess.DEVNULL,
351 )
352 except (OSError, subprocess.CalledProcessError):
353 return None
354 if not dst.exists():
355 return None
356 return dst.read_bytes()
357
358 def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float] | None:
359 """Convert <a:srcRect l t r b="1/100000"/> to (x, y, w, h) in unit space."""
360 if elem is None:
361 return None
362 if not (elem.attrib.keys() & {"l", "t", "r", "b"}):
363 return None
364 l = _pct_attr(elem, "l")
365 t = _pct_attr(elem, "t")
366 r = _pct_attr(elem, "r")
367 b = _pct_attr(elem, "b")
368 # All zero -> equivalent to no crop
369 if l == 0 and t == 0 and r == 0 and b == 0:
370 return None
371 vb_x = l
372 vb_y = t
373 vb_w = max(0.0, 1.0 - l - r)
374 vb_h = max(0.0, 1.0 - t - b)
375 if vb_w <= 0 or vb_h <= 0:
376 return None
377 return vb_x, vb_y, vb_w, vb_h
378
379
380 def _single_cover_tile_crop(
381 tile: ET.Element,
382 blip_fill: ET.Element,
383 xfrm: Xfrm,
384 img_bytes: bytes,
385 ) -> tuple[float, float, float, float] | None:
386 """Map one oversized DrawingML tile to a unit-image crop window.
387
388 The closed SVG/PPTX crop transport cannot express a repeating pattern. It
389 can, however, reproduce a tile when the aligned first tile alone covers the
390 complete frame. This is how PowerPoint commonly stores full-page bitmap
391 backgrounds without distorting their aspect ratio.
392 """
393 if Image is None or xfrm.w <= 0 or xfrm.h <= 0:
394 return None
395 if tile.attrib.get("flip", "none") != "none":
396 return None
397
398 natural_size = _image_size_at_96_dpi(img_bytes, blip_fill)
399 if natural_size is None:
400 return None
401 natural_w, natural_h = natural_size
402 scale_x = _pct_attr_default(tile, "sx", 1.0)
403 scale_y = _pct_attr_default(tile, "sy", 1.0)
404 tile_w = natural_w * scale_x
405 tile_h = natural_h * scale_y
406 if tile_w <= 0 or tile_h <= 0:
407 return None
408
409 align_x, align_y = _tile_alignment(tile.attrib.get("algn", "tl"))
410 tile_x = (
411 xfrm.x
412 + (xfrm.w - tile_w) * align_x
413 + emu_to_px(tile.attrib.get("tx"))
414 )
415 tile_y = (
416 xfrm.y
417 + (xfrm.h - tile_h) * align_y
418 + emu_to_px(tile.attrib.get("ty"))
419 )
420
421 tolerance = 1e-4
422 if (
423 tile_x > xfrm.x + tolerance
424 or tile_y > xfrm.y + tolerance
425 or tile_x + tile_w < xfrm.x + xfrm.w - tolerance
426 or tile_y + tile_h < xfrm.y + xfrm.h - tolerance
427 ):
428 return None
429
430 crop = (
431 (xfrm.x - tile_x) / tile_w,
432 (xfrm.y - tile_y) / tile_h,
433 xfrm.w / tile_w,
434 xfrm.h / tile_h,
435 )
436 if all(
437 abs(actual - expected) <= 1e-7
438 for actual, expected in zip(crop, (0.0, 0.0, 1.0, 1.0))
439 ):
440 return None
441 return crop
442
443
444 def _image_size_at_96_dpi(
445 img_bytes: bytes,
446 blip_fill: ET.Element,
447 ) -> tuple[float, float] | None:
448 """Return the bitmap's physical size in the SVG canvas' 96-DPI pixels."""
449 try:
450 with Image.open(io.BytesIO(img_bytes)) as image:
451 pixel_w, pixel_h = image.size
452 embedded_dpi = image.info.get("dpi")
453 except (OSError, ValueError):
454 return None
455
456 configured_dpi = _positive_float(blip_fill.attrib.get("dpi"))
457 if configured_dpi is not None:
458 dpi_x = dpi_y = configured_dpi
459 else:
460 dpi_x, dpi_y = _dpi_pair(embedded_dpi)
461 return pixel_w * 96.0 / dpi_x, pixel_h * 96.0 / dpi_y
462
463
464 def _dpi_pair(value: object) -> tuple[float, float]:
465 if isinstance(value, (tuple, list)) and len(value) >= 2:
466 dpi_x = _positive_float(value[0]) or 96.0
467 dpi_y = _positive_float(value[1]) or 96.0
468 return dpi_x, dpi_y
469 dpi = _positive_float(value) or 96.0
470 return dpi, dpi
471
472
473 def _positive_float(value: object) -> float | None:
474 try:
475 parsed = float(value)
476 except (TypeError, ValueError):
477 return None
478 return parsed if parsed > 0 else None
479
480
481 def _pct_attr_default(elem: ET.Element, name: str, default: float) -> float:
482 value = elem.attrib.get(name)
483 if value is None:
484 return default
485 try:
486 return float(value) / 100000.0
487 except ValueError:
488 return default
489
490
491 def _tile_alignment(value: str) -> tuple[float, float]:
492 alignments = {
493 "tl": (0.0, 0.0),
494 "t": (0.5, 0.0),
495 "tr": (1.0, 0.0),
496 "l": (0.0, 0.5),
497 "ctr": (0.5, 0.5),
498 "r": (1.0, 0.5),
499 "bl": (0.0, 1.0),
500 "b": (0.5, 1.0),
501 "br": (1.0, 1.0),
502 }
503 return alignments.get(value, alignments["tl"])
504
505
506 def _apply_blip_image_effects(
507 filename: str,
508 img_bytes: bytes,
509 blip: ET.Element,
510 ) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]:
511 """Bake supported DrawingML blip effects into extracted image bytes.
512
513 Brightness and contrast are pixel operations, not picture-shape
514 shadow/glow effects, so preserve them in the extracted bitmap.
515 """
516 lum_effects = blip.findall("a:lum", NS)
517 if not lum_effects:
518 return filename, img_bytes, ()
519 if len(lum_effects) > 1:
520 return filename, img_bytes, ()
521 lum = lum_effects[0]
522
523 values: dict[str, float | None] = {}
524 for name in ("bright", "contrast"):
525 raw_value = lum.attrib.get(name)
526 if raw_value is None:
527 values[name] = None
528 continue
529 try:
530 value = float(raw_value) / 100000.0
531 except ValueError:
532 return filename, img_bytes, (
533 _effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"),
534 )
535 if not math.isfinite(value) or not -1.0 <= value <= 1.0:
536 return filename, img_bytes, (
537 _effect_diagnostic(
538 f"out-of-range a:lum {name}={raw_value!r}"
539 ),
540 )
541 values[name] = value
542 bright = values["bright"]
543 contrast = values["contrast"]
544 if bright is None and contrast is None:
545 return filename, img_bytes, ()
546 if Image is None or ImageEnhance is None:
547 return filename, img_bytes, (
548 _effect_diagnostic(
549 "a:lum requires Pillow, but Pillow is unavailable"
550 ),
551 )
552
553 try:
554 with Image.open(io.BytesIO(img_bytes)) as source_image:
555 if getattr(source_image, "is_animated", False):
556 return filename, img_bytes, (
557 _effect_diagnostic(
558 "a:lum on an animated image would flatten its frames"
559 ),
560 )
561 output_format = (
562 source_image.format or _pil_format_from_filename(filename)
563 )
564 image = source_image.copy()
565 if image.mode not in ("RGB", "RGBA"):
566 image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
567 if bright is not None:
568 image = ImageEnhance.Brightness(image).enhance(max(0.0, 1.0 + bright))
569 if contrast is not None:
570 image = ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 + contrast))
571
572 out = io.BytesIO()
573 save_format = output_format or "PNG"
574 save_kwargs = {"quality": 95} if save_format.upper() in {"JPEG", "JPG"} else {}
575 image.save(out, format=save_format, **save_kwargs)
576 effect_key = f"lum-{bright}-{contrast}".encode("ascii")
577 digest = hashlib.sha1(effect_key).hexdigest()[:8]
578 return (
579 _effect_filename(filename, digest, save_format),
580 out.getvalue(),
581 (),
582 )
583 except (KeyError, OSError, ValueError) as exc:
584 return filename, img_bytes, (
585 _effect_diagnostic(f"a:lum could not be rendered: {exc}"),
586 )
587
588
589 def _pil_format_from_filename(filename: str) -> str | None:
590 ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
591 if ext in {"jpg", "jpeg"}:
592 return "JPEG"
593 if ext == "png":
594 return "PNG"
595 if ext == "gif":
596 return "GIF"
597 if ext == "webp":
598 return "WEBP"
599 return None
600
601
602 def _effect_filename(filename: str, digest: str, image_format: str) -> str:
603 stem, sep, ext = filename.rpartition(".")
604 if not sep:
605 ext = (image_format or "png").lower()
606 stem = filename
607 if ext.lower() == "jpg":
608 ext = "jpeg"
609 return f"{stem}_fx_{digest}.{ext}"
610
611
612 def _pct_attr(elem: ET.Element, name: str) -> float:
613 val = elem.attrib.get(name)
614 if val is None:
615 return 0.0
616 try:
617 return float(val) / 100000.0
618 except ValueError:
619 return 0.0
620
621
622 def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str:
623 """Build an <image href=...> value (relative path or data URI).
624
625 The path is relative to the SVG file's location. The slide assembler writes
626 SVGs to <output>/svg/, so media files in <output>/<subdir>/ resolve via
627 a leading "../".
628 """
629 if embed:
630 mime = (
631 mimetypes.guess_type(filename)[0]
632 or _sniff_mime(img_bytes)
633 or "application/octet-stream"
634 )
635 encoded = base64.b64encode(img_bytes).decode("ascii")
636 return f"data:{mime};base64,{encoded}"
637 rel = f"../{subdir}/{filename}" if subdir else f"../{filename}"
638 return rel
639
640
641 def _sniff_mime(data: bytes) -> str | None:
642 """Best-effort MIME sniffing for embedded images."""
643 if data.startswith(b"\x89PNG\r\n\x1a\n"):
644 return "image/png"
645 if data.startswith(b"\xff\xd8\xff"):
646 return "image/jpeg"
647 if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
648 return "image/gif"
649 if data.startswith(b"<svg") or data.startswith(b"<?xml"):
650 return "image/svg+xml"
651 if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
652 return "image/webp"
653 return None
654
654 lines PYTHON