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.
- studio_psd_extract-0.2.0/LICENSE +6 -0
- studio_psd_extract-0.2.0/PKG-INFO +53 -0
- studio_psd_extract-0.2.0/README.md +38 -0
- studio_psd_extract-0.2.0/pyproject.toml +22 -0
- studio_psd_extract-0.2.0/setup.cfg +4 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/__init__.py +152 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/__main__.py +3 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/artboards.py +22 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/builtin_fonts.py +15 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/cli.py +94 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/color.py +65 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/compact.py +111 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/fonts.py +53 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/guard.py +25 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/layers.py +73 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/limits.py +36 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/metrics.py +234 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/model.py +41 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/raster.py +143 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract/text.py +138 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/PKG-INFO +53 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/SOURCES.txt +34 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/dependency_links.txt +1 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/entry_points.txt +2 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/requires.txt +6 -0
- studio_psd_extract-0.2.0/src/studio_psd_extract.egg-info/top_level.txt +1 -0
- studio_psd_extract-0.2.0/tests/test_cli.py +149 -0
- studio_psd_extract-0.2.0/tests/test_cmyk.py +72 -0
- studio_psd_extract-0.2.0/tests/test_import_guard.py +22 -0
- studio_psd_extract-0.2.0/tests/test_metrics.py +192 -0
- studio_psd_extract-0.2.0/tests/test_profiles.py +38 -0
- studio_psd_extract-0.2.0/tests/test_psd_extract.py +182 -0
- studio_psd_extract-0.2.0/tests/test_psd_guard.py +32 -0
- studio_psd_extract-0.2.0/tests/test_psd_layers.py +130 -0
- studio_psd_extract-0.2.0/tests/test_psd_raster.py +108 -0
- 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,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,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}
|