876 lines
31 KiB
Python
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())
|