Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user