diff --git a/feinschliff-builder/feinschliff_builder/decompile/pptx_decompile.py b/feinschliff-builder/feinschliff_builder/decompile/pptx_decompile.py index 324d933..1ec8208 100644 --- a/feinschliff-builder/feinschliff_builder/decompile/pptx_decompile.py +++ b/feinschliff-builder/feinschliff_builder/decompile/pptx_decompile.py @@ -376,7 +376,7 @@ def decompile_pptx(pptx_path: Path, brand_pack_dir: Path, f"theme {tokens.brand_name}", "", ] - pic_count = 0 + _pic_counter = [0] for shape in slide.shapes: x = _scaled(_px(shape.left), scale) y = _scaled(_px(shape.top), scale) @@ -385,7 +385,7 @@ def decompile_pptx(pptx_path: Path, brand_pack_dir: Path, line = _emit_one(shape, x, y, w, h, color_index=color_index, style_index=style_index, assets_dir=assets_dir, slide_idx=slide_idx, - pic_idx_ref=lambda inc=False, _c=[pic_count]: + pic_idx_ref=lambda inc=False, _c=_pic_counter: (_c.__setitem__(0, _c[0] + 1) if inc else _c[0])) if line: lines.append(line) diff --git a/feinschliff-builder/feinschliff_builder/verify/autofix.py b/feinschliff-builder/feinschliff_builder/verify/autofix.py index 2ffa481..7dd7132 100644 --- a/feinschliff-builder/feinschliff_builder/verify/autofix.py +++ b/feinschliff-builder/feinschliff_builder/verify/autofix.py @@ -378,18 +378,18 @@ def _find_smaller_layout( if not candidates: return None + try: + from feinschliff.layout_discovery import find_layout + except ImportError: + return None + current_name = _layout_name(current_layout_rel) for c in candidates: layout_id = c["layout"] if layout_id == current_name: continue # same as current - # Resolve path - rel = f"layouts/{layout_id}.slide.dsl" - # Verify the layout file exists (layouts live in the core plugin) - _builder_root = Path(__file__).resolve().parents[2] - _core_root = _builder_root.parent / "feinschliff" - if (_core_root / rel).is_file(): - return rel + if find_layout(layout_id) is not None: + return f"layouts/{layout_id}.slide.dsl" return None @@ -426,19 +426,18 @@ def _find_larger_layout( if not candidates: return None + try: + from feinschliff.layout_discovery import find_layout + except ImportError: + return None + current_name = _layout_name(current_layout_rel) for c in candidates: layout_id = c["layout"] if layout_id == current_name: continue - rel = f"layouts/{layout_id}.slide.dsl" - # Layouts live in the core feinschliff plugin, which is siblings to - # this builder plugin. Resolve relative to this file's package root. - _builder_root = Path(__file__).resolve().parents[2] - _core_root = _builder_root.parent / "feinschliff" - layout_path = _core_root / rel - if layout_path.is_file(): - return rel + if find_layout(layout_id) is not None: + return f"layouts/{layout_id}.slide.dsl" return None diff --git a/feinschliff-builder/feinschliff_builder/verify/cache.py b/feinschliff-builder/feinschliff_builder/verify/cache.py index 97e123f..5899282 100644 --- a/feinschliff-builder/feinschliff_builder/verify/cache.py +++ b/feinschliff-builder/feinschliff_builder/verify/cache.py @@ -9,6 +9,7 @@ import hashlib import json +import tempfile from dataclasses import asdict, dataclass from pathlib import Path from typing import Literal @@ -64,10 +65,17 @@ def put(self, verdict: CachedVerdict) -> None: self._data[key] = asdict(verdict) def save(self) -> None: - self._path.write_text( - json.dumps(self._data, indent=2, ensure_ascii=False), - encoding="utf-8", + payload = json.dumps(self._data, indent=2, ensure_ascii=False) + tmp_fd, tmp_name = tempfile.mkstemp( + dir=self._path.parent, prefix=".verify_cache_", suffix=".tmp" ) + try: + with open(tmp_fd, "w", encoding="utf-8") as fh: + fh.write(payload) + Path(tmp_name).replace(self._path) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise # ------------------------------------------------------------------ # Internal diff --git a/feinschliff-builder/feinschliff_builder/verify/static.py b/feinschliff-builder/feinschliff_builder/verify/static.py index e45c49a..9ad15d1 100644 --- a/feinschliff-builder/feinschliff_builder/verify/static.py +++ b/feinschliff-builder/feinschliff_builder/verify/static.py @@ -216,8 +216,8 @@ def _flatten_content_keys(ctx: dict, prefix: str = "") -> dict[str, str]: elif isinstance(ctx, list): for item in ctx: out.update(_flatten_content_keys(item, f"{prefix}[]")) - elif isinstance(ctx, str): - out[prefix] = ctx + elif ctx is not None: + out[prefix] = str(ctx) return out diff --git a/feinschliff/feinschliff/brand_discovery.py b/feinschliff/feinschliff/brand_discovery.py index 2045b9a..cae8cf6 100644 --- a/feinschliff/feinschliff/brand_discovery.py +++ b/feinschliff/feinschliff/brand_discovery.py @@ -137,7 +137,7 @@ def discover_brands() -> list[BrandPack]: try: resolved = load_tokens(d, brands_dir=root) ip = resolved.raw.get("$image_provider") if isinstance(resolved.raw, dict) else None - if isinstance(ip, dict): + if isinstance(ip, dict) and "kind" in ip: image_provider_config = ip except (OSError, ValueError, JSONDecodeError) as exc: warnings.warn( diff --git a/feinschliff/feinschliff/deck/orchestrate.py b/feinschliff/feinschliff/deck/orchestrate.py index 9265694..12af128 100644 --- a/feinschliff/feinschliff/deck/orchestrate.py +++ b/feinschliff/feinschliff/deck/orchestrate.py @@ -315,7 +315,7 @@ def _bundled_compounds() -> Path: local_compounds = dict(compounds) for cd in layout_compounds: local_compounds[cd.name] = cd - interp = interpolate_nodes(layout_nodes, entry["content"]) + interp = interpolate_nodes(layout_nodes, entry.get("content") or {}) interp = expand_diagram_blocks( interp, brand_dir=brand_dir, diff --git a/feinschliff/feinschliff/dsl/expander.py b/feinschliff/feinschliff/dsl/expander.py index f41f7fa..a0a310a 100644 --- a/feinschliff/feinschliff/dsl/expander.py +++ b/feinschliff/feinschliff/dsl/expander.py @@ -496,8 +496,10 @@ def expand_diagram_blocks( # body is authored in WxH coords and the renderer rasterizes at WxH. # PowerPoint downscales on insert. When absent, the slot IS the canvas # (legacy behavior, preserved bit-for-bit). - virtual_w: int = n.kw_args.get("virtual_w") or w # type: ignore[assignment] - virtual_h: int = n.kw_args.get("virtual_h") or h # type: ignore[assignment] + _vw = n.kw_args.get("virtual_w") + virtual_w: int = _vw if _vw is not None else w # type: ignore[assignment] + _vh = n.kw_args.get("virtual_h") + virtual_h: int = _vh if _vh is not None else h # type: ignore[assignment] # Resolve body: inline string or external file. body: str = n.kw_args.get("body") or "" # type: ignore[assignment] diff --git a/feinschliff/feinschliff/dsl/tokens.py b/feinschliff/feinschliff/dsl/tokens.py index ce4c170..2d75983 100644 --- a/feinschliff/feinschliff/dsl/tokens.py +++ b/feinschliff/feinschliff/dsl/tokens.py @@ -419,7 +419,12 @@ def load_tokens(brand_root: Path, *, brands_dir: Path | None = None) -> Tokens: for b in reversed(chain): tj = b / "tokens.json" if tj.is_file(): - data = json.loads(tj.read_text()) + try: + data = json.loads(tj.read_text()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError( + f"tokens.json in brand '{b.name}' is not valid JSON: {exc}" + ) from exc # `$image_provider` semantics: when the child swaps `kind`, the # parent's `config` must NOT carry over (it was scoped to a # different provider). Drop merged's `config` before deep-merge diff --git a/feinschliff/feinschliff/io/image_preflight.py b/feinschliff/feinschliff/io/image_preflight.py index ff5083e..9fd53ee 100644 --- a/feinschliff/feinschliff/io/image_preflight.py +++ b/feinschliff/feinschliff/io/image_preflight.py @@ -139,6 +139,8 @@ def _extract_dominant_colors(img: Image.Image, n: int = 3) -> list[tuple[int, in """ rgb_img = img.convert("RGB") total_pixels = rgb_img.width * rgb_img.height + if total_pixels == 0: + return [] # Quantize to 16 colours and collect (count, palette_index) pairs. # PIL getcolors() returns (count, value) tuples — value is the palette