Files
Templated-PPT-Master/runtime/ppt-master/scripts/extract_svg_assets.py
T

876 lines
31 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Large Vector Asset Extractor
Factor large inline vector groups (complex illustrations) out of working SVGs
into project icon assets, leaving a one-line `<use data-icon="namespace/id"/>`
placeholder behind — so the working SVG stays readable (structure, not a wall of
`<path>`). Visually lossless and reversible: the existing icon embedding path
re-inlines each asset before export, so the exported PPTX remains native shapes,
not an embedded picture.
Because re-inlining restores the extracted vector subtree, the detection
threshold is a readability convenience — it changes which blobs are factored
out, not whether the export stays editable.
Usage:
python3 scripts/extract_svg_assets.py <svg_dir> [options]
Examples:
python3 scripts/extract_svg_assets.py import_ws/authoring-svg \
--icons-dir import_ws/icons --icon-namespace imported \
--inplace --id-prefix layered --clean-stale
python3 scripts/extract_svg_assets.py import_ws/authoring-svg-flat \
--icons-dir import_ws/icons --icon-namespace imported \
--reuse-inventory import_ws/authoring-svg_vector_asset_inventory.json \
--inplace --id-prefix flat --clean-stale
python3 scripts/extract_svg_assets.py project/svg_output --inplace --min-drawables 40
Dependencies:
None (standard library only).
See workflows/create-template.md and svg_finalize/embed_icons.py.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Optional
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
from svg_authoring_view import (
AUTHORING_MANIFEST_NAME,
write_authoring_summary,
)
configure_utf8_stdio()
SVG_NS = "http://www.w3.org/2000/svg"
DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"}
SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"}
DEFAULT_MIN_DRAWABLES = 20
DEFAULT_MIN_BYTES = 3000
DEFAULT_MIN_DECORATION_BYTES = 3000
SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref"
ICON_NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$")
URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)")
def _local(tag: object) -> str:
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
def _drawable_count(elem: ET.Element) -> int:
return sum(1 for e in elem.iter() if _local(e.tag) in DRAWABLE)
def _xml_size(elem: ET.Element) -> int:
if not any(item.get(SOURCE_REF_ATTRIBUTE) for item in elem.iter()):
return len(ET.tostring(elem, encoding="utf-8"))
measured = copy.deepcopy(elem)
for item in measured.iter():
item.attrib.pop(SOURCE_REF_ATTRIBUTE, None)
return len(ET.tostring(measured, encoding="utf-8"))
def _large_enough(elem: ET.Element, min_drawables: int, min_bytes: int) -> bool:
return _drawable_count(elem) >= min_drawables or _xml_size(elem) >= min_bytes
def _has_semantic_content(elem: ET.Element) -> bool:
"""Text-bearing groups must stay readable/editable in the working SVG."""
return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter())
def _is_existing_placeholder(elem: ET.Element) -> bool:
return _local(elem.tag) == "use" and elem.get("data-icon") is not None
def _has_icon_placeholder(elem: ET.Element) -> bool:
return any(_is_existing_placeholder(item) for item in elem.iter())
def _is_extractable_subtree(elem: ET.Element) -> bool:
"""Pure vector subtrees can be moved; semantic content must stay inline."""
if _has_icon_placeholder(elem) or _is_chart_group(elem) or _has_semantic_content(elem):
return False
return _drawable_count(elem) > 0
def _is_chart_group(elem: ET.Element) -> bool:
"""Charts are handled separately (data + calibration) — never extract them."""
gid = (elem.get("id") or "").lower()
if "chart" in gid:
return True
return any("chart" in (e.get("id") or "").lower() for e in elem.iter())
def _tag_histogram(elem: ET.Element) -> dict[str, int]:
hist: dict[str, int] = {}
for e in elem.iter():
name = _local(e.tag)
if name in DRAWABLE:
hist[name] = hist.get(name, 0) + 1
return hist
def _source_references(elem: ET.Element) -> list[str]:
return sorted({
source_ref
for item in elem.iter()
if (source_ref := item.get(SOURCE_REF_ATTRIBUTE))
})
def _is_descendant(container: ET.Element, candidate: ET.Element) -> bool:
return any(elem is candidate for elem in container.iter())
def _id_index(root: ET.Element) -> dict[str, ET.Element]:
return {elem_id: elem for elem in root.iter() if (elem_id := elem.get("id"))}
def _referenced_ids(elem: ET.Element) -> set[str]:
refs: set[str] = set()
for item in elem.iter():
for attr_name, value in item.attrib.items():
refs.update(match.group(2) for match in URL_REF_RE.finditer(value))
if _local(attr_name) == "href" and value.startswith("#") and len(value) > 1:
refs.add(value[1:])
return refs
def _dependency_elements(root: ET.Element, asset_group: ET.Element) -> list[ET.Element]:
"""
Return external definition elements referenced by the extracted subtree.
Gradients, patterns, filters, clip paths, and markers often live in the
source SVG root <defs>. The extracted asset must carry these dependencies
itself; otherwise the standalone icon and later re-inline can lose styling.
"""
by_id = _id_index(root)
dependencies: list[ET.Element] = []
seen: set[str] = set()
queue = sorted(_referenced_ids(asset_group))
while queue:
ref_id = queue.pop(0)
if ref_id in seen:
continue
seen.add(ref_id)
target = by_id.get(ref_id)
if target is None or _is_descendant(asset_group, target):
continue
dependencies.append(target)
for nested_ref in sorted(_referenced_ids(target)):
if nested_ref not in seen:
queue.append(nested_ref)
return dependencies
def _collect_id_mapping(asset_id: str, group: ET.Element, dependencies: list[ET.Element]) -> dict[str, str]:
mapping: dict[str, str] = {}
for root in [group, *dependencies]:
for elem in root.iter():
elem_id = elem.get("id")
if elem_id and elem_id not in mapping:
mapping[elem_id] = f"{asset_id}_{elem_id}"
return mapping
def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None:
def rewrite_url(match: re.Match[str]) -> str:
quote, ref_id = match.group(1), match.group(2)
new_id = id_mapping.get(ref_id, ref_id)
return f"url({quote}#{new_id}{quote})"
for item in elem.iter():
elem_id = item.get("id")
if elem_id in id_mapping:
item.set("id", id_mapping[elem_id])
for attr_name, value in list(item.attrib.items()):
rewritten = URL_REF_RE.sub(rewrite_url, value)
if _local(attr_name) == "href" and value.startswith("#") and value[1:] in id_mapping:
rewritten = f"#{id_mapping[value[1:]]}"
if rewritten != value:
item.set(attr_name, rewritten)
def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> list[ET.Element]:
"""Outermost <g> groups whose drawable count clears the threshold (no nesting)."""
found: list[ET.Element] = []
def walk(elem: ET.Element) -> None:
for child in list(elem):
if _local(child.tag) != "g":
walk(child)
continue
if (
not _is_chart_group(child)
and not _has_semantic_content(child)
and not _has_icon_placeholder(child)
and _large_enough(child, min_drawables, min_bytes)
):
found.append(child) # outermost qualifying — do not descend
else:
walk(child)
walk(root)
return found
def _find_extractable_runs(
root: ET.Element,
min_drawables: int,
min_bytes: int,
min_decoration_bytes: int,
) -> list[tuple[ET.Element, list[ET.Element]]]:
"""
Consecutive pure-vector children inside mixed groups.
PPT exports often flatten text and large vector decorations as siblings
under the same parent. Whole-group extraction would hide text, so only the
contiguous vector runs are factored out.
"""
found: list[tuple[ET.Element, list[ET.Element]]] = []
def flush(parent: ET.Element, run: list[ET.Element]) -> None:
byte_threshold = min_decoration_bytes if _has_semantic_content(parent) else min_bytes
if run and (
sum(_drawable_count(child) for child in run) >= min_drawables
or sum(_xml_size(child) for child in run) >= byte_threshold
):
found.append((parent, list(run)))
def walk(elem: ET.Element) -> None:
run: list[ET.Element] = []
for child in list(elem):
if _is_extractable_subtree(child):
run.append(child)
continue
flush(elem, run)
run = []
walk(child)
flush(elem, run)
walk(root)
return found
def _asset_svg(
group: ET.Element,
dependencies: list[ET.Element],
view_box: str | None,
width: str | None,
height: str | None,
) -> bytes:
"""Standalone, independently-viewable SVG carrying the group in page coords."""
svg = ET.Element(f"{{{SVG_NS}}}svg")
svg.set("data-icon-style", "preserve-color")
if view_box:
svg.set("viewBox", view_box)
if width:
svg.set("width", width)
if height:
svg.set("height", height)
if dependencies:
defs = ET.SubElement(svg, f"{{{SVG_NS}}}defs")
for dependency in dependencies:
defs.append(dependency)
svg.append(group)
return ET.tostring(svg, encoding="utf-8", xml_declaration=True)
def _source_sha256(
group: ET.Element,
dependencies: list[ET.Element],
view_box: str | None,
width: str | None,
height: str | None,
) -> str:
"""Fingerprint an extracted subtree before asset-id namespacing."""
payload = _asset_svg(
copy.deepcopy(group),
[copy.deepcopy(dependency) for dependency in dependencies],
view_box,
width,
height,
)
return hashlib.sha256(payload).hexdigest()
def _asset_group(nodes: list[ET.Element]) -> ET.Element:
group = ET.Element(f"{{{SVG_NS}}}g")
for node in nodes:
group.append(node)
return group
def _asset_id(svg_path: Path, index: int, id_prefix: str) -> str:
prefix = f"{id_prefix}_" if id_prefix else ""
return f"{prefix}{svg_path.stem}_ill{index:02d}"
def _icon_reference(icon_namespace: str, asset_id: str) -> str:
return f"{icon_namespace}/{asset_id}" if icon_namespace else asset_id
def _asset_relative_path(icon_namespace: str, asset_id: str) -> str:
return f"{_icon_reference(icon_namespace, asset_id)}.svg"
def _icon_asset_for_namespace(icon_name: str, icon_namespace: str) -> str | None:
"""Map one local placeholder to its asset path, excluding other libraries."""
if icon_namespace:
prefix = f"{icon_namespace}/"
if not icon_name.startswith(prefix):
return None
asset_id = icon_name[len(prefix):]
if not asset_id or "/" in asset_id:
return None
return f"{icon_name}.svg"
if "/" in icon_name:
return None
return f"{icon_name}.svg"
def _has_namespace_placeholder(root: ET.Element, icon_namespace: str) -> bool:
if not icon_namespace:
return False
return any(
_local(elem.tag) == "use"
and (icon_name := elem.get("data-icon")) is not None
and _icon_asset_for_namespace(icon_name, icon_namespace) is not None
for elem in root.iter()
)
def _generated_asset_re(svg_stems: list[str], id_prefix: str) -> re.Pattern[str] | None:
if not svg_stems:
return None
prefix = f"{re.escape(id_prefix)}_" if id_prefix else ""
stems = "|".join(re.escape(stem) for stem in svg_stems)
return re.compile(rf"^{prefix}(?:{stems})_ill\d+\.svg$")
def _clean_stale_assets(
icons_dir: Path,
icon_namespace: str,
svg_paths: list[Path],
id_prefix: str,
keep_assets: set[str],
) -> list[str]:
pattern = _generated_asset_re([path.stem for path in svg_paths], id_prefix)
if pattern is None:
return []
removed: list[str] = []
asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir
for asset_path in sorted(asset_dir.glob("*.svg")):
relative_asset = asset_path.relative_to(icons_dir).as_posix()
if relative_asset in keep_assets or not pattern.match(asset_path.name):
continue
asset_path.unlink()
removed.append(relative_asset)
return removed
def _referenced_icon_assets(svg_paths: list[Path], icon_namespace: str) -> set[str]:
assets: set[str] = set()
for svg_path in svg_paths:
try:
root = ET.parse(svg_path).getroot()
except ET.ParseError:
continue
for elem in root.iter():
if _local(elem.tag) != "use":
continue
icon_name = elem.get("data-icon")
if icon_name and (asset := _icon_asset_for_namespace(icon_name, icon_namespace)):
assets.add(asset)
return assets
def _existing_placeholder_entries(
svg_paths: list[Path],
icons_dir: Path,
icon_namespace: str,
known_assets: set[str],
) -> list[dict]:
by_asset: dict[str, dict] = {}
for svg_path in svg_paths:
try:
root = ET.parse(svg_path).getroot()
except ET.ParseError:
continue
for elem in root.iter():
if _local(elem.tag) != "use":
continue
icon_name = elem.get("data-icon")
if not icon_name:
continue
asset = _icon_asset_for_namespace(icon_name, icon_namespace)
if asset is None:
continue
if asset in known_assets:
continue
entry = by_asset.setdefault(
asset,
{
"svg": svg_path.name,
"svgs": [],
"id": icon_name,
"icon": icon_name,
"asset": asset,
"source": "existing-placeholder",
"asset_exists": (icons_dir / asset).exists(),
},
)
if svg_path.name not in entry["svgs"]:
entry["svgs"].append(svg_path.name)
entries: list[dict] = []
for asset, entry in sorted(by_asset.items()):
asset_path = icons_dir / asset
if asset_path.exists():
entry["asset_sha256"] = hashlib.sha256(
asset_path.read_bytes()
).hexdigest()
try:
root = ET.parse(asset_path).getroot()
except ET.ParseError:
pass
else:
entry["drawable_count"] = _drawable_count(root)
entry["byte_count"] = _xml_size(root)
entry["elements"] = _tag_histogram(root)
entry["source_refs"] = _source_references(root)
entry["dependencies"] = sorted(
elem_id
for elem in root.iter()
if _local(elem.tag) == "defs"
for child in elem
if (elem_id := child.get("id"))
)
entries.append(entry)
return entries
def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, dict]:
"""Load fingerprinted assets from an earlier extraction inventory."""
try:
payload = json.loads(inventory_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"reuse inventory not found: {inventory_path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"invalid reuse inventory JSON: {inventory_path}: {exc}") from exc
entries = payload.get("assets")
if not isinstance(entries, list):
raise ValueError(f"reuse inventory has no assets list: {inventory_path}")
reusable: dict[str, dict] = {}
fingerprinted = 0
for entry in entries:
if not isinstance(entry, dict):
continue
source_sha256 = entry.get("source_sha256")
asset_sha256 = entry.get("asset_sha256")
asset = entry.get("asset")
icon = entry.get("icon")
if not all(
isinstance(value, str) and value
for value in (source_sha256, asset_sha256, asset, icon)
):
continue
fingerprinted += 1
asset_path = icons_dir / asset
if not asset_path.is_file():
raise ValueError(
f"reusable asset is missing from the target icons directory: {asset_path}"
)
actual_asset_sha256 = hashlib.sha256(asset_path.read_bytes()).hexdigest()
if actual_asset_sha256 != asset_sha256:
raise ValueError(
f"reusable asset hash does not match its inventory: {asset_path}"
)
current = reusable.get(source_sha256)
if current is None or asset < str(current["asset"]):
reusable[source_sha256] = entry
extracted_count = payload.get("extracted_count", 0)
if isinstance(extracted_count, int) and extracted_count > fingerprinted:
raise ValueError(
"reuse inventory predates source fingerprints; rerun the source extraction "
f"with the current tool: {inventory_path}"
)
return reusable
def _rewritten_path(svg_path: Path, rewritten_dir: Path | None, inplace: bool) -> Path:
if inplace:
return svg_path
if rewritten_dir is None:
return svg_path.parent.parent / f"{svg_path.parent.name}-rewritten" / svg_path.name
return rewritten_dir / svg_path.name
def extract_file(
svg_path: Path,
icons_dir: Path,
icon_namespace: str,
min_drawables: int,
min_bytes: int,
min_decoration_bytes: int,
inplace: bool,
id_prefix: str = "",
rewritten_dir: Path | None = None,
reusable_assets: dict[str, dict] | None = None,
) -> list[dict]:
"""Extract qualifying groups from one SVG. Returns inventory entries."""
ET.register_namespace("", SVG_NS)
tree = ET.parse(svg_path)
root = tree.getroot()
view_box = root.get("viewBox")
width = root.get("width")
height = root.get("height")
# A namespaced projection is an all-at-once readability pass. Once it owns
# an asset reference, reruns inventory the existing placeholders instead of
# progressively factoring their remaining parent/sibling geometry.
if _has_namespace_placeholder(root, icon_namespace):
if not inplace:
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return []
targets: list[tuple[ET.Element, list[ET.Element]]] = []
parents = {child: parent for parent in root.iter() for child in parent}
group_targets = _find_extractable(root, min_drawables, min_bytes)
selected_groups = set(group_targets)
def inside_selected_group(elem: ET.Element) -> bool:
current = elem
while current in parents:
current = parents[current]
if current in selected_groups:
return True
return False
for group in group_targets:
parent = parents.get(group)
if parent is not None:
targets.append((parent, [group]))
for parent, run in _find_extractable_runs(root, min_drawables, min_bytes, min_decoration_bytes):
if (
parent not in selected_groups
and not inside_selected_group(parent)
and not any(child in selected_groups for child in run)
and all(child in parent for child in run)
):
targets.append((parent, run))
if not targets:
if not inplace:
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return []
asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir
asset_dir.mkdir(parents=True, exist_ok=True)
entries = []
for index, (parent, nodes) in enumerate(targets, start=1):
if not nodes or not all(node in parent for node in nodes):
continue
asset_id = _asset_id(svg_path, index, id_prefix)
icon_reference = _icon_reference(icon_namespace, asset_id)
asset = _asset_relative_path(icon_namespace, asset_id)
pos = list(parent).index(nodes[0])
group = nodes[0] if len(nodes) == 1 and _local(nodes[0].tag) == "g" else _asset_group(nodes)
dependencies = [copy.deepcopy(elem) for elem in _dependency_elements(root, group)]
dependency_source_ids = sorted({
elem_id
for dependency in dependencies
for elem in dependency.iter()
if (elem_id := elem.get("id"))
})
source_sha256 = _source_sha256(group, dependencies, view_box, width, height)
source_refs = _source_references(group)
reusable = (reusable_assets or {}).get(source_sha256)
if reusable is not None:
reused_icon = str(reusable["icon"])
reused_asset = str(reusable["asset"])
placeholder = ET.Element(f"{{{SVG_NS}}}use")
placeholder.set("data-icon", reused_icon)
for node in nodes:
if node in parent:
parent.remove(node)
parent.insert(pos, placeholder)
entries.append({
"svg": svg_path.name,
"id": reused_icon,
"icon": reused_icon,
"asset": reused_asset,
"source": "reused-inventory",
"source_sha256": source_sha256,
"asset_sha256": reusable["asset_sha256"],
"reused_from_svg": reusable.get("svg"),
"drawable_count": _drawable_count(group),
"byte_count": _xml_size(group),
"source_refs": source_refs,
"dependencies": dependency_source_ids,
"elements": _tag_histogram(group),
})
continue
id_mapping = _collect_id_mapping(asset_id, group, dependencies)
_rewrite_references(group, id_mapping)
for dependency in dependencies:
_rewrite_references(dependency, id_mapping)
# Asset keeps the group in original page coordinates and carries its defs.
asset_bytes = _asset_svg(group, dependencies, view_box, width, height)
(icons_dir / asset).write_bytes(asset_bytes)
placeholder = ET.Element(f"{{{SVG_NS}}}use")
placeholder.set("data-icon", icon_reference)
for node in nodes:
if node in parent:
parent.remove(node)
parent.insert(pos, placeholder)
entries.append({
"svg": svg_path.name,
"id": icon_reference,
"icon": icon_reference,
"asset": asset,
"source": "extracted",
"source_sha256": source_sha256,
"asset_sha256": hashlib.sha256(asset_bytes).hexdigest(),
"drawable_count": _drawable_count(group),
"byte_count": _xml_size(group),
"source_refs": source_refs,
"dependencies": [id_mapping.get(elem_id, elem_id) for elem_id in dependency_source_ids],
"elements": _tag_histogram(group),
})
rewritten = _rewritten_path(svg_path, rewritten_dir, inplace)
rewritten.parent.mkdir(parents=True, exist_ok=True)
tree.write(rewritten, encoding="utf-8", xml_declaration=True)
return entries
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Factor large inline vector groups out of SVGs into reusable assets.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)")
parser.add_argument("-o", "--output", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
parser.add_argument("--icons-dir", dest="icons_dir", help="Project icon dir (default: <svg_dir>/../icons)")
parser.add_argument(
"--icon-namespace",
default="",
help=(
"Optional lower-case subdirectory and data-icon prefix for extracted "
"assets (create-template uses: imported)"
),
)
parser.add_argument(
"--rewritten-dir",
help="Directory for rewritten SVGs when not using --inplace (default: <svg_dir>/../<svg_dir-name>-rewritten)",
)
parser.add_argument(
"--inventory",
help="Inventory JSON path (default: <svg_dir>/../<svg_dir-name>_vector_asset_inventory.json)",
)
parser.add_argument(
"--reuse-inventory",
help=(
"Reuse fingerprint-matched assets from an earlier extraction inventory; "
"only unmatched vector subtrees create new assets"
),
)
parser.add_argument(
"--id-prefix",
default="",
help=(
"Optional prefix for generated asset IDs, useful when processing "
"layered and flat SVG dirs into one icons dir"
),
)
parser.add_argument(
"--min-drawables", type=int, default=DEFAULT_MIN_DRAWABLES,
help=f"Min drawable elements for a group to be extracted (default: {DEFAULT_MIN_DRAWABLES})",
)
parser.add_argument(
"--min-bytes", type=int, default=DEFAULT_MIN_BYTES,
help=f"Min XML bytes for a pure-vector group/run to be extracted (default: {DEFAULT_MIN_BYTES})",
)
parser.add_argument(
"--min-decoration-bytes", type=int, default=DEFAULT_MIN_DECORATION_BYTES,
help=(
"Min XML bytes for pure-vector decoration runs inside text-bearing groups "
f"(default: {DEFAULT_MIN_DECORATION_BYTES})"
),
)
parser.add_argument(
"--inplace", action="store_true",
help="Rewrite the source SVGs in place instead of writing to --rewritten-dir",
)
parser.add_argument(
"--clean-stale",
action="store_true",
help=(
"Remove stale generated assets for the current svg filenames/id prefix "
"that are not referenced by this run's inventory"
),
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
svg_dir = Path(args.svg_dir)
if not svg_dir.is_dir():
print(f"[ERROR] svg_dir not found: {svg_dir}", file=sys.stderr)
return 1
icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons"
icons_dir.mkdir(parents=True, exist_ok=True)
icon_namespace = args.icon_namespace.strip()
if icon_namespace and not ICON_NAMESPACE_RE.fullmatch(icon_namespace):
print(
"[ERROR] --icon-namespace must be one lower-case ASCII directory name "
"using only letters, digits, '_' or '-'",
file=sys.stderr,
)
return 1
reusable_assets: dict[str, dict] = {}
reuse_inventory_path = Path(args.reuse_inventory) if args.reuse_inventory else None
if reuse_inventory_path is not None:
try:
reusable_assets = _load_reusable_assets(reuse_inventory_path, icons_dir)
except ValueError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None
inventory_path = (
Path(args.inventory)
if args.inventory
else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json"
)
svg_paths = sorted(svg_dir.glob("*.svg"))
inventory: list[dict] = []
for svg_path in svg_paths:
try:
inventory.extend(
extract_file(
svg_path,
icons_dir,
icon_namespace,
args.min_drawables,
args.min_bytes,
args.min_decoration_bytes,
args.inplace,
args.id_prefix,
rewritten_dir,
reusable_assets,
)
)
except ET.ParseError as exc:
print(f"[WARN] skip unparseable {svg_path.name}: {exc}", file=sys.stderr)
extracted_count = sum(entry.get("source") == "extracted" for entry in inventory)
reused_count = sum(entry.get("source") == "reused-inventory" for entry in inventory)
known_assets = {str(entry["asset"]) for entry in inventory}
inventory.extend(
_existing_placeholder_entries(
svg_paths,
icons_dir,
icon_namespace,
known_assets,
)
)
stale_removed: list[str] = []
if args.clean_stale:
keep_assets = {str(entry["asset"]) for entry in inventory}
keep_assets.update(_referenced_icon_assets(svg_paths, icon_namespace))
stale_removed = _clean_stale_assets(
icons_dir,
icon_namespace,
svg_paths,
args.id_prefix,
keep_assets,
)
manifest = {
"schema": "vector_asset_inventory.v1",
"svg_dir": str(svg_dir),
"icons_dir": str(icons_dir),
"icon_namespace": icon_namespace or None,
"rewritten_dir": (
None
if args.inplace
else str(_rewritten_path(svg_dir / "_sample.svg", rewritten_dir, False).parent)
),
"reuse_inventory": str(reuse_inventory_path) if reuse_inventory_path is not None else None,
"min_drawables": args.min_drawables,
"min_bytes": args.min_bytes,
"min_decoration_bytes": args.min_decoration_bytes,
"extracted_count": extracted_count,
"reused_count": reused_count,
"asset_count": len(inventory),
"stale_removed": stale_removed,
"assets": inventory,
}
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary_path: Path | None = None
if args.inplace and (svg_dir / AUTHORING_MANIFEST_NAME).is_file():
try:
summary_path = write_authoring_summary(svg_dir)
except (OSError, ValueError) as exc:
print(
f"[ERROR] vector extraction succeeded but authoring summary "
f"refresh failed: {exc}",
file=sys.stderr,
)
return 1
print(
f"[OK] extracted {extracted_count} new asset(s), reused {reused_count} asset(s), "
f"inventoried {len(inventory)} asset reference(s) -> "
f"{icons_dir / icon_namespace if icon_namespace else icons_dir}",
file=sys.stderr,
)
if stale_removed:
print(f"[OK] removed {len(stale_removed)} stale generated asset(s)", file=sys.stderr)
if summary_path is not None:
print(f"[OK] refreshed model-readable summary: {summary_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())