studio-psd-extract 0.2.0__tar.gz

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.
Files changed (36) hide show
  1. studio_psd_extract-0.2.0/LICENSE +6 -0
  2. studio_psd_extract-0.2.0/PKG-INFO +53 -0
  3. studio_psd_extract-0.2.0/README.md +38 -0
  4. studio_psd_extract-0.2.0/pyproject.toml +22 -0
  5. studio_psd_extract-0.2.0/setup.cfg +4 -0
  6. studio_psd_extract-0.2.0/src/studio_psd_extract/__init__.py +152 -0
  7. studio_psd_extract-0.2.0/src/studio_psd_extract/__main__.py +3 -0
  8. studio_psd_extract-0.2.0/src/studio_psd_extract/artboards.py +22 -0
  9. studio_psd_extract-0.2.0/src/studio_psd_extract/builtin_fonts.py +15 -0
  10. studio_psd_extract-0.2.0/src/studio_psd_extract/cli.py +94 -0
  11. studio_psd_extract-0.2.0/src/studio_psd_extract/color.py +65 -0
  12. studio_psd_extract-0.2.0/src/studio_psd_extract/compact.py +111 -0
  13. studio_psd_extract-0.2.0/src/studio_psd_extract/fonts.py +53 -0
  14. studio_psd_extract-0.2.0/src/studio_psd_extract/guard.py +25 -0
  15. studio_psd_extract-0.2.0/src/studio_psd_extract/layers.py +73 -0
  16. studio_psd_extract-0.2.0/src/studio_psd_extract/limits.py +36 -0
  17. studio_psd_extract-0.2.0/src/studio_psd_extract/metrics.py +234 -0
  18. studio_psd_extract-0.2.0/src/studio_psd_extract/model.py +41 -0
  19. studio_psd_extract-0.2.0/src/studio_psd_extract/raster.py +143 -0
  20. studio_psd_extract-0.2.0/src/studio_psd_extract/text.py +138 -0
  21. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/PKG-INFO +53 -0
  22. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/SOURCES.txt +34 -0
  23. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/dependency_links.txt +1 -0
  24. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/entry_points.txt +2 -0
  25. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/requires.txt +6 -0
  26. studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/top_level.txt +1 -0
  27. studio_psd_extract-0.2.0/tests/test_cli.py +149 -0
  28. studio_psd_extract-0.2.0/tests/test_cmyk.py +72 -0
  29. studio_psd_extract-0.2.0/tests/test_import_guard.py +22 -0
  30. studio_psd_extract-0.2.0/tests/test_metrics.py +192 -0
  31. studio_psd_extract-0.2.0/tests/test_profiles.py +38 -0
  32. studio_psd_extract-0.2.0/tests/test_psd_extract.py +182 -0
  33. studio_psd_extract-0.2.0/tests/test_psd_guard.py +32 -0
  34. studio_psd_extract-0.2.0/tests/test_psd_layers.py +130 -0
  35. studio_psd_extract-0.2.0/tests/test_psd_raster.py +108 -0
  36. studio_psd_extract-0.2.0/tests/test_psd_text.py +123 -0
@@ -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,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,38 @@
1
+ # studio-psd-extract
2
+
3
+ Extrae de un PSD las mesas, el árbol de capas, los runs de texto y los fondos rasterizados
4
+ (sin capas de texto) al contrato JSON v1 de Omnitok Studio. Es el mismo extractor que usa la
5
+ tool `studio_extract_psd` del MCP de Studio.
6
+
7
+ ## Desarrollo
8
+
9
+ Desde la raíz del repo `omnitok-studio-mcp`:
10
+
11
+ uv pip install --python .venv/bin/python -e packages/studio-psd-extract --no-deps
12
+ PYTHONPATH=. .venv/bin/python -m pytest -q packages/studio-psd-extract/tests
13
+
14
+ Fixtures: `python tests/fixtures/make_fixtures.py` (dentro del paquete) regenera los PSD de prueba.
15
+
16
+ ## Uso
17
+
18
+ uvx studio-psd-extract <archivo.psd> --out-dir <carpeta> [--brand-fonts "Fam A,Fam B"] [--font-dir <carpeta> ...] [--no-metrics]
19
+ uvx studio-psd-extract <carpeta>/extraction.json --artboard A3
20
+
21
+ Escribe `<carpeta>/extraction.json` (contrato completo) y `<carpeta>/assets/psd-<hash12>.png`, e
22
+ imprime en stdout un resumen compacto (mesas, fuentes, warnings, `assets_dir` y assets). Con
23
+ `--artboard` imprime el outline compacto de esa mesa (ids, tipos, bbox, texto). Exit 0 = ok; 2 = error
24
+ (`{"error": {"code", "message"}}`). Límites locales: 1 GB, 300 MP; PSB y Lab no soportados.
25
+
26
+ 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
27
+ con el de la fuente declarada instalada en este equipo (carpetas del sistema, Adobe Fonts y
28
+ `--font-dir`). `metrics.status`: `match` (±4 %, o ±2 px en textos chicos), `suspect` (Photoshop probablemente dibujó otra
29
+ 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`).
30
+
31
+ ## Publicación
32
+
33
+ Solo con un tag `psd-v<versión>` que coincida con `version` del `pyproject.toml`; el workflow
34
+ `publish-psd-extract` corre los tests, construye y publica en PyPI con trusted publishing.
35
+
36
+ ## Licencia
37
+
38
+ Propietaria: todos los derechos reservados (ver `LICENSE`).
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "studio-psd-extract"
7
+ version = "0.2.0"
8
+ description = "Extrae mesas, capas, texto e imágenes de un PSD al contrato JSON de Omnitok Studio."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "LicenseRef-Proprietary"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["psd-tools>=1.25,<1.26", "Pillow>=11", "fonttools>=4.50"]
14
+
15
+ [project.scripts]
16
+ studio-psd-extract = "studio_psd_extract.cli:main"
17
+
18
+ [project.optional-dependencies]
19
+ test = ["pytest>=8.0"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}