返回 ppt-master
align_embed_images.py
根目录 / skills / ppt-master / scripts / svg_finalize / align_embed_images.py
1 #!/usr/bin/env python3
2 """PPT Master — single-pass image alignment + Base64 embedding.
3
4 Replaces the previous three independent finalize_svg steps:
5
6 crop-images → for each <image preserveAspectRatio="… slice"/>, crop the
7 source bitmap to the target aspect ratio at the given
8 anchor and write to ``images/cropped/`` so the SVG
9 reference points to a pre-cropped asset.
10 fix-aspect → for each <image>, read the source bitmap dimensions and
11 adjust x/y/width/height so the rendered box matches the
12 image aspect ratio in PowerPoint SVG rendering paths that
13 do not honor preserveAspectRatio consistently.
14 embed-images → Base64-inline every embeddable external image reference
15 so ``svg_final/`` remains portable when opened or manually
16 inserted as an SVG image. EMF/WMF keep the documented
17 external-reference exception.
18
19 Why merge: each step independently parsed + serialized the SVG, each step
20 re-read the same bitmap from disk, and the two spatial transforms (crop and
21 fit-box) are mutually exclusive yet were sequenced one after the other.
22 The fix-aspect default ``preserveAspectRatio = "xMidYMid meet"`` could
23 also kick in on rects already cropped by crop-images (whose par was
24 already removed), with the only thing keeping it from corrupting the
25 geometry being that crop and fix-aspect happened to produce numerically
26 equal box dimensions — a brittle accident.
27
28 The merged pipeline:
29
30 for image in svg:
31 if href starts with data: → skip (already inline)
32 if href is unresolvable / external URL → skip
33 if href points to EMF/WMF → skip (native PPTX passthrough only)
34 if image belongs to a valid nested crop → preserve source pixels, embed
35 if missing preserveAspectRatio → just embed (do not assume meet)
36 if align == none → just embed (no spatial transform)
37 if mode == slice → crop in memory, embed cropped bytes
38 if mode == meet → adjust x/y/w/h, embed original bytes
39 write SVG once
40
41 Bonus: the cropped bitmap is base64-inlined directly without going through
42 ``images/cropped/``, so that intermediate directory disappears and stale
43 crops can no longer accumulate across re-runs.
44 """
45
46 from __future__ import annotations
47
48 import base64
49 import io
50 import math
51 import os
52 import re
53 import sys
54 from pathlib import Path
55 from typing import TYPE_CHECKING
56 from xml.etree import ElementTree as ET
57
58 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
59 if str(_SCRIPTS_DIR) not in sys.path:
60 sys.path.insert(0, str(_SCRIPTS_DIR))
61
62 from console_encoding import configure_utf8_stdio # noqa: E402
63 from resource_paths import ( # noqa: E402
64 resolve_external_image_reference,
65 svg_data_uri_payload_error,
66 svg_image_payload_error,
67 )
68
69 configure_utf8_stdio()
70
71 if __package__ in {None, ''}:
72 import types
73
74 package = types.ModuleType('svg_finalize')
75 package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
76 sys.modules.setdefault('svg_finalize', package)
77 __package__ = 'svg_finalize'
78
79 # Reuse helpers from the previous standalone modules.
80 from .crop_images import crop_image_to_size, get_crop_anchor, parse_preserve_aspect_ratio
81 from .embed_images import _optimize_image_bytes, get_mime_type
82 from .fix_image_aspect import calculate_fitted_dimensions
83 from svg_to_pptx.drawingml.elements import ( # noqa: E402
84 parse_project_nested_svg_crop,
85 )
86
87 if TYPE_CHECKING: # pragma: no cover
88 from PIL import Image as PILImage # noqa: F401
89
90
91 SVG_NS = 'http://www.w3.org/2000/svg'
92 XLINK_NS = 'http://www.w3.org/1999/xlink'
93
94 # PIL save format is named slightly differently from the file extension /
95 # MIME type set we expose elsewhere; this map covers the formats we accept.
96 _PIL_FORMAT_BY_MIME = {
97 'image/png': 'PNG',
98 'image/jpeg': 'JPEG',
99 'image/gif': 'GIF',
100 'image/webp': 'WEBP',
101 }
102 _OFFICE_VECTOR_EXTENSIONS = {'.emf', '.wmf'}
103
104
105 def _parse_float(val: str | None, default: float = 0.0) -> float:
106 """Best-effort float parse, tolerating trailing ``px`` etc."""
107 if val is None or val == '':
108 return default
109 try:
110 return float(re.sub(r'(px|pt|em|%|rem)$', '', val.strip()))
111 except (ValueError, AttributeError):
112 return default
113
114
115 def _format_number(n: float) -> str:
116 """Format a float for compact SVG attribute output."""
117 if abs(n - round(n)) < 1e-6:
118 return str(int(round(n)))
119 s = f"{n:.2f}".rstrip('0').rstrip('.')
120 return s or '0'
121
122
123 def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
124 """Resolve an <image> href to a local filesystem path.
125
126 Returns None for unresolvable references (http/https/etc.) so callers
127 can leave those refs untouched.
128 """
129 if not href:
130 return None
131 return resolve_external_image_reference(svg_dir, href)
132
133
134 def _is_svg_image(img_path: Path, raw_bytes: bytes) -> bool:
135 """Return True when an image reference is an SVG document."""
136 if img_path.suffix.lower() == '.svg':
137 return True
138 head = raw_bytes.lstrip()[:512].lower()
139 return head.startswith(b'<svg') or (head.startswith(b'<?xml') and b'<svg' in head)
140
141
142 def _embed_raw_image(image: ET.Element, img_path: Path, raw_bytes: bytes) -> None:
143 """Embed raw image bytes without PIL transforms."""
144 mime_type = get_mime_type(img_path.name, raw_bytes)
145 b64 = base64.b64encode(raw_bytes).decode('ascii')
146 _set_href(image, f'data:{mime_type};base64,{b64}')
147
148
149 def _load_pil_image(img_path: Path) -> 'PILImage' | None:
150 """Open an image with PIL, returning None on any failure."""
151 try:
152 from PIL import Image
153 except ImportError:
154 return None
155 try:
156 return Image.open(img_path)
157 except (OSError, ValueError):
158 return None
159
160
161 def _prepare_raster_for_geometry(img: 'PILImage') -> 'PILImage':
162 """Apply EXIF orientation and materialize palette/tRNS transparency."""
163 from PIL import ImageOps
164
165 prepared = ImageOps.exif_transpose(img)
166 if prepared.mode == 'P':
167 prepared = prepared.convert('RGBA' if _has_alpha(prepared) else 'RGB')
168 elif (
169 'transparency' in getattr(prepared, 'info', {})
170 and prepared.mode not in {'RGBA', 'LA'}
171 ):
172 prepared = prepared.convert('RGBA')
173 return prepared
174
175
176 def _has_exif_geometry_transform(img: 'PILImage') -> bool:
177 """Return whether EXIF requires a physical mirror or rotation."""
178 try:
179 return int(img.getexif().get(274, 1)) in range(2, 9)
180 except (AttributeError, TypeError, ValueError):
181 return False
182
183
184 def _normalize_for_save(img: 'PILImage', mime_type: str) -> 'PILImage':
185 """Coerce a PIL image into a mode that the target format can save.
186
187 JPEG cannot store alpha — flatten to white background. Other formats
188 keep alpha when present.
189 """
190 if mime_type == 'image/jpeg':
191 if img.mode in ('RGBA', 'LA'):
192 from PIL import Image
193 background = Image.new('RGB', img.size, (255, 255, 255))
194 alpha = img.getchannel('A')
195 background.paste(img.convert('RGB'), mask=alpha)
196 return background
197 if img.mode != 'RGB':
198 return img.convert('RGB')
199 return img
200 # Lossless output — preserve alpha if present.
201 if img.mode == 'P':
202 return img.convert('RGBA' if _has_alpha(img) else 'RGB')
203 if img.mode not in {'1', 'L', 'LA', 'I', 'I;16', 'RGB', 'RGBA'}:
204 return img.convert('RGBA' if _has_alpha(img) else 'RGB')
205 return img
206
207
208 def _has_alpha(img: 'PILImage') -> bool:
209 """Return whether a PIL image has transparency."""
210 if img.mode in ('RGBA', 'LA'):
211 return True
212 return 'transparency' in getattr(img, 'info', {})
213
214
215 def _target_size(
216 box_w: float,
217 box_h: float,
218 *,
219 max_dimension: int | None,
220 image_scale: float,
221 ) -> tuple[int, int]:
222 """Resolve the pixel budget for a rendered SVG image box."""
223 target_w = max(1, int(round(box_w * max(image_scale, 1.0))))
224 target_h = max(1, int(round(box_h * max(image_scale, 1.0))))
225 if max_dimension and max(target_w, target_h) > max_dimension:
226 ratio = max_dimension / max(target_w, target_h)
227 target_w = max(1, int(round(target_w * ratio)))
228 target_h = max(1, int(round(target_h * ratio)))
229 return target_w, target_h
230
231
232 def _downscale_to_target(img: 'PILImage', target_w: int, target_h: int) -> tuple['PILImage', bool]:
233 """Downscale without upsampling."""
234 width, height = img.size
235 if width <= 0 or height <= 0:
236 return img, False
237 ratio = min(target_w / width, target_h / height, 1.0)
238 if ratio >= 1.0:
239 return img, False
240 from PIL import Image
241 new_size = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
242 return img.resize(new_size, Image.Resampling.LANCZOS), True
243
244
245 def _encode_pil_to_data_uri(
246 img: 'PILImage',
247 src_path: Path,
248 *,
249 compress: bool,
250 max_dimension: int | None,
251 fallback_bytes: bytes | None,
252 ) -> tuple[str, int] | None:
253 """Serialize *img* to a base64 data URI.
254
255 If the image has not been transformed, ``--no-compress`` preserves a
256 supported PNG/JPEG/GIF/WebP payload byte-for-byte. Compression mode may
257 still retain a smaller original payload. *fallback_bytes* carries those
258 raw on-disk bytes.
259 """
260 original_mime_type = get_mime_type(src_path.name, fallback_bytes)
261 if (
262 not compress
263 and fallback_bytes is not None
264 and original_mime_type in _PIL_FORMAT_BY_MIME
265 ):
266 encoded = base64.b64encode(fallback_bytes).decode('ascii')
267 return (
268 f'data:{original_mime_type};base64,{encoded}',
269 len(fallback_bytes),
270 )
271
272 # Match native export: only original JPEG assets stay lossy. PNG remains
273 # PNG, while BMP/TIFF and other static raster formats become lossless PNG.
274 mime_type = (
275 'image/jpeg' if original_mime_type == 'image/jpeg' else 'image/png'
276 )
277 pil_format = _PIL_FORMAT_BY_MIME.get(mime_type, 'PNG')
278
279 # Encode current PIL image
280 try:
281 prepared = _normalize_for_save(img, mime_type)
282 buf = io.BytesIO()
283 save_kwargs: dict = {'format': pil_format}
284 if pil_format == 'JPEG':
285 save_kwargs['quality'] = 95
286 save_kwargs['optimize'] = True
287 elif pil_format == 'PNG':
288 save_kwargs['optimize'] = True
289 prepared.save(buf, **save_kwargs)
290 encoded_bytes = buf.getvalue()
291 except (OSError, ValueError):
292 return None
293
294 optimized_bytes = _optimize_image_bytes(
295 encoded_bytes, mime_type, compress=compress, max_dimension=max_dimension,
296 )
297
298 # If the original represents the same uncropped pixels and is smaller,
299 # retain it instead of inflating an already efficient PNG/JPEG.
300 chosen = optimized_bytes
301 if (
302 fallback_bytes
303 and mime_type == original_mime_type
304 and len(fallback_bytes) < len(optimized_bytes)
305 ):
306 chosen = fallback_bytes
307
308 b64 = base64.b64encode(chosen).decode('ascii')
309 return f'data:{mime_type};base64,{b64}', len(chosen)
310
311
312 def _iter_image_elements(root: ET.Element):
313 """Yield every <image> in the tree regardless of namespace prefix."""
314 for image in root.iter(f'{{{SVG_NS}}}image'):
315 yield image
316 # Also catch namespace-stripped trees just in case
317 for image in root.iter('image'):
318 yield image
319
320
321 def _get_href(image: ET.Element) -> str | None:
322 """Return the image href, supporting both ``href`` and ``xlink:href``."""
323 return image.get('href') or image.get(f'{{{XLINK_NS}}}href')
324
325
326 def _set_href(image: ET.Element, value: str) -> None:
327 """Write the data URI back to whichever href attribute the image used."""
328 if image.get(f'{{{XLINK_NS}}}href') is not None:
329 image.set(f'{{{XLINK_NS}}}href', value)
330 else:
331 image.set('href', value)
332
333
334 def _nested_crop_image_ids(root: ET.Element) -> set[int]:
335 """Return child image identities from valid nested crop transports."""
336 image_ids: set[int] = set()
337 for elem in root.iter(f'{{{SVG_NS}}}svg'):
338 if elem is root:
339 continue
340 try:
341 crop = parse_project_nested_svg_crop(elem)
342 except ValueError:
343 continue
344 image_ids.add(id(crop.image))
345 return image_ids
346
347
348 def _process_one_image(
349 image: ET.Element,
350 svg_dir: Path,
351 *,
352 compress: bool,
353 max_dimension: int | None,
354 image_scale: float,
355 preserve_source_pixels: bool,
356 verbose: bool,
357 ) -> tuple[bool, str | None]:
358 """Align (slice/meet) and embed a single <image>.
359
360 Returns ``(processed, error)`` where *processed* is True iff the image
361 was rewritten and *error* is a short message when something went wrong
362 (the image is left untouched in that case).
363 """
364 href = _get_href(image)
365 if not href:
366 return False, None
367 if href.lower().startswith('data:'):
368 payload_error = svg_data_uri_payload_error(href)
369 if payload_error is not None:
370 return False, payload_error
371 return False, None # already inline
372
373 img_path = _resolve_image_path(href, svg_dir)
374 if img_path is None:
375 return False, f'unresolved href: {href[:60]}'
376
377 try:
378 with open(img_path, 'rb') as fh:
379 raw_bytes = fh.read()
380 except OSError as exc:
381 return False, f'read failed: {exc}'
382
383 if img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
384 if verbose:
385 print(f' [INFO] {img_path.name}: Office vector left external for native PPTX passthrough')
386 return False, None
387
388 if _is_svg_image(img_path, raw_bytes):
389 payload_error = svg_image_payload_error(raw_bytes)
390 if payload_error is not None:
391 return False, f'{img_path.name}: {payload_error}'
392 _embed_raw_image(image, img_path, raw_bytes)
393 if verbose:
394 print(f' [OK] {img_path.name} (svg, embedded as-is)')
395 return True, None
396
397 img = _load_pil_image(img_path)
398 if img is None:
399 return False, 'PIL open failed'
400
401 # Multi-frame images (animated GIF / WebP / APNG): every PIL transform
402 # and re-save below operates on frame 0 only, silently flattening the
403 # animation — and the "original bytes are smaller" fallback never fires
404 # because one frame is always smaller than all frames. Embed the raw
405 # bytes untouched and keep the geometry attributes (including
406 # preserveAspectRatio, which the native converter maps to srcRect
407 # non-destructively). Animated assets skip re-encode, resize, and the
408 # size cap.
409 if getattr(img, 'is_animated', False):
410 _embed_raw_image(image, img_path, raw_bytes)
411 if max_dimension and max(img.size) > max_dimension:
412 print(f' [WARN] {img_path.name}: animated image kept as-is '
413 f'({img.size[0]}x{img.size[1]} exceeds max dimension '
414 f'{max_dimension}px); animations are exempt from size limits')
415 if verbose:
416 print(f' [OK] {img_path.name} (animated, embedded as-is)')
417 return True, None
418
419 geometry_normalized = _has_exif_geometry_transform(img)
420 img = _prepare_raster_for_geometry(img)
421 box_x = _parse_float(image.get('x'))
422 box_y = _parse_float(image.get('y'))
423 box_w = _parse_float(image.get('width'))
424 box_h = _parse_float(image.get('height'))
425 if (
426 not math.isfinite(box_w)
427 or not math.isfinite(box_h)
428 or box_w <= 0
429 or box_h <= 0
430 ):
431 return False, 'zero-sized box'
432
433 par_attr = image.get('preserveAspectRatio') or ''
434 par_attr = par_attr.strip()
435
436 # ------------------------------------------------------------------
437 # Decide the spatial transform
438 # ------------------------------------------------------------------
439 final_img: 'PILImage' = img
440 new_x, new_y, new_w, new_h = box_x, box_y, box_w, box_h
441 transformed = geometry_normalized
442 target_box_w, target_box_h = box_w, box_h
443 preserve_stretch = False
444 preserve_slice = False
445
446 if not par_attr:
447 # No preserveAspectRatio at all. The previous pipeline's fix-aspect
448 # step assumed "xMidYMid meet" here, which silently re-fit images
449 # that crop-images had already shaped. Treat absence as "leave it
450 # alone": embed bytes, keep box.
451 pass
452 else:
453 align, mode = parse_preserve_aspect_ratio(par_attr)
454 if align == 'none':
455 # Author wants stretch-to-box; preserve geometry, embed bytes.
456 preserve_stretch = True
457 elif mode == 'slice':
458 x_anchor, y_anchor = get_crop_anchor(align)
459 cropped_img = crop_image_to_size(
460 img, box_w, box_h, x_anchor, y_anchor
461 )
462 final_img = cropped_img
463 transformed = True
464 preserve_slice = not math.isclose(
465 cropped_img.size[0] / cropped_img.size[1],
466 box_w / box_h,
467 rel_tol=1e-6,
468 abs_tol=1e-9,
469 )
470 else: # meet (or any other mode → treat as meet)
471 new_w_calc, new_h_calc, off_x, off_y = calculate_fitted_dimensions(
472 img.size[0], img.size[1], box_w, box_h, mode='meet',
473 )
474 new_x = box_x + off_x
475 new_y = box_y + off_y
476 new_w = new_w_calc
477 new_h = new_h_calc
478 target_box_w, target_box_h = new_w, new_h
479
480 if preserve_source_pixels:
481 # The child's 1×1 box is source-unit crop geometry, not its rendered
482 # frame. Keep the full source raster so the outer viewport remains
483 # authoritative and independently shaped crops do not lose detail.
484 target_w, target_h = final_img.size
485 else:
486 target_w, target_h = _target_size(
487 target_box_w,
488 target_box_h,
489 max_dimension=max_dimension,
490 image_scale=image_scale,
491 )
492 final_img, resized = _downscale_to_target(final_img, target_w, target_h)
493 transformed = transformed or resized
494
495 # ------------------------------------------------------------------
496 # Encode and rewrite
497 # ------------------------------------------------------------------
498 encoded = _encode_pil_to_data_uri(
499 final_img,
500 img_path,
501 compress=compress,
502 max_dimension=None if preserve_source_pixels else max_dimension,
503 fallback_bytes=raw_bytes if not transformed else None,
504 )
505 if encoded is None:
506 return False, 'encode failed'
507 data_uri, _ = encoded
508
509 _set_href(image, data_uri)
510 image.set('x', _format_number(new_x))
511 image.set('y', _format_number(new_y))
512 image.set('width', _format_number(new_w))
513 image.set('height', _format_number(new_h))
514 if preserve_stretch:
515 image.set('preserveAspectRatio', 'none')
516 elif preserve_slice:
517 image.set('preserveAspectRatio', par_attr)
518 elif 'preserveAspectRatio' in image.attrib:
519 del image.attrib['preserveAspectRatio']
520
521 if verbose:
522 if preserve_source_pixels:
523 suffix = ' (nested crop, source pixels preserved)'
524 else:
525 suffix = ' (cropped)' if transformed else ''
526 print(f' [OK] {img_path.name}{suffix}')
527 return True, None
528
529
530 def count_office_vector_refs_in_svg(svg_path: str | Path) -> int:
531 """Count local EMF/WMF image refs that the embed pass intentionally skips."""
532 svg_path = Path(svg_path)
533 svg_dir = svg_path.parent.resolve()
534 try:
535 tree = ET.parse(svg_path)
536 except ET.ParseError:
537 return 0
538 count = 0
539 seen: set[int] = set()
540 for image in _iter_image_elements(tree.getroot()):
541 ident = id(image)
542 if ident in seen:
543 continue
544 seen.add(ident)
545 href = _get_href(image)
546 if not href or href.startswith('data:'):
547 continue
548 img_path = _resolve_image_path(href, svg_dir)
549 if img_path and img_path.suffix.lower() in _OFFICE_VECTOR_EXTENSIONS:
550 count += 1
551 return count
552
553
554 def align_and_embed_images_in_svg(
555 svg_path: str | Path,
556 *,
557 dry_run: bool = False,
558 verbose: bool = False,
559 compress: bool = True,
560 max_dimension: int | None = 2560,
561 image_scale: float = 2.0,
562 ) -> tuple[int, int]:
563 """Run the merged align + embed pass on a single SVG file.
564
565 Returns ``(processed_count, error_count)``.
566 """
567 svg_path = Path(svg_path)
568 svg_dir = svg_path.parent.resolve()
569
570 # Register namespaces for clean serialization
571 ET.register_namespace('', SVG_NS)
572 ET.register_namespace('xlink', XLINK_NS)
573
574 try:
575 tree = ET.parse(svg_path)
576 except ET.ParseError as exc:
577 print(
578 f' [ERROR] {svg_path.name}: parse failed ({exc})',
579 file=sys.stderr,
580 )
581 return (0, 1)
582 root = tree.getroot()
583 nested_crop_image_ids = _nested_crop_image_ids(root)
584
585 # Avoid double-iteration if an element matches both namespaced and
586 # bare-tag iteration paths.
587 seen: set[int] = set()
588 processed = 0
589 errors = 0
590
591 for image in _iter_image_elements(root):
592 ident = id(image)
593 if ident in seen:
594 continue
595 seen.add(ident)
596
597 if dry_run:
598 processed += 1
599 continue
600
601 ok, err = _process_one_image(
602 image, svg_dir,
603 compress=compress, max_dimension=max_dimension,
604 image_scale=image_scale,
605 preserve_source_pixels=ident in nested_crop_image_ids,
606 verbose=verbose,
607 )
608 if ok:
609 processed += 1
610 elif err:
611 errors += 1
612 print(f' [ERROR] {svg_path.name}: {err}', file=sys.stderr)
613
614 if processed > 0 and errors == 0 and not dry_run:
615 tree.write(svg_path, encoding='utf-8', xml_declaration=False)
616
617 return (processed, errors)
618
619
620 # ---------------------------------------------------------------------------
621 # Standalone CLI (rare; the main entry point is finalize_svg.py)
622 # ---------------------------------------------------------------------------
623
624 def build_parser() -> argparse.ArgumentParser:
625 """Build the standalone diagnostic parser."""
626 import argparse
627 parser = argparse.ArgumentParser(
628 description='Align (slice/meet) and Base64-embed all <image> refs in an SVG.',
629 )
630 parser.add_argument('svg', type=Path, help='SVG file to process in place')
631 parser.add_argument('-n', '--dry-run', action='store_true')
632 parser.add_argument('-v', '--verbose', action='store_true')
633 parser.add_argument('--compress', dest='compress', action='store_true', default=True,
634 help='Compress images before embedding (default)')
635 parser.add_argument('--no-compress', dest='compress', action='store_false',
636 help='Disable image compression')
637 parser.add_argument('--max-dimension', type=int, default=2560,
638 help='Downscale images larger than this on either axis (default: 2560)')
639 parser.add_argument('--image-scale', type=float, default=2.0,
640 help='Target pixels per rendered SVG pixel')
641 return parser
642
643
644 def main(argv: list[str] | None = None) -> int:
645 """Run the standalone diagnostic CLI."""
646 parser = build_parser()
647 args = parser.parse_args(argv)
648
649 if not args.svg.exists():
650 print(f'Error: file not found: {args.svg}', file=sys.stderr)
651 return 1
652 if args.max_dimension < 1:
653 print('Error: --max-dimension must be >= 1', file=sys.stderr)
654 return 1
655 if args.image_scale < 1:
656 print('Error: --image-scale must be >= 1', file=sys.stderr)
657 return 1
658
659 proc, err = align_and_embed_images_in_svg(
660 args.svg,
661 dry_run=args.dry_run,
662 verbose=args.verbose,
663 compress=args.compress,
664 max_dimension=args.max_dimension,
665 image_scale=args.image_scale,
666 )
667 print(f'Processed {proc} image(s), {err} error(s)')
668 return 1 if err else 0
669
670
671 if __name__ == '__main__':
672 raise SystemExit(main())
673
673 lines PYTHON