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.
- studio_psd_extract/__init__.py +152 -0
- studio_psd_extract/__main__.py +3 -0
- studio_psd_extract/artboards.py +22 -0
- studio_psd_extract/builtin_fonts.py +15 -0
- studio_psd_extract/cli.py +94 -0
- studio_psd_extract/color.py +65 -0
- studio_psd_extract/compact.py +111 -0
- studio_psd_extract/fonts.py +53 -0
- studio_psd_extract/guard.py +25 -0
- studio_psd_extract/layers.py +73 -0
- studio_psd_extract/limits.py +36 -0
- studio_psd_extract/metrics.py +234 -0
- studio_psd_extract/model.py +41 -0
- studio_psd_extract/raster.py +143 -0
- studio_psd_extract/text.py +138 -0
- studio_psd_extract-0.2.0.dist-info/METADATA +53 -0
- studio_psd_extract-0.2.0.dist-info/RECORD +21 -0
- studio_psd_extract-0.2.0.dist-info/WHEEL +5 -0
- studio_psd_extract-0.2.0.dist-info/entry_points.txt +2 -0
- studio_psd_extract-0.2.0.dist-info/licenses/LICENSE +6 -0
- studio_psd_extract-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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}
|
|
@@ -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,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
|