studio-psd-extract 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ """Extracción de PSD -> contrato JSON v1. `extract` es PURA (sin red ni DAM)."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import math
6
+ from importlib.metadata import PackageNotFoundError, version as _version
7
+
8
+ from .guard import GuardError, check_header
9
+
10
+ try:
11
+ EXTRACTOR_VERSION = _version("studio-psd-extract")
12
+ except PackageNotFoundError: # árbol de fuentes sin instalar
13
+ EXTRACTOR_VERSION = "0+unknown"
14
+
15
+
16
+ def _preorder(layers):
17
+ """Capas en el mismo orden pre-orden que usa `layers.walk` para numerar ids."""
18
+ for l in layers:
19
+ yield l
20
+ if l.is_group():
21
+ yield from _preorder(list(l))
22
+
23
+
24
+ def _flatten(nodes):
25
+ for n in nodes:
26
+ yield n
27
+ yield from _flatten(n["children"])
28
+
29
+
30
+ def _ppi(psd) -> int:
31
+ """ppi desde RESOLUTION_INFO: `horizontal` es fixed-point 16.16; unit 2 = px/cm."""
32
+ from psd_tools.constants import Resource
33
+ info = psd.image_resources.get_data(Resource.RESOLUTION_INFO)
34
+ raw = getattr(info, "horizontal", None)
35
+ if not raw:
36
+ return 72
37
+ try:
38
+ value = float(raw) / 65536.0
39
+ if getattr(info, "horizontal_unit", 1) == 2: # px/cm -> px/in
40
+ value *= 2.54
41
+ ppi = int(round(value))
42
+ except (TypeError, ValueError):
43
+ return 72
44
+ return ppi if 1 <= ppi <= 2000 else 72
45
+
46
+
47
+ def _transform_scale(layer) -> tuple[float, bool]:
48
+ """Escala efectiva del `TypeLayer.transform` (xx, xy, yx, yy, tx, ty) -> (escala, es_compleja).
49
+
50
+ Escala uniforme geométrica = sqrt(|det|) = sqrt(|xx*yy - xy*yx|): para escala pura
51
+ (con o sin reflejo) coincide con el factor; con rotación pura es 1.0 (la rotación no
52
+ cambia el tamaño). `es_compleja` si hay rotación/shear (xy o yx > 1% de la escala) o
53
+ escala anisotrópica (||xx|-|yy|| > 1% de la escala), donde una sola escala es aproximada. tx/ty son posición, no afectan. Identidad, atributo
54
+ ausente o valor inválido -> (1.0, False).
55
+ """
56
+ try:
57
+ xx, xy, yx, yy = (float(x) for x in tuple(layer.transform)[:4])
58
+ except (AttributeError, TypeError, ValueError):
59
+ return 1.0, False
60
+ scale = abs(xx * yy - xy * yx) ** 0.5
61
+ if not math.isfinite(scale) or not scale > 1e-9:
62
+ return 1.0, False
63
+ if abs(scale - 1.0) < 1e-6:
64
+ scale = 1.0
65
+ tol = 0.01 * scale
66
+ return scale, max(abs(xy), abs(yx)) > tol or abs(abs(xx) - abs(yy)) > tol
67
+
68
+
69
+ def extract(psd_bytes: bytes, *, theme_families: list[str] | None = None, profile=None,
70
+ mode: str = "online") -> dict:
71
+ from . import artboards as _artboards, layers as _layers, limits, raster, text as _text
72
+ from .model import bbox_dict, map_kind
73
+
74
+ p = profile or limits.server()
75
+ head = check_header(psd_bytes, p)
76
+
77
+ from psd_tools import PSDImage
78
+ psd = PSDImage.open(io.BytesIO(psd_bytes))
79
+
80
+ from .color import ColorConverter
81
+
82
+ converter = ColorConverter(raster._icc_profile(psd), head["color_mode"])
83
+
84
+ all_layers = list(psd.descendants())
85
+ if len(all_layers) > p.max_layers:
86
+ raise GuardError("psd_too_complex", f"{len(all_layers)} capas > {p.max_layers}")
87
+ for l in all_layers:
88
+ try:
89
+ b = bbox_dict(l.bbox)
90
+ except ValueError:
91
+ continue
92
+ if b["w"] * b["h"] > p.max_layer_pixels:
93
+ raise GuardError("psd_too_complex",
94
+ f"capa '{l.name}' {b['w']}x{b['h']}px > {p.max_layer_pixels}")
95
+
96
+ boards = _artboards.detect(psd)
97
+ tree = _layers.walk(psd, boards)
98
+ warnings: list[dict] = []
99
+ fonts: dict[str, dict] = {}
100
+ theme = set(theme_families or [])
101
+
102
+ flat_nodes = list(_flatten(tree))
103
+ ordered_layers = list(_preorder(list(psd)))
104
+ if len(flat_nodes) != len(ordered_layers):
105
+ raise RuntimeError(
106
+ f"desalineado nodo/capa: {len(flat_nodes)} nodos vs {len(ordered_layers)} capas")
107
+ for node, layer in zip(flat_nodes, ordered_layers):
108
+ if node["name"] != layer.name or node["kind"] != map_kind(layer):
109
+ raise RuntimeError(
110
+ f"desalineado nodo/capa: nodo {node['id']} ({node['name']!r}, {node['kind']}) "
111
+ f"vs capa {layer.name!r} ({map_kind(layer)})")
112
+ node["text"] = None
113
+ node["asset"] = None
114
+ if node["kind"] != "text":
115
+ continue
116
+ scale, complex_tf = _transform_scale(layer)
117
+ try:
118
+ t = _text.runs(layer, scale, theme, to_hex=converter.hex)
119
+ except Exception as exc: # noqa: BLE001 - un run malo no debe perder el PSD entero
120
+ warnings.append({"code": "text_parse_failed", "layer": node["id"],
121
+ "detail": repr(exc)})
122
+ continue
123
+ node["text"] = t
124
+ for r in t["runs"]:
125
+ f = fonts.setdefault(r["family"], {"family": r["family"],
126
+ "available": r["font_available"], "runs": 0})
127
+ f["runs"] += 1
128
+ if complex_tf:
129
+ warnings.append({"code": "text_transform_complex", "layer": node["id"],
130
+ "scale": round(scale, 6)})
131
+
132
+ # Nodo extra por mesa con el background rasterizado (bytes; la subida es Task 7).
133
+ for a in boards:
134
+ bg = raster.artboard_background(psd, a, converter)
135
+ warnings.extend(bg["warnings"])
136
+ tree.append({
137
+ "id": f"{a['id']}-bg", "name": f"{a['name']} (fondo sin texto)", "kind": "pixel",
138
+ "artboard": a["id"], "bbox": dict(a["rect"]), "visible": True, "opacity": 1.0,
139
+ "clipping": False, "mask": False, "effects": [], "children": [], "text": None,
140
+ "asset": {"png": bg["png"], "rgba_hash": bg["rgba_hash"], "w": bg["w"], "h": bg["h"]},
141
+ })
142
+
143
+ return {
144
+ "contract_version": 1,
145
+ "extractor": {"mode": mode, "version": EXTRACTOR_VERSION},
146
+ "document": {"width": head["width"], "height": head["height"], "ppi": _ppi(psd),
147
+ "color_mode": head["color_mode"], "depth": head["depth"]},
148
+ "artboards": boards,
149
+ "layers": tree,
150
+ "fonts_used": list(fonts.values()),
151
+ "warnings": warnings,
152
+ }
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ """Detección de mesas (artboards) de un PSDImage de psd-tools."""
2
+ from __future__ import annotations
3
+
4
+ from .model import bbox_dict
5
+
6
+
7
+ def detect(psd) -> list[dict]:
8
+ """Mesas = grupos con tagged block ARTBOARD_DATA (psd-tools los expone como
9
+ `Artboard`, con bbox = artboardRect). Sin mesas: una sola `A0` = canvas."""
10
+ found = []
11
+ for layer in psd.descendants():
12
+ if layer.kind != "artboard":
13
+ continue
14
+ try:
15
+ rect = bbox_dict(layer.bbox)
16
+ except ValueError: # artboardRect ausente/incompleto
17
+ continue
18
+ found.append({"id": f"A{len(found)}", "name": layer.name, "rect": rect})
19
+ if found:
20
+ return found
21
+ return [{"id": "A0", "name": "A0",
22
+ "rect": {"x": 0, "y": 0, "w": psd.width, "h": psd.height}}]
@@ -0,0 +1,15 @@
1
+ """Fuentes built-in del render de Studio.
2
+
3
+ Espejo de BUILTIN_FONTS (packages/studio-render-core/src/builtin-fonts.ts en
4
+ omnitok-studio-api). Mantener en sync a mano si se agrega o quita una built-in.
5
+ """
6
+ BUILTIN_FONTS = (
7
+ "Nunito Sans",
8
+ "Open Sans",
9
+ "Inter",
10
+ "Manrope",
11
+ "Libre Franklin",
12
+ "Lora",
13
+ "Roboto Slab",
14
+ "Plus Jakarta Sans",
15
+ )
@@ -0,0 +1,94 @@
1
+ """CLI local: `studio-psd-extract <archivo.psd> --out-dir <carpeta>`.
2
+
3
+ Escribe `extraction.json` (contrato completo) y `assets/psd-<hash12>.png`, e imprime en stdout
4
+ solo el resumen compacto. Corre `extract()` directo, sin sandbox: el archivo es de la persona.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import extract, limits
14
+ from .compact import iter_nodes, outline, summary
15
+ from .guard import GuardError
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(prog="studio-psd-extract",
20
+ description="Extrae un PSD al contrato JSON de Omnitok Studio.")
21
+ p.add_argument("psd", type=Path, help="archivo .psd, o un extraction.json ya generado (con --artboard)")
22
+ p.add_argument("--out-dir", type=Path, help="carpeta de salida (obligatoria con un .psd)")
23
+ p.add_argument("--artboard", help="imprime el outline compacto de esa mesa en vez del resumen")
24
+ p.add_argument("--brand-fonts", default="",
25
+ help="familias de la marca separadas por coma (font_available=theme)")
26
+ p.add_argument("--font-dir", type=Path, action="append", default=[],
27
+ help="carpeta extra de fuentes (se puede repetir); se busca antes que las del sistema")
28
+ p.add_argument("--no-metrics", action="store_true", help="no medir fuentes sustitutas")
29
+ return p
30
+
31
+
32
+ def _write_assets(result: dict, out_dir: Path) -> None:
33
+ assets_dir = out_dir / "assets"
34
+ assets_dir.mkdir(parents=True, exist_ok=True)
35
+ for node in iter_nodes(result.get("layers") or []):
36
+ a = node.get("asset")
37
+ if not isinstance(a, dict) or "png" not in a:
38
+ continue
39
+ h = a["rgba_hash"]
40
+ name = f"psd-{h[:12]}.png"
41
+ path = (assets_dir / name).resolve()
42
+ path.write_bytes(a["png"])
43
+ node["asset"] = {"local_path": str(path), "filename": name, "content_type": "image/png",
44
+ "size": len(a["png"]), "hash": h, "tag": f"psd-hash:{h}",
45
+ "w": a["w"], "h": a["h"]}
46
+
47
+
48
+ def _error(code: str, message: str) -> int:
49
+ print(json.dumps({"error": {"code": code, "message": message}}, ensure_ascii=False))
50
+ return 2
51
+
52
+
53
+ def _print_outline(result: dict, artboard_id: str) -> int:
54
+ view = outline(result, artboard_id)
55
+ if view is None:
56
+ return _error("artboard_not_found", f"no existe la mesa {artboard_id!r}")
57
+ print(json.dumps(view, ensure_ascii=False))
58
+ return 0
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = _parser()
63
+ args = parser.parse_args(argv)
64
+ if not args.psd.is_file():
65
+ parser.error(f"no existe el archivo: {args.psd}")
66
+ if args.psd.suffix.lower() == ".json":
67
+ if not args.artboard:
68
+ parser.error("con un extraction.json se necesita --artboard")
69
+ return _print_outline(json.loads(args.psd.read_text(encoding="utf-8")), args.artboard)
70
+ if args.out_dir is None:
71
+ parser.error("con un .psd se necesita --out-dir")
72
+ theme = [f.strip() for f in args.brand_fonts.split(",") if f.strip()]
73
+ try:
74
+ result = extract(args.psd.read_bytes(), theme_families=theme, profile=limits.local(), mode="local")
75
+ except GuardError as err:
76
+ return _error(err.code, str(err))
77
+ except Exception as err: # noqa: BLE001 - el contrato del CLI es JSON + exit 2, no un traceback
78
+ return _error("extract_failed", f"{type(err).__name__}: {err}")
79
+ args.out_dir.mkdir(parents=True, exist_ok=True)
80
+ _write_assets(result, args.out_dir)
81
+ if not args.no_metrics:
82
+ from . import metrics
83
+
84
+ metrics.annotate(result, font_dirs=[*args.font_dir, *metrics.default_font_dirs()])
85
+ path = (args.out_dir / "extraction.json").resolve()
86
+ path.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8")
87
+ if args.artboard:
88
+ return _print_outline(result, args.artboard)
89
+ print(json.dumps(summary(result, path, (args.out_dir / "assets").resolve()), ensure_ascii=False))
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ """Conversión del color del documento (rgb|cmyk) a sRGB, para fondos y colores de texto.
2
+
3
+ Un único transform por documento: el texto y los fondos quedan consistentes entre sí.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import io
8
+
9
+ from PIL import Image, ImageCms
10
+
11
+
12
+ def _clamp255(value: float) -> int:
13
+ return max(0, min(255, round(float(value) * 255)))
14
+
15
+
16
+ class ColorConverter:
17
+ def __init__(self, icc: bytes | None, color_mode: str):
18
+ self.icc = icc
19
+ self.color_mode = color_mode
20
+ self._cmyk = None
21
+ self._cmyk_failed = False
22
+
23
+ def _cmyk_transform(self):
24
+ if self._cmyk is None and not self._cmyk_failed and self.icc:
25
+ try:
26
+ src = ImageCms.ImageCmsProfile(io.BytesIO(self.icc))
27
+ self._cmyk = ImageCms.buildTransform(src, ImageCms.createProfile("sRGB"), "CMYK", "RGB")
28
+ except (ImageCms.PyCMSError, OSError):
29
+ self._cmyk_failed = True
30
+ return self._cmyk
31
+
32
+ def image(self, img: Image.Image) -> tuple[Image.Image, list[dict]]:
33
+ """Imagen compuesta -> (RGBA sRGB, warnings)."""
34
+ if self.color_mode == "cmyk":
35
+ if img.mode != "CMYK": # p. ej. el lienzo vacío de respaldo de raster._composite
36
+ return img.convert("RGBA"), []
37
+ if not self.icc:
38
+ return img.convert("RGB").convert("RGBA"), [{"code": "cmyk_no_icc"}]
39
+ transform = self._cmyk_transform()
40
+ if transform is None:
41
+ return img.convert("RGB").convert("RGBA"), [{"code": "icc_conversion_failed"}]
42
+ return ImageCms.applyTransform(img, transform).convert("RGBA"), []
43
+ img = img.convert("RGBA")
44
+ if not self.icc:
45
+ return img, [{"code": "icc_missing"}]
46
+ try:
47
+ src = ImageCms.ImageCmsProfile(io.BytesIO(self.icc))
48
+ return ImageCms.profileToProfile(img, src, ImageCms.createProfile("sRGB"), outputMode="RGBA"), []
49
+ except (ImageCms.PyCMSError, OSError):
50
+ return img, [{"code": "icc_conversion_failed"}]
51
+
52
+ def hex(self, values) -> str | None:
53
+ """`Values` de un FillColor del engine: [A,R,G,B] o [A,C,M,Y,K], floats 0..1 -> '#rrggbb'."""
54
+ try:
55
+ vals = [float(v) for v in values]
56
+ except (TypeError, ValueError):
57
+ return None
58
+ if len(vals) == 4:
59
+ return "#" + "".join(f"{_clamp255(c):02x}" for c in vals[1:])
60
+ if len(vals) == 5:
61
+ px = Image.new("CMYK", (1, 1), tuple(_clamp255(c) for c in vals[1:]))
62
+ transform = self._cmyk_transform()
63
+ rgb = (ImageCms.applyTransform(px, transform) if transform else px.convert("RGB")).getpixel((0, 0))
64
+ return "#" + "".join(f"{c:02x}" for c in rgb[:3])
65
+ return None
@@ -0,0 +1,111 @@
1
+ """Vistas compactas de la extracción (sin red): lo que ve el agente por defecto."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Iterator, List, Optional
6
+
7
+ PREVIEW_CHARS = 80
8
+
9
+
10
+ def iter_nodes(nodes: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
11
+ """Recorre el árbol de capas en pre-orden."""
12
+ for n in nodes:
13
+ yield n
14
+ yield from iter_nodes(n.get("children") or [])
15
+
16
+
17
+ def compact_text(result: Dict[str, Any], full_ids: Optional[set]) -> None:
18
+ """Reemplaza `text.runs` por {align, run_count, text_preview}, salvo en `full_ids` (None = todos)."""
19
+ for n in iter_nodes(result.get("layers") or []):
20
+ t = n.get("text")
21
+ if not isinstance(t, dict) or "runs" not in t:
22
+ continue
23
+ if full_ids is None or n.get("id") in full_ids:
24
+ continue
25
+ runs = t.get("runs") or []
26
+ joined = "".join(r.get("text", "") for r in runs)
27
+ n["text"] = {"align": t.get("align"), "run_count": len(runs),
28
+ "text_preview": joined[:PREVIEW_CHARS]}
29
+
30
+
31
+ MAX_SUSPECT_IN_SUMMARY = 30
32
+
33
+
34
+ def _metrics_summary(nodes) -> Optional[Dict[str, Any]]:
35
+ suspect, missing, counts = [], {}, {"match": 0, "skipped": 0}
36
+ for n in nodes:
37
+ t = n.get("text")
38
+ m = t.get("metrics") if isinstance(t, dict) else None
39
+ if not m:
40
+ continue
41
+ run = (t.get("runs") or [{}])[0]
42
+ if m["status"] == "suspect":
43
+ suspect.append({"layer": n["id"], "name": n.get("name"), "font_ps_name": run.get("font_ps_name"),
44
+ "text_preview": (run.get("text") or "")[:40], "width_ratio": m["width_ratio"],
45
+ "tracking_needed_em": m["tracking_needed_em"]})
46
+ elif m["status"] == "font_not_installed":
47
+ ps = run.get("font_ps_name") or ""
48
+ missing[ps] = missing.get(ps, 0) + 1
49
+ else:
50
+ counts[m["status"]] += 1
51
+ if not (suspect or missing or counts["match"] or counts["skipped"]):
52
+ return None
53
+ return {"suspect": suspect[:MAX_SUSPECT_IN_SUMMARY], "suspect_total": len(suspect),
54
+ "font_not_installed": missing, **counts}
55
+
56
+
57
+ ASSET_KEYS = ("filename", "size", "tag", "w", "h") # local_path/hash/content_type quedan en extraction.json
58
+ OUTLINE_PREVIEW_CHARS = 40
59
+
60
+
61
+ def summary(result: Dict[str, Any], extraction_path: Path, assets_dir: Path) -> Dict[str, Any]:
62
+ """Resumen para stdout del CLI: todo menos el árbol de capas (ese queda en extraction.json)."""
63
+ nodes = list(iter_nodes(result.get("layers") or []))
64
+ text_nodes = [n for n in nodes if isinstance(n.get("text"), dict)]
65
+ warnings: Dict[str, int] = {}
66
+ for w in result.get("warnings") or []:
67
+ warnings[w.get("code")] = warnings.get(w.get("code"), 0) + 1
68
+ metrics_view = _metrics_summary(nodes)
69
+ out = {
70
+ "extraction_json": str(extraction_path),
71
+ "assets_dir": str(assets_dir),
72
+ "extractor": result.get("extractor"),
73
+ "document": result.get("document"),
74
+ "artboards": [{"id": a["id"], "name": a["name"], "w": a["rect"]["w"], "h": a["rect"]["h"]}
75
+ for a in result.get("artboards") or []],
76
+ "counts": {"layers": len(nodes), "text_layers": len(text_nodes),
77
+ "runs": sum(len(n["text"].get("runs") or []) for n in text_nodes)},
78
+ "fonts_used": result.get("fonts_used") or [],
79
+ "warnings": warnings,
80
+ "assets": [{"node": n["id"], **{k: n["asset"][k] for k in ASSET_KEYS if k in n["asset"]}}
81
+ for n in nodes if isinstance(n.get("asset"), dict)],
82
+ }
83
+ if metrics_view is not None:
84
+ out["metrics"] = metrics_view
85
+ return out
86
+
87
+
88
+ def outline(result: Dict[str, Any], artboard_id: str) -> Optional[Dict[str, Any]]:
89
+ """Capas de una mesa en pre-orden, compactas (medido: orden de 2-8 KB por mesa; la A1 de Indurama, ~7,8 KB). None si la mesa no existe."""
90
+ board = next((a for a in result.get("artboards") or [] if a.get("id") == artboard_id), None)
91
+ if board is None:
92
+ return None
93
+ rows: List[Dict[str, Any]] = []
94
+
95
+ def walk(nodes, depth):
96
+ for n in nodes:
97
+ if n.get("artboard") == artboard_id and n.get("kind") != "artboard":
98
+ bb = n.get("bbox")
99
+ row = {"id": n["id"], "name": n.get("name"), "kind": n.get("kind"), "depth": depth,
100
+ "bbox": [bb["x"], bb["y"], bb["w"], bb["h"]] if isinstance(bb, dict) else bb}
101
+ t = n.get("text")
102
+ if isinstance(t, dict):
103
+ joined = "".join(r.get("text", "") for r in t.get("runs") or []) or t.get("text_preview", "")
104
+ row["text_preview"] = joined[:OUTLINE_PREVIEW_CHARS]
105
+ if t.get("metrics"):
106
+ row["metrics"] = t["metrics"]["status"]
107
+ rows.append(row)
108
+ walk(n.get("children") or [], depth + 1)
109
+
110
+ walk(result.get("layers") or [], 0)
111
+ return {"artboard": {"id": board["id"], "name": board.get("name"), "rect": board.get("rect")}, "nodes": rows}
@@ -0,0 +1,53 @@
1
+ """Resolución de nombres PostScript a family/weight/italic y disponibilidad."""
2
+ import re
3
+
4
+ from .builtin_fonts import BUILTIN_FONTS
5
+
6
+ _WEIGHTS = {
7
+ "thin": 100, "hairline": 100,
8
+ "extralight": 200, "ultralight": 200,
9
+ "light": 300,
10
+ "regular": 400, "normal": 400, "book": 400, "roman": 400,
11
+ "medium": 500,
12
+ "semibold": 600, "demibold": 600, "demi": 600,
13
+ "bold": 700,
14
+ "extrabold": 800, "ultrabold": 800, "heavy": 800,
15
+ "black": 900,
16
+ }
17
+ _ITALIC = {"italic", "it", "oblique", "ital"}
18
+
19
+
20
+ def _split_camel(s: str) -> str:
21
+ # "OpenSans" -> "Open Sans"; "PlusJakartaSans" -> "Plus Jakarta Sans"
22
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", s)
23
+
24
+
25
+ def _style_tokens(style: str) -> list[str]:
26
+ return [t.lower() for t in re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])", style)]
27
+
28
+
29
+ def resolve(ps_name: str, theme_families: set[str]) -> dict:
30
+ base, _, style = (ps_name or "").partition("-")
31
+ family = _split_camel(base).strip()
32
+ weight, italic = 400, False
33
+ # Une tokens: "SemiBold", "BoldItalic", "Semi"+"Bold" -> comparar por concatenación.
34
+ tokens = _style_tokens(style)
35
+ i = 0
36
+ while i < len(tokens):
37
+ tok = tokens[i]
38
+ if i + 1 < len(tokens) and tok + tokens[i + 1] in _WEIGHTS:
39
+ weight = _WEIGHTS[tok + tokens[i + 1]]
40
+ i += 1
41
+ elif tok in _ITALIC:
42
+ italic = True
43
+ elif tok in _WEIGHTS:
44
+ weight = _WEIGHTS[tok]
45
+ i += 1
46
+
47
+ fam_l = family.lower()
48
+ available = None
49
+ if any(fam_l == b.lower() for b in BUILTIN_FONTS):
50
+ available = "builtin"
51
+ elif any(fam_l == t.lower() for t in theme_families):
52
+ available = "theme"
53
+ return {"family": family, "weight": weight, "italic": italic, "available": available}
@@ -0,0 +1,25 @@
1
+ import struct
2
+ from . import limits
3
+
4
+ class GuardError(Exception):
5
+ def __init__(self, code: str, message: str):
6
+ super().__init__(message)
7
+ self.code = code
8
+
9
+ def check_header(psd_bytes: bytes, profile: "limits.Profile | None" = None) -> dict:
10
+ p = profile or limits.server()
11
+ if len(psd_bytes) > p.max_bytes:
12
+ raise GuardError("psd_too_large", f"PSD supera {p.max_bytes} bytes")
13
+ if len(psd_bytes) < 26 or psd_bytes[:4] != b"8BPS":
14
+ raise GuardError("invalid_psd", "firma 8BPS ausente")
15
+ sig, ver, _res, channels, height, width, depth, mode = struct.unpack(">4sH6sHIIHH", psd_bytes[:26])
16
+ if ver != 1:
17
+ raise GuardError("psd_unsupported_mode", "PSB (version 2) no soportado en P0")
18
+ color_mode = limits._COLOR_MODES.get(mode, str(mode))
19
+ if color_mode not in ("rgb", "cmyk"):
20
+ raise GuardError("psd_unsupported_mode", f"color mode {color_mode} no soportado (solo RGB y CMYK)")
21
+ if depth > 16:
22
+ raise GuardError("psd_unsupported_mode", f"depth {depth} > 16 no soportado")
23
+ if width * height > p.max_canvas_pixels:
24
+ raise GuardError("psd_too_large", f"canvas {width}x{height} supera {p.max_canvas_pixels}px")
25
+ return {"width": width, "height": height, "color_mode": color_mode, "depth": depth, "n_layers": 0}
@@ -0,0 +1,73 @@
1
+ """Árbol de capas de un PSDImage de psd-tools."""
2
+ from __future__ import annotations
3
+
4
+ from . import model
5
+
6
+
7
+ _EMPTY = {"x": 0, "y": 0, "w": 0, "h": 0}
8
+
9
+
10
+ def _safe_bbox(layer) -> dict:
11
+ try:
12
+ return model.bbox_dict(layer.bbox)
13
+ except ValueError: # p.ej. Artboard sin artboardRect
14
+ return dict(_EMPTY)
15
+
16
+
17
+ def _artboard_by_center(bbox: dict, artboards: list[dict]):
18
+ if bbox["w"] <= 0 or bbox["h"] <= 0:
19
+ return None
20
+ cx = bbox["x"] + bbox["w"] / 2
21
+ cy = bbox["y"] + bbox["h"] / 2
22
+ for a in artboards:
23
+ r = a["rect"]
24
+ if r["x"] <= cx < r["x"] + r["w"] and r["y"] <= cy < r["y"] + r["h"]:
25
+ return a["id"]
26
+ return None
27
+
28
+
29
+ def walk(psd, artboards: list[dict]) -> list[dict]:
30
+ """Nodos {id,name,kind,artboard,bbox,visible,opacity,clipping,mask,effects,children}.
31
+
32
+ `artboard`: hijos directos de una mesa heredan su id; el resto, la mesa que contiene
33
+ el centro de su propio bbox (bbox vacío o fuera de toda mesa -> None).
34
+ """
35
+ counter = [0]
36
+ unused = list(artboards)
37
+
38
+ def resolve_board(layer, bbox):
39
+ """Id de la mesa que `detect` devolvió para esta capa (por name+rect), o None."""
40
+ for a in unused:
41
+ if a["name"] == layer.name and a["rect"] == bbox:
42
+ unused.remove(a)
43
+ return a["id"]
44
+ return None
45
+
46
+ def node(layer, inherited, parent_is_artboard):
47
+ counter[0] += 1
48
+ kind = model.map_kind(layer)
49
+ bbox = _safe_bbox(layer)
50
+ if kind == "artboard":
51
+ board = resolve_board(layer, bbox) # None si detect la omitió
52
+ elif parent_is_artboard:
53
+ board = inherited
54
+ else:
55
+ board = _artboard_by_center(bbox, artboards)
56
+ children = []
57
+ if layer.is_group():
58
+ children = [node(c, board, kind == "artboard") for c in layer]
59
+ return {
60
+ "id": f"L{counter[0]}",
61
+ "name": layer.name,
62
+ "kind": kind,
63
+ "artboard": board,
64
+ "bbox": bbox,
65
+ "visible": layer.is_visible(),
66
+ "opacity": model.opacity_unit(layer.opacity),
67
+ "clipping": bool(layer.clipping),
68
+ "mask": layer.mask is not None,
69
+ "effects": model.effect_names(layer),
70
+ "children": children,
71
+ }
72
+
73
+ return [node(l, None, False) for l in psd]
@@ -0,0 +1,36 @@
1
+ MAX_BYTES = 64 * 1024 * 1024
2
+ MAX_CANVAS_PIXELS = 40_000_000
3
+ MAX_LAYER_PIXELS = 40_000_000
4
+ MAX_LAYERS = 2000
5
+ MAX_DEPTH = 20
6
+ EXTRACT_TIMEOUT_S = 40
7
+ RLIMIT_AS_BYTES = 2 * 1024 ** 3
8
+ MAX_ASSETS = 12
9
+ UPLOAD_CONCURRENCY = 4
10
+ TOOL_DEADLINE_S = 45
11
+ _COLOR_MODES = {1: "grayscale", 3: "rgb", 4: "cmyk", 9: "lab"}
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Profile:
17
+ max_bytes: int
18
+ max_canvas_pixels: int
19
+ max_layer_pixels: int
20
+ max_layers: int
21
+
22
+
23
+ LOCAL_MAX_BYTES = 1024 ** 3
24
+ LOCAL_MAX_CANVAS_PIXELS = 300_000_000
25
+ LOCAL_MAX_LAYER_PIXELS = 300_000_000
26
+ LOCAL_MAX_LAYERS = 10_000
27
+
28
+
29
+ def server() -> Profile:
30
+ """Perfil del contenedor compartido. Se arma en cada llamada (los tests parchean las constantes)."""
31
+ return Profile(MAX_BYTES, MAX_CANVAS_PIXELS, MAX_LAYER_PIXELS, MAX_LAYERS)
32
+
33
+
34
+ def local() -> Profile:
35
+ """Perfil del CLI: la máquina es de la persona y el archivo es suyo; sin contenedor que proteger."""
36
+ return Profile(LOCAL_MAX_BYTES, LOCAL_MAX_CANVAS_PIXELS, LOCAL_MAX_LAYER_PIXELS, LOCAL_MAX_LAYERS)
@@ -0,0 +1,234 @@
1
+ """`substituted`: detecta si Photoshop dibujó una capa con una fuente distinta de la declarada.
2
+
3
+ La caja de la capa de texto (`bbox`) es la tinta que Photoshop rasterizó con la fuente que usó de
4
+ verdad. Se compara con el ancho de tinta predicho con la fuente declarada (fontTools: avances `hmtx`
5
+ + `kern` clásico + tracking). Solo local: el servidor no tiene los archivos de fuente. fontTools se
6
+ importa al usar estas funciones, no al importar el paquete.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from .compact import iter_nodes
14
+
15
+ MATCH_TOLERANCE = 0.04
16
+ INVALID_FONT = "AdobeInvalidFont"
17
+ FONT_SUFFIXES = {".ttf", ".otf", ".ttc", ".otc"}
18
+ LINE_BREAKS = ("\r", "\n", "\x03")
19
+
20
+
21
+ def default_font_dirs() -> list[Path]:
22
+ home = Path.home()
23
+ dirs = [
24
+ home / "Library" / "Fonts",
25
+ Path("/Library/Fonts"),
26
+ Path("/System/Library/Fonts"),
27
+ home / "Library" / "Application Support" / "Adobe" / "CoreSync" / "plugins" / "livetype" / ".r",
28
+ home / ".fonts",
29
+ home / ".local" / "share" / "fonts",
30
+ Path("/usr/share/fonts"),
31
+ ]
32
+ if os.environ.get("WINDIR"):
33
+ dirs.append(Path(os.environ["WINDIR"]) / "Fonts")
34
+ if os.environ.get("LOCALAPPDATA"):
35
+ dirs.append(Path(os.environ["LOCALAPPDATA"]) / "Microsoft" / "Windows" / "Fonts")
36
+ return dirs
37
+
38
+
39
+ def _index_font(index: dict, font, path: str, number: int) -> None:
40
+ """Registra el nombre PostScript principal y, si es variable, el de cada instancia con nombre."""
41
+ name = font["name"]
42
+ ps = name.getDebugName(6)
43
+ if ps:
44
+ index.setdefault(ps, (path, number, None))
45
+ if "fvar" in font:
46
+ for inst in font["fvar"].instances:
47
+ if inst.postscriptNameID in (None, 0xFFFF):
48
+ continue
49
+ ips = name.getDebugName(inst.postscriptNameID)
50
+ if ips:
51
+ index.setdefault(ips, (path, number, dict(inst.coordinates)))
52
+
53
+
54
+ def _open_font(path: str, number: int, coords: dict | None):
55
+ """Abre la fuente; si `coords` viene, la instancia en esas coordenadas (fuente variable)."""
56
+ from fontTools.ttLib import TTFont
57
+
58
+ if not coords:
59
+ return TTFont(path, fontNumber=number, lazy=True)
60
+ from fontTools.varLib.instancer import instantiateVariableFont
61
+
62
+ return instantiateVariableFont(TTFont(path, fontNumber=number), coords, inplace=True)
63
+
64
+
65
+ def index_fonts(dirs) -> dict[str, tuple[str, int, dict | None]]:
66
+ """Nombre PostScript -> (ruta, índice en la colección, coords de instancia variable o None). Cierra cada archivo."""
67
+ from fontTools.ttLib import TTCollection, TTFont
68
+
69
+ index: dict[str, tuple[str, int, dict | None]] = {}
70
+ for d in dirs:
71
+ if not Path(d).is_dir():
72
+ continue
73
+ for root, _subdirs, files in os.walk(d):
74
+ for name in files:
75
+ path = Path(root) / name
76
+ suffix = path.suffix.lower()
77
+ if suffix not in FONT_SUFFIXES:
78
+ continue
79
+ try:
80
+ if suffix in (".ttc", ".otc"):
81
+ coll = TTCollection(str(path), lazy=True)
82
+ try:
83
+ for i, font in enumerate(coll.fonts):
84
+ _index_font(index, font, str(path), i)
85
+ finally:
86
+ coll.close()
87
+ else:
88
+ font = TTFont(str(path), lazy=True)
89
+ try:
90
+ _index_font(index, font, str(path), 0)
91
+ finally:
92
+ font.close()
93
+ except Exception: # noqa: BLE001 - un archivo roto no corta el índice
94
+ continue
95
+ return index
96
+
97
+
98
+ def predicted_ink_width(font, text: str, size_px: float) -> tuple[float, int] | None:
99
+ """(ancho de tinta sin tracking en px, cantidad de huecos entre glifos), o None si falta un glifo."""
100
+ from fontTools.pens.boundsPen import BoundsPen
101
+
102
+ cmap = font.getBestCmap() or {}
103
+ names = [cmap.get(ord(ch)) for ch in text]
104
+ if not names or None in names:
105
+ return None
106
+ hmtx = font["hmtx"]
107
+ kern: dict = {}
108
+ if "kern" in font:
109
+ for sub in getattr(font["kern"], "kernTables", None) or []:
110
+ kern.update(getattr(sub, "kernTable", None) or {})
111
+ glyphs = font.getGlyphSet()
112
+
113
+ def bounds(glyph):
114
+ pen = BoundsPen(glyphs)
115
+ glyphs[glyph].draw(pen)
116
+ return pen.bounds
117
+
118
+ x, positions = 0, []
119
+ for i, g in enumerate(names):
120
+ positions.append(x)
121
+ x += hmtx[g][0] + (kern.get((g, names[i + 1]), 0) if i + 1 < len(names) else 0)
122
+ first, last = bounds(names[0]), bounds(names[-1])
123
+ if first is None or last is None:
124
+ return None
125
+ units = positions[-1] + last[2] - (positions[0] + first[0])
126
+ return round(units * size_px / font["head"].unitsPerEm, 4), len(names) - 1
127
+
128
+
129
+ def merge_runs(runs: list[dict]) -> dict | None:
130
+ """Une runs con la misma fuente, tamaño, caps y escala (Photoshop los parte por tracking por letra).
131
+
132
+ El tracking resultante se pondera por caracteres. None si algún run difiere en esas claves.
133
+ """
134
+ if not runs:
135
+ return None
136
+ keys = ("font_ps_name", "size_px", "caps", "h_scale")
137
+ first = runs[0]
138
+ if any(tuple(r.get(k) for k in keys) != tuple(first.get(k) for k in keys) for r in runs[1:]):
139
+ return None
140
+ lengths = [len(r.get("text") or "") for r in runs]
141
+ total = sum(lengths)
142
+ tracking = (sum(float(r.get("tracking_em") or 0.0) * n for r, n in zip(runs, lengths)) / total) if total else 0.0
143
+ return {**first, "text": "".join(r.get("text") or "" for r in runs), "tracking_em": round(tracking, 6)}
144
+
145
+
146
+ def _metric(status, *, ratio=None, needed=None, font_file=None, reason=None) -> dict:
147
+ return {"status": status, "width_ratio": ratio, "tracking_needed_em": needed,
148
+ "font_file": font_file, "reason": reason}
149
+
150
+
151
+ def annotate(result: dict, *, font_dirs) -> None:
152
+ """Escribe `text.metrics` en cada capa de texto y `suspect_runs` en `fonts_used`."""
153
+ complex_ids = {w.get("layer") for w in result.get("warnings") or []
154
+ if w.get("code") == "text_transform_complex"}
155
+ index = None
156
+ opened: dict[tuple, object] = {}
157
+ suspects: dict[str, int] = {}
158
+ try:
159
+ for node in iter_nodes(result.get("layers") or []):
160
+ t = node.get("text")
161
+ if not isinstance(t, dict) or "runs" not in t:
162
+ continue
163
+ runs = t.get("runs") or []
164
+ joined = "".join(r.get("text", "") for r in runs).strip()
165
+ if not joined:
166
+ t["metrics"] = _metric("skipped", reason="empty")
167
+ continue
168
+ run = merge_runs(runs)
169
+ if run is None:
170
+ t["metrics"] = _metric("skipped", reason="multi_run")
171
+ continue
172
+ if t.get("shape") == "area": # caja con ajuste automático: la tinta abarca varias líneas
173
+ t["metrics"] = _metric("skipped", reason="area_text")
174
+ continue
175
+ line_h = float(run.get("line_height_px") or 0)
176
+ if t.get("shape") is None and line_h > 0 and float((node.get("bbox") or {}).get("h") or 0) > 1.5 * line_h:
177
+ t["metrics"] = _metric("skipped", reason="multiline")
178
+ continue
179
+ if run.get("caps") == "small":
180
+ t["metrics"] = _metric("skipped", reason="small_caps")
181
+ continue
182
+ text = (run.get("text") or "").strip()
183
+ if run.get("caps") == "all":
184
+ text = text.upper()
185
+ if any(b in text for b in LINE_BREAKS):
186
+ t["metrics"] = _metric("skipped", reason="multiline")
187
+ continue
188
+ if node.get("id") in complex_ids:
189
+ t["metrics"] = _metric("skipped", reason="transform_complex")
190
+ continue
191
+ ps = run.get("font_ps_name") or ""
192
+ if ps == INVALID_FONT:
193
+ t["metrics"] = _metric("font_not_installed")
194
+ continue
195
+ if index is None:
196
+ index = index_fonts(font_dirs)
197
+ entry = index.get(ps)
198
+ if entry is None:
199
+ t["metrics"] = _metric("font_not_installed")
200
+ continue
201
+ font_file = Path(entry[0]).name
202
+ try:
203
+ key = (entry[0], entry[1], tuple(sorted(entry[2].items())) if entry[2] else None)
204
+ if key not in opened:
205
+ opened[key] = _open_font(*entry)
206
+ size = float(run.get("size_px") or 0)
207
+ pred = predicted_ink_width(opened[key], text, size) if size > 0 else None
208
+ except Exception: # noqa: BLE001 - una capa con fuente rara no corta el resto
209
+ t["metrics"] = _metric("skipped", font_file=font_file, reason="metrics_error")
210
+ continue
211
+ if pred is None:
212
+ t["metrics"] = _metric("skipped", font_file=font_file, reason="glyph_missing")
213
+ continue
214
+ base, gaps = pred
215
+ base *= float(run.get("h_scale") or 1.0) # escala horizontal: afecta glifos y avances
216
+ tracking = float(run.get("tracking_em") or 0.0)
217
+ predicted = base + tracking * size * gaps
218
+ observed = float((node.get("bbox") or {}).get("w") or 0)
219
+ if predicted <= 0 or observed <= 0:
220
+ t["metrics"] = _metric("skipped", font_file=font_file, reason="empty")
221
+ continue
222
+ ratio = observed / predicted
223
+ needed = round((observed - base) / (size * gaps), 4) if gaps else None
224
+ tolerance = max(MATCH_TOLERANCE, 2.0 / observed) # ±2 px de antialias en etiquetas chicas
225
+ status = "match" if abs(ratio - 1) <= tolerance else "suspect"
226
+ t["metrics"] = _metric(status, ratio=round(ratio, 4), needed=needed, font_file=font_file)
227
+ if status == "suspect":
228
+ fam = run.get("family") or ""
229
+ suspects[fam] = suspects.get(fam, 0) + 1
230
+ finally:
231
+ for font in opened.values():
232
+ font.close()
233
+ for f in result.get("fonts_used") or []:
234
+ f["suspect_runs"] = suspects.get(f.get("family"), 0)
@@ -0,0 +1,41 @@
1
+ """Helpers puros (sin importar psd-tools) para mapear capas a nuestro modelo."""
2
+ from __future__ import annotations
3
+
4
+ # Orden importa: Artboard hereda de Group; los subtipos de ajuste/relleno
5
+ # heredan de AdjustmentLayer/FillLayer.
6
+ _BY_CLASS = (
7
+ ("Artboard", "artboard"),
8
+ ("Group", "group"),
9
+ ("TypeLayer", "text"),
10
+ ("SmartObjectLayer", "smartobject"),
11
+ ("ShapeLayer", "shape"),
12
+ ("AdjustmentLayer", "adjustment"),
13
+ ("FillLayer", "fill"),
14
+ ("PixelLayer", "pixel"),
15
+ )
16
+
17
+
18
+ def map_kind(layer) -> str:
19
+ names = {c.__name__ for c in type(layer).__mro__}
20
+ for cls, kind in _BY_CLASS:
21
+ if cls in names:
22
+ return kind
23
+ return "pixel"
24
+
25
+
26
+ def bbox_dict(bbox) -> dict:
27
+ """(left, top, right, bottom) -> {x, y, w, h} en coords de documento."""
28
+ left, top, right, bottom = bbox
29
+ return {"x": left, "y": top, "w": right - left, "h": bottom - top}
30
+
31
+
32
+ def opacity_unit(opacity: int) -> float:
33
+ """psd-tools da 0-255; exponemos 0..1."""
34
+ return round(opacity / 255, 4)
35
+
36
+
37
+ def effect_names(layer) -> list[str]:
38
+ effects = layer.effects
39
+ if not layer.has_effects() or not effects.enabled:
40
+ return []
41
+ return [e.__class__.__name__ for e in effects]
@@ -0,0 +1,143 @@
1
+ """Rasteriza una mesa SIN capas de texto (el texto se re-emite como bloques)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import io
6
+
7
+ from PIL import Image
8
+
9
+ from .model import bbox_dict, effect_names
10
+
11
+
12
+ def _keep(layer) -> bool:
13
+ return layer.is_visible() and layer.kind != "type"
14
+
15
+
16
+ def _icc_profile(psd):
17
+ from psd_tools.constants import Resource
18
+ data = psd.image_resources.get_data(Resource.ICC_PROFILE)
19
+ return data or None
20
+
21
+
22
+ def _color_mode(psd) -> str:
23
+ return getattr(getattr(psd, "color_mode", None), "name", "rgb").lower()
24
+
25
+
26
+ def _in_rect(layer, rect) -> bool:
27
+ try:
28
+ b = bbox_dict(layer.bbox)
29
+ except Exception:
30
+ return False
31
+ if b["w"] <= 0 or b["h"] <= 0:
32
+ return False
33
+ cx, cy = b["x"] + b["w"] / 2, b["y"] + b["h"] / 2
34
+ return rect["x"] <= cx < rect["x"] + rect["w"] and rect["y"] <= cy < rect["y"] + rect["h"]
35
+
36
+
37
+ def _scope(psd, rect):
38
+ """Capas de la mesa: descendientes del Artboard con ese rect; si no hay
39
+ (A0 = canvas), todo el documento."""
40
+ for layer in psd.descendants():
41
+ if layer.kind == "artboard":
42
+ try:
43
+ if bbox_dict(layer.bbox) == rect:
44
+ return layer.descendants()
45
+ except ValueError:
46
+ continue
47
+ return psd.descendants()
48
+
49
+
50
+ def _warnings(psd, rect) -> list[dict]:
51
+ out = []
52
+ for layer in _scope(psd, rect):
53
+ if not layer.is_visible() or layer.kind in ("group", "artboard"):
54
+ continue
55
+ feats = []
56
+ if effect_names(layer):
57
+ feats.append("effects")
58
+ if layer.kind == "adjustment":
59
+ feats.append("adjustment")
60
+ if layer.kind == "smartobject":
61
+ so = getattr(layer, "smart_object", None)
62
+ if so is not None and getattr(so, "kind", None) == "external":
63
+ feats.append("smartobject_linked")
64
+ if not feats:
65
+ continue
66
+ if layer.kind != "adjustment" and not _in_rect(layer, rect):
67
+ continue
68
+ for f in feats:
69
+ out.append({"code": "unsupported_feature", "feature": f, "layer": layer.name})
70
+ return out
71
+
72
+
73
+ def _has_stroke(layer) -> bool:
74
+ """Stroke vectorial (aggdraw) o efecto Stroke (scipy)."""
75
+ try:
76
+ if getattr(layer, "has_stroke", None) and layer.has_stroke():
77
+ return True
78
+ except Exception:
79
+ pass
80
+ try:
81
+ return "Stroke" in effect_names(layer)
82
+ except Exception:
83
+ return False
84
+
85
+
86
+ def _needs_extras(layer) -> bool:
87
+ """Capas que en psd-tools piden scipy/aggdraw (no incluidos): ajustes,
88
+ rellenos de gradiente y cualquier efecto."""
89
+ try:
90
+ return layer.kind in ("adjustment", "gradientfill") or bool(effect_names(layer))
91
+ except Exception:
92
+ return False
93
+
94
+
95
+ def _composite(psd, viewport, rect, warnings):
96
+ """composite() tolerante a ImportError (aggdraw/scipy ausentes): omite las
97
+ capas afectadas con warning; nunca propaga el ImportError."""
98
+ def run(flt):
99
+ return psd.composite(viewport=viewport, force=True, layer_filter=flt, apply_icc=False)
100
+ try:
101
+ return run(_keep)
102
+ except ImportError:
103
+ pass
104
+ scope = [l for l in _scope(psd, rect) if l.is_visible() and l.kind not in ("group", "artboard")]
105
+ skip = {id(l) for l in scope if _has_stroke(l)}
106
+ for l in scope:
107
+ if id(l) in skip:
108
+ warnings.append({"code": "unsupported_feature", "feature": "stroke", "layer": l.name})
109
+ try:
110
+ return run(lambda l: _keep(l) and id(l) not in skip)
111
+ except ImportError:
112
+ pass
113
+ skip2 = skip | {id(l) for l in scope if _needs_extras(l)}
114
+ for l in scope:
115
+ if id(l) in skip2 - skip:
116
+ warnings.append({"code": "unsupported_feature", "feature": "composite_dependency", "layer": l.name})
117
+ try:
118
+ return run(lambda l: _keep(l) and id(l) not in skip2)
119
+ except ImportError:
120
+ warnings.append({"code": "unsupported_feature", "feature": "composite_dependency", "layer": None})
121
+ return Image.new("RGBA", (viewport[2] - viewport[0], viewport[3] - viewport[1]), (0, 0, 0, 0))
122
+
123
+
124
+ def artboard_background(psd, artboard: dict, converter=None) -> dict:
125
+ from .color import ColorConverter
126
+
127
+ r = artboard["rect"]
128
+ viewport = (r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])
129
+ warnings: list[dict] = []
130
+ if converter is None:
131
+ converter = ColorConverter(_icc_profile(psd), _color_mode(psd))
132
+ img, color_warnings = converter.image(_composite(psd, viewport, r, warnings))
133
+ warnings.extend(color_warnings)
134
+ warnings.extend(_warnings(psd, r))
135
+ buf = io.BytesIO()
136
+ img.save(buf, "PNG")
137
+ return {
138
+ "png": buf.getvalue(),
139
+ "w": img.width,
140
+ "h": img.height,
141
+ "rgba_hash": hashlib.sha256(img.convert("RGBA").tobytes()).hexdigest(),
142
+ "warnings": warnings,
143
+ }
@@ -0,0 +1,138 @@
1
+ """Runs de texto de una capa tipo (psd-tools TypeLayer.engine_dict / resource_dict)."""
2
+ from . import fonts as _fonts
3
+ from .color import ColorConverter
4
+
5
+ _CAPS = {0: "none", 1: "small", 2: "all"}
6
+ _ALIGN = {0: "left", 1: "right", 2: "center"} # 3..6 = justify*
7
+
8
+
9
+ def _val(v, default=None):
10
+ """Desenvuelve elementos de engine_data (Integer/Float/String/Bool) a Python."""
11
+ if v is None:
12
+ return default
13
+ return getattr(v, "value", v)
14
+
15
+
16
+ def _get(d, key):
17
+ try:
18
+ return d.get(key) if d else None
19
+ except AttributeError:
20
+ return None
21
+
22
+
23
+ def _seq(v):
24
+ try:
25
+ return list(v) if v else []
26
+ except TypeError:
27
+ return []
28
+
29
+
30
+ def _style_get(data, default, key):
31
+ for src in (data, default):
32
+ v = _get(src, key)
33
+ if v is not None:
34
+ return v
35
+ return None
36
+
37
+
38
+ def _default_sheet(resource):
39
+ sets = _seq(_get(resource, "StyleSheetSet"))
40
+ idx = _val(_get(resource, "TheNormalStyleSheet"))
41
+ try:
42
+ return _get(sets[int(idx)], "StyleSheetData")
43
+ except (TypeError, ValueError, IndexError):
44
+ return None
45
+
46
+
47
+ _DEFAULT_HEX = ColorConverter(None, "rgb").hex
48
+
49
+
50
+ def _color(fill, to_hex):
51
+ return to_hex([_val(x) for x in _seq(_get(fill, "Values"))])
52
+
53
+
54
+ def _align(engine, resource):
55
+ arr = _seq(_get(_get(engine, "ParagraphRun"), "RunArray"))
56
+ props = _get(_get(arr[0], "ParagraphSheet"), "Properties") if arr else None
57
+ j = _val(_get(props, "Justification"))
58
+ if j is None:
59
+ sets = _seq(_get(resource, "ParagraphSheetSet"))
60
+ try:
61
+ props = _get(sets[int(_val(_get(resource, "TheNormalParagraphSheet")))], "Properties")
62
+ j = _val(_get(props, "Justification"))
63
+ except (TypeError, ValueError, IndexError):
64
+ j = None
65
+ if j is None:
66
+ return "left"
67
+ try:
68
+ j = int(j)
69
+ except (TypeError, ValueError):
70
+ return "left"
71
+ return _ALIGN.get(j, "justify")
72
+
73
+
74
+ def _shape(layer):
75
+ """'point' | 'area' (caja con ajuste de línea automático) | None si psd-tools no lo determina."""
76
+ try:
77
+ kind = layer.text_type
78
+ except Exception: # noqa: BLE001 - psd-tools lanza ValueError si no puede determinarlo
79
+ return None
80
+ name = getattr(kind, "name", None)
81
+ return {"POINT": "point", "PARAGRAPH": "area"}.get(name)
82
+
83
+
84
+ def runs(layer, transform_scale: float, theme_families=None, to_hex=None) -> dict:
85
+ to_hex = to_hex or _DEFAULT_HEX
86
+ engine = layer.engine_dict
87
+ resource = layer.resource_dict
88
+ text = layer.text or ""
89
+ fontset = _seq(_get(resource, "FontSet"))
90
+ default = _default_sheet(resource)
91
+
92
+ style_run = _get(engine, "StyleRun")
93
+ lengths = [int(_val(x, 0)) for x in _seq(_get(style_run, "RunLengthArray"))]
94
+ arr = _seq(_get(style_run, "RunArray"))
95
+ if not lengths:
96
+ lengths = [len(text)]
97
+
98
+ out, pos = [], 0
99
+ for i, n in enumerate(lengths):
100
+ if pos >= len(text) and out:
101
+ break
102
+ chunk = text[pos:pos + n]
103
+ pos += n
104
+ data = _get(_get(arr[i], "StyleSheet"), "StyleSheetData") if i < len(arr) else None
105
+
106
+ try:
107
+ ps_name = str(_val(_get(fontset[int(_val(_style_get(data, default, "Font")))], "Name"), ""))
108
+ except (TypeError, ValueError, IndexError):
109
+ ps_name = ""
110
+ f = _fonts.resolve(ps_name, theme_families or set())
111
+ size = float(_val(_style_get(data, default, "FontSize"), 12.0))
112
+ if _val(_style_get(data, default, "FauxBold"), False) and f["weight"] < 700:
113
+ f["weight"] = 700
114
+ if _val(_style_get(data, default, "FauxItalic"), False):
115
+ f["italic"] = True
116
+ auto = bool(_val(_style_get(data, default, "AutoLeading"), True))
117
+ leading = _val(_style_get(data, default, "Leading"))
118
+ if auto or leading is None:
119
+ line_h, derived = size * 1.2, True
120
+ else:
121
+ line_h, derived = float(leading), False
122
+
123
+ out.append({
124
+ "text": chunk,
125
+ "font_ps_name": ps_name,
126
+ "family": f["family"],
127
+ "weight": f["weight"],
128
+ "italic": f["italic"],
129
+ "font_available": f["available"],
130
+ "size_px": size * transform_scale,
131
+ "color": _color(_style_get(data, default, "FillColor"), to_hex),
132
+ "tracking_em": float(_val(_style_get(data, default, "Tracking"), 0)) / 1000.0,
133
+ "line_height_px": line_h * transform_scale,
134
+ "line_height_derived": derived,
135
+ "caps": _CAPS.get(int(_val(_style_get(data, default, "FontCaps"), 0) or 0), "none"),
136
+ "h_scale": float(_val(_style_get(data, default, "HorizontalScale"), 1.0) or 1.0),
137
+ })
138
+ return {"align": _align(engine, resource), "shape": _shape(layer), "runs": out}
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: studio-psd-extract
3
+ Version: 0.2.0
4
+ Summary: Extrae mesas, capas, texto e imágenes de un PSD al contrato JSON de Omnitok Studio.
5
+ License-Expression: LicenseRef-Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: psd-tools<1.26,>=1.25
10
+ Requires-Dist: Pillow>=11
11
+ Requires-Dist: fonttools>=4.50
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=8.0; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # studio-psd-extract
17
+
18
+ Extrae de un PSD las mesas, el árbol de capas, los runs de texto y los fondos rasterizados
19
+ (sin capas de texto) al contrato JSON v1 de Omnitok Studio. Es el mismo extractor que usa la
20
+ tool `studio_extract_psd` del MCP de Studio.
21
+
22
+ ## Desarrollo
23
+
24
+ Desde la raíz del repo `omnitok-studio-mcp`:
25
+
26
+ uv pip install --python .venv/bin/python -e packages/studio-psd-extract --no-deps
27
+ PYTHONPATH=. .venv/bin/python -m pytest -q packages/studio-psd-extract/tests
28
+
29
+ Fixtures: `python tests/fixtures/make_fixtures.py` (dentro del paquete) regenera los PSD de prueba.
30
+
31
+ ## Uso
32
+
33
+ uvx studio-psd-extract <archivo.psd> --out-dir <carpeta> [--brand-fonts "Fam A,Fam B"] [--font-dir <carpeta> ...] [--no-metrics]
34
+ uvx studio-psd-extract <carpeta>/extraction.json --artboard A3
35
+
36
+ Escribe `<carpeta>/extraction.json` (contrato completo) y `<carpeta>/assets/psd-<hash12>.png`, e
37
+ imprime en stdout un resumen compacto (mesas, fuentes, warnings, `assets_dir` y assets). Con
38
+ `--artboard` imprime el outline compacto de esa mesa (ids, tipos, bbox, texto). Exit 0 = ok; 2 = error
39
+ (`{"error": {"code", "message"}}`). Límites locales: 1 GB, 300 MP; PSB y Lab no soportados.
40
+
41
+ Métrica de fuente sustituta: en capas de una línea (runs de la misma fuente y tamaño se unen) compara el ancho de tinta del PSD
42
+ con el de la fuente declarada instalada en este equipo (carpetas del sistema, Adobe Fonts y
43
+ `--font-dir`). `metrics.status`: `match` (±4 %, o ±2 px en textos chicos), `suspect` (Photoshop probablemente dibujó otra
44
+ fuente; `tracking_needed_em` es el tracking que haría calzar), `font_not_installed` o `skipped` (con `reason`: `multiline`, `area_text`, `multi_run`, `transform_complex`, `empty`, `glyph_missing`, `small_caps`, `metrics_error`).
45
+
46
+ ## Publicación
47
+
48
+ Solo con un tag `psd-v<versión>` que coincida con `version` del `pyproject.toml`; el workflow
49
+ `publish-psd-extract` corre los tests, construye y publica en PyPI con trusted publishing.
50
+
51
+ ## Licencia
52
+
53
+ Propietaria: todos los derechos reservados (ver `LICENSE`).
@@ -0,0 +1,21 @@
1
+ studio_psd_extract/__init__.py,sha256=8K6Hj4PhWaiVcZ8QdpFmlGwm4l1ELzKSeYIPwwtu75M,6064
2
+ studio_psd_extract/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ studio_psd_extract/artboards.py,sha256=397BBTRXBayoLeQWO0IRlDOEkMHP3y_WSIYGwGBfFqY,799
4
+ studio_psd_extract/builtin_fonts.py,sha256=QAefAtCgKzJGjvtDLN6OKZB7XBqfleoPMoSTfly0JEM,366
5
+ studio_psd_extract/cli.py,sha256=-JjOzUmIIRvx9figlQVBSVouTxrlYKJwZjKa-Q1Y4u8,4082
6
+ studio_psd_extract/color.py,sha256=NjiLArV3Tn0UVYDpGdJJUUdnRmX4_o0Fu7LLVhOyeV4,2772
7
+ studio_psd_extract/compact.py,sha256=yvPbDKbR3nb9Zv8A3jsQGqXrdR0O5DKsR7Kmva1INZ0,5073
8
+ studio_psd_extract/fonts.py,sha256=B9432V1VWBuLjesgt0SsnUXDXFJylKvjghOGL0aryr4,1756
9
+ studio_psd_extract/guard.py,sha256=81dzYGVdnrgnaFhxbg-kLW5gS09_1kQw-xxT3AsuvXI,1295
10
+ studio_psd_extract/layers.py,sha256=OzN7exSqxDTxCx8EMnzqMgTpsb2oRZEQTVttLBiz-zk,2361
11
+ studio_psd_extract/limits.py,sha256=EtdUAewu5GYbC2OKuAQT6fU1XgU2gUMBwvx8JBhcXjQ,1045
12
+ studio_psd_extract/metrics.py,sha256=3Od246ZGC05W4hXembn7aMOqOLnnwCyIGY4LWG8wv8c,10261
13
+ studio_psd_extract/model.py,sha256=mIk6gk_Q5mXrtdptSRddPsCuYK3nXqSVLstu_FormTE,1208
14
+ studio_psd_extract/raster.py,sha256=1jDAn_mYKRp2KBBHF9zbqHyCg5SZ3-f-pYvUGvsunoU,4827
15
+ studio_psd_extract/text.py,sha256=WXwOih_BW6cQpzSI6LM_znTlcjBngZ9bQ2yfyMSBUls,4737
16
+ studio_psd_extract-0.2.0.dist-info/licenses/LICENSE,sha256=xHqT7gAMSOBQIRkqlvinPa34PrZB8KrA_TL6PmG_9mI,358
17
+ studio_psd_extract-0.2.0.dist-info/METADATA,sha256=jBghoh5-6ntuTG1PQoWW-xPD2BdQtnoUUcbJAypzOlI,2503
18
+ studio_psd_extract-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ studio_psd_extract-0.2.0.dist-info/entry_points.txt,sha256=qNzcCYXQI2MtCiUacBhfG-g726ZFvzKMKcn6HAEDuyQ,67
20
+ studio_psd_extract-0.2.0.dist-info/top_level.txt,sha256=I-xrNPxdUL39I4Z78b4kem31LSE0s6gO9UYvy_0u1cQ,19
21
+ studio_psd_extract-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ studio-psd-extract = studio_psd_extract.cli:main
@@ -0,0 +1,6 @@
1
+ Copyright (c) 2026 Omnitok. Todos los derechos reservados.
2
+
3
+ Este software es propietario. Se publica en PyPI solo para que el equipo y las
4
+ personas autorizadas por Omnitok puedan instalarlo y ejecutarlo junto con Omnitok
5
+ Studio. No se otorga permiso para copiarlo, modificarlo, redistribuirlo ni usarlo
6
+ fuera de ese fin sin autorización escrita de Omnitok.
@@ -0,0 +1 @@
1
+ studio_psd_extract