Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
Image Size Analysis Tool
========================
Reports objective parameters for all images in a folder. It does not resolve a
canvas, prescribe a layout, or generate Strategist recommendations.
Usage:
python scripts/analyze_images.py <images_folder_path>
python scripts/analyze_images.py projects/xxx/images
Output:
- Analysis report displayed in console
- Generates image_analysis.csv under the project's analysis/ directory
(sibling of the images folder), alongside the PPTX intake bundle
"""
import argparse
import csv
import json
import os
import sys
import tempfile
from pathlib import Path
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
try:
from PIL import Image, ImageOps
except ImportError:
print("Error: PIL/Pillow not installed. Run: pip install Pillow")
sys.exit(1)
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
REPORT_WIDTH = 100
CATEGORY_WIDTH = 50
ImageAnalysis = dict[str, object]
def _load_image_manifest(images_dir: str) -> dict[str, dict]:
"""Load optional DOCX image metadata keyed by generated filename."""
manifest_path = Path(images_dir) / "image_manifest.json"
if not manifest_path.is_file():
return {}
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"[WARN] Cannot read image manifest: {exc}")
return {}
if not isinstance(data, list):
return {}
manifest: dict[str, dict] = {}
for item in data:
if not isinstance(item, dict):
continue
filename = item.get("filename")
if isinstance(filename, str):
manifest[filename] = item
return manifest
def _manifest_ratio(meta: dict | None) -> float | None:
"""Return a positive display ratio from manifest metadata."""
if not meta:
return None
value = meta.get("display_ratio")
if not isinstance(value, (int, float)):
return None
ratio = float(value)
return ratio if ratio > 0 else None
def _manifest_display_size(meta: dict, ratio: float) -> tuple[int, int]:
"""Return a display-sized stand-in for vector media dimensions."""
width_in = meta.get("display_width_in")
height_in = meta.get("display_height_in")
if isinstance(width_in, (int, float)) and isinstance(height_in, (int, float)):
width = max(1, int(round(float(width_in) * 96)))
height = max(1, int(round(float(height_in) * 96)))
return width, height
width_emu = meta.get("display_width_emu")
height_emu = meta.get("display_height_emu")
if isinstance(width_emu, int) and isinstance(height_emu, int):
width = max(1, int(round(width_emu / 914400 * 96)))
height = max(1, int(round(height_emu / 914400 * 96)))
return width, height
width = 960
height = max(1, int(round(width / ratio)))
return width, height
def _manifest_usage_count(meta: dict | None) -> int:
"""Return how many source occurrences point to one asset."""
if not meta:
return 1
usage_count = meta.get("usage_count")
if isinstance(usage_count, int) and usage_count > 0:
return usage_count
occurrences = meta.get("occurrences")
if isinstance(occurrences, list) and occurrences:
return len(occurrences)
return 1
def _manifest_ratio_variants(meta: dict | None) -> str:
"""Return a compact list of display ratio variants from manifest metadata."""
if not meta:
return ""
variants = meta.get("display_ratio_variants")
if not isinstance(variants, list):
return ""
ratios = [
f"{float(value):.2f}"
for value in variants
if isinstance(value, (int, float)) and value > 0
]
return ";".join(ratios)
def _apply_manifest_metadata(result: ImageAnalysis, meta: dict | None) -> None:
"""Copy optional manifest fields into an image analysis row."""
result["usage_count"] = _manifest_usage_count(meta)
result["display_ratio_variants"] = _manifest_ratio_variants(meta)
if not meta:
return
source_ext = meta.get("source_ext")
original_filename = meta.get("original_filename")
if isinstance(source_ext, str):
result["source_ext"] = source_ext
if isinstance(original_filename, str):
result["original_filename"] = original_filename
result["asset_kind"] = meta.get("asset_kind", "bitmap")
result["svg_renderable"] = meta.get("svg_renderable", True)
result["pptx_native_supported"] = meta.get("pptx_native_supported", True)
def _has_transparent_pixels(image: Image.Image) -> bool:
"""Return whether any frame contains a pixel with alpha below 255."""
original_frame = image.tell()
frame_count = int(getattr(image, "n_frames", 1))
try:
for frame_index in range(frame_count):
image.seek(frame_index)
if "A" not in image.getbands() and "transparency" not in image.info:
continue
rgba = image.convert("RGBA")
alpha = rgba.getchannel("A")
try:
extrema = alpha.getextrema()
finally:
alpha.close()
rgba.close()
if extrema and extrema[0] < 255:
return True
finally:
image.seek(original_frame)
return False
def _result_from_manifest(
filename: str,
filepath: str,
meta: dict,
) -> ImageAnalysis | None:
"""Build an analysis row for vector media Pillow cannot decode."""
ratio = _manifest_ratio(meta)
if ratio is None:
return None
width, height = _manifest_display_size(meta, ratio)
result: ImageAnalysis = {
'filename': filename,
'width': width,
'height': height,
'aspect_ratio': ratio,
'pixel_aspect_ratio': None,
'source_display_ratio': ratio,
'ratio_source': 'manifest',
'format': Path(filename).suffix.lstrip('.').upper(),
'has_transparent_pixels': None,
'category': classify_ratio(ratio),
'filesize_kb': os.path.getsize(filepath) / 1024,
}
_apply_manifest_metadata(result, meta)
suffix = Path(filename).suffix.lower()
is_office_vector = suffix in OFFICE_VECTOR_EXTENSIONS
result["asset_kind"] = meta.get(
"asset_kind",
"office_vector" if is_office_vector else "vector",
)
result["svg_renderable"] = meta.get("svg_renderable", suffix == ".svg")
result["pptx_native_supported"] = meta.get(
"pptx_native_supported",
is_office_vector or suffix == ".svg",
)
return result
def classify_ratio(aspect_ratio: float) -> str:
"""Classify an image by its objective aspect-ratio range.
Ranges: >2.0 ultra-wide, 1.5-2.0 wide, 1.2-1.5 standard
landscape, 0.8-1.2 near square, and <0.8 portrait.
"""
if aspect_ratio > 2.0:
return "Ultra-wide"
elif aspect_ratio > 1.5:
return "Wide landscape"
elif aspect_ratio > 1.2:
return "Standard landscape"
elif aspect_ratio > 0.8:
return "Near square"
else:
return "Portrait"
def _analyze_images(images_dir: str) -> tuple[list[ImageAnalysis], list[str]]:
"""Analyze all image files in a directory.
Args:
images_dir: Directory that contains image files.
Returns:
Sorted image analysis records and supported files that could not be read.
"""
results: list[ImageAnalysis] = []
errors: list[str] = []
manifest = _load_image_manifest(images_dir)
for filename in sorted(os.listdir(images_dir)):
filepath = os.path.join(images_dir, filename)
if not os.path.isfile(filepath):
continue
suffix = Path(filename).suffix.lower()
meta = manifest.get(filename)
if suffix in IMAGE_EXTENSIONS:
try:
with Image.open(filepath) as img:
image_format = img.format or suffix.lstrip(".").upper()
has_transparent_pixels = _has_transparent_pixels(img)
oriented = ImageOps.exif_transpose(img)
try:
width, height = oriented.size
finally:
if oriented is not img:
oriented.close()
aspect_ratio = width / height
result: ImageAnalysis = {
'filename': filename,
'width': width,
'height': height,
'aspect_ratio': aspect_ratio,
'pixel_aspect_ratio': aspect_ratio,
'source_display_ratio': _manifest_ratio(meta),
'ratio_source': 'native',
'format': image_format,
'has_transparent_pixels': has_transparent_pixels,
'category': classify_ratio(aspect_ratio),
'filesize_kb': os.path.getsize(filepath) / 1024
}
_apply_manifest_metadata(result, meta)
results.append(result)
except (
EOFError,
OSError,
SyntaxError,
ValueError,
ZeroDivisionError,
Image.DecompressionBombError,
) as exc:
message = f"{filename}: {exc}"
errors.append(message)
print(f"[WARN] Cannot read {message}")
elif meta:
result = _result_from_manifest(filename, filepath, meta)
if result:
results.append(result)
else:
message = f"{filename}: manifest has no valid display_ratio"
errors.append(message)
print(f"[WARN] Cannot analyze {message}")
elif suffix in OFFICE_VECTOR_EXTENSIONS:
message = f"{filename}: image_manifest.json metadata is required"
errors.append(message)
print(f"[WARN] Cannot analyze {message}")
return results, errors
def analyze_images(images_dir: str) -> list[ImageAnalysis]:
"""Analyze readable image files while preserving the existing public API."""
results, _ = _analyze_images(images_dir)
return results
def print_results(results: list[ImageAnalysis]) -> None:
"""Print the analysis report to stdout."""
print("\n" + "=" * REPORT_WIDTH)
print("Image Size Analysis Report")
print("=" * REPORT_WIDTH)
print(
f"\n{'No.':<4} {'Width':<7} {'Height':<7} {'Ratio':<7} "
f"{'Source':<8} {'Refs':<5} {'Size':<10} {'Category':<20} {'Filename'}"
)
print("-" * REPORT_WIDTH)
for i, img in enumerate(results, 1):
ratio_source = str(img.get('ratio_source', 'native'))
usage_count = int(img.get('usage_count', 1))
base = (
f"{i:<4} {img['width']:<7} {img['height']:<7} "
f"{img['aspect_ratio']:<7.2f} {ratio_source:<8} {usage_count:<5} "
f"{img['filesize_kb']:<10.1f}KB {img['category']:<20}"
)
print(f"{base} {img['filename'][:40]}")
print("-" * REPORT_WIDTH)
print(f"Total: {len(results)} images\n")
# Group statistics by objective aspect-ratio ranges.
print("\nGroup by Aspect Ratio:")
print("-" * CATEGORY_WIDTH)
categories = {
"Ultra-wide (>2.0)": [],
"Wide (1.5-2.0)": [],
"Standard (1.2-1.5)": [],
"Square (0.8-1.2)": [],
"Portrait (<0.8)": [],
}
for img in results:
ar = img['aspect_ratio']
if ar > 2.0:
categories["Ultra-wide (>2.0)"].append(img)
elif ar > 1.5:
categories["Wide (1.5-2.0)"].append(img)
elif ar > 1.2:
categories["Standard (1.2-1.5)"].append(img)
elif ar > 0.8:
categories["Square (0.8-1.2)"].append(img)
else:
categories["Portrait (<0.8)"].append(img)
for cat, imgs in categories.items():
if imgs:
print(f"\n{cat}: {len(imgs)} images")
for img in imgs[:5]: # Show only the first 5
print(f" - {img['width']}x{img['height']} (ratio {img['aspect_ratio']:.2f}) - {img['filename'][:35]}...")
if len(imgs) > 5:
print(f" ... and {len(imgs) - 5} more")
native_only = [
img for img in results
if img.get('asset_kind') == 'office_vector'
and not img.get('svg_renderable', True)
]
if native_only:
print("\nOffice vector assets for PPTX native passthrough:")
for img in native_only[:10]:
original = img.get('original_filename', img['filename'])
print(f" - {original} (display ratio {img['aspect_ratio']:.2f}; SVG preview not supported)")
if len(native_only) > 10:
print(f" ... and {len(native_only) - 10} more")
def _format_optional_number(value: object, digits: int = 2) -> str:
"""Format a numeric value for CSV, leaving unavailable facts blank."""
if not isinstance(value, (int, float)):
return ""
return f"{float(value):.{digits}f}"
def save_csv(results: list[ImageAnalysis], csv_path: str | Path) -> None:
"""Atomically save analysis results to a standards-compliant CSV file."""
target = Path(csv_path)
target.parent.mkdir(parents=True, exist_ok=True)
header = [
"No",
"Filename",
"Width",
"Height",
"AspectRatio",
"PixelAspectRatio",
"SourceDisplayRatio",
"RatioSource",
"Format",
"HasTransparentPixels",
"UsageCount",
"DisplayRatioVariants",
"AssetKind",
"SvgRenderable",
"PptxNativeSupported",
"SizeKB",
"Category",
]
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
newline="",
prefix=f".{target.name}.",
suffix=".tmp",
dir=target.parent,
delete=False,
) as handle:
temporary_path = Path(handle.name)
writer = csv.writer(handle, lineterminator="\n")
writer.writerow(header)
for index, image in enumerate(results, 1):
row = [
index,
image["filename"],
image["width"],
image["height"],
_format_optional_number(image["aspect_ratio"]),
_format_optional_number(image.get("pixel_aspect_ratio")),
_format_optional_number(image.get("source_display_ratio")),
image.get("ratio_source", "native"),
image.get("format", ""),
image.get("has_transparent_pixels", ""),
image.get("usage_count", 1),
image.get("display_ratio_variants", ""),
image.get("asset_kind", "bitmap"),
image.get("svg_renderable", True),
image.get("pptx_native_supported", True),
_format_optional_number(image["filesize_kb"], digits=1),
image["category"],
]
writer.writerow(row)
os.replace(temporary_path, target)
temporary_path = None
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
print(f"\n[REPORT] Image analysis CSV: {target}")
def main(argv: list[str] | None = None) -> int:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description="Analyze objective image-file facts"
)
parser.add_argument(
"images_dir",
help="Path to the images directory"
)
args = parser.parse_args(argv)
images_dir = Path(args.images_dir).resolve()
if not images_dir.exists():
print(f"Error: Directory not found: {images_dir}")
return 1
if not images_dir.is_dir():
print(f"Error: Not a directory: {images_dir}")
return 1
print(f"Analyzing: {images_dir}")
results, errors = _analyze_images(str(images_dir))
if results:
print_results(results)
else:
print("No readable supported image files found in the directory.")
analysis_dir = images_dir.parent / "analysis"
csv_path = analysis_dir / "image_analysis.csv"
save_csv(results, csv_path)
if errors:
print(
f"[ERROR] {len(errors)} supported image file(s) could not be analyzed; "
"the current report was still written.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())