@mmerterden/multi-agent-pipeline 20.5.0 → 20.7.0
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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +10 -0
- package/docs/facts.json +5 -4
- package/install/_dev-only-files.mjs +1 -0
- package/manifest.json +84 -82
- package/package.json +4 -3
- package/pipeline/lib/confusables.json +100 -0
- package/pipeline/lib/normalize-text.mjs +48 -0
- package/pipeline/lib/outbound-gate.mjs +10 -1
- package/pipeline/lib/redact.mjs +4 -1
- package/pipeline/lib/vercel-deploy.sh +5 -7
- package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
- package/pipeline/scripts/_notices.mjs +11 -0
- package/pipeline/scripts/agent-guard.py +30 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/scripts/pre-commit-check.sh +59 -16
- package/pipeline/scripts/pre-push-check.sh +3 -3
- package/pipeline/scripts/run-ui-tests.sh +5 -5
- package/pipeline/scripts/website-deploy-commit.sh +3 -2
- package/pipeline/skills/.skill-manifest.json +39 -39
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/council/SKILL.md +1 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
- package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
- package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
- package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
- package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
- package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
- package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
- package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
- package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
- package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
|
@@ -1,528 +1,523 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Two input modes (mirrors fetch-annotations.py's REST/MCP split):
|
|
12
|
-
* REST (default) - needs a Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
|
|
13
|
-
geometry `GET /v1/files/<key>/nodes?ids=<id>` → absoluteBoundingBox
|
|
14
|
-
image `GET /v1/images/<key>?ids=<id>&format=png&scale=<n>` → PNG url → data URL
|
|
15
|
-
* --spec <file> (MCP / no network): { fileKey, pages:[{nodeId,name,image:<dataUrl>,
|
|
16
|
-
frame:{w,h}, nodes:[{characters,tr,en,x,y,w,h}] }] } - the orchestrator builds
|
|
17
|
-
this from get_screenshot + use_figma so no REST calls happen.
|
|
18
|
-
|
|
19
|
-
Cards are keyed off the run's **mapping JSON** (`--mapping`): each text node is matched to
|
|
20
|
-
a mapping row (by `cmsNodeId`, else by normalized value) to label the card with its
|
|
21
|
-
`newKey`. A node carrying a Final UX Writing annotation → pink "İçerik (CMS)" card with
|
|
22
|
-
TR/EN; a keyable node without one → gray "Öneri bekleniyor" card with the on-screen text.
|
|
23
|
-
Non-keyable text (no key, no annotation) is dropped.
|
|
24
|
-
|
|
25
|
-
**No annotations needed.** When value-matching is unreliable (design wording ≠ resolved
|
|
26
|
-
values, or the frame mixes in background content), anchor explicitly: set each row's
|
|
27
|
-
`cmsNodeId` to a synthetic id and feed a `--spec` whose nodes carry the same `id` + the
|
|
28
|
-
box read off the clean screenshot - `cmsNodeId` matching is exact, so the overlay is
|
|
29
|
-
faithful with zero annotations. (`design-export/.../tree.json` is component-instance level
|
|
30
|
-
- use its root `nodeId` for the REST frame, not for per-label boxes.)
|
|
31
|
-
|
|
32
|
-
Output: `<out>/<slug>.overlay.png` (or `<slug>.overlay.html` fallback) + a sibling
|
|
33
|
-
`<slug>.overlay.manifest.json` (`page → file → {fileKey,nodeId}`) so the publish step
|
|
34
|
-
can find the image to attach. Read-only against every source.
|
|
2
|
+
"""Draw the overlay that ties each localization key to its place on one screen.
|
|
3
|
+
|
|
4
|
+
The screen image sits in the middle; every keyed element gets a numbered card
|
|
5
|
+
at the side and a dashed line leading to it. A card's number equals the index,
|
|
6
|
+
counted from 1, of its matching entry in mapping.rows. Boxes are read from
|
|
7
|
+
Figma over REST, or taken from a --spec file, and headless Chrome turns the
|
|
8
|
+
HTML page into a PNG. The same input always gives the same picture; nothing is
|
|
9
|
+
generated by a model.
|
|
35
10
|
"""
|
|
36
11
|
import argparse
|
|
37
12
|
import base64
|
|
13
|
+
import html
|
|
38
14
|
import json
|
|
39
15
|
import os
|
|
40
16
|
import re
|
|
17
|
+
import shutil
|
|
41
18
|
import ssl
|
|
42
19
|
import subprocess
|
|
43
20
|
import sys
|
|
44
21
|
import tempfile
|
|
22
|
+
import time
|
|
45
23
|
import unicodedata
|
|
46
24
|
import urllib.error
|
|
47
25
|
import urllib.parse
|
|
48
26
|
import urllib.request
|
|
49
27
|
|
|
50
|
-
|
|
51
|
-
|
|
28
|
+
API = "https://api.figma.com/v1"
|
|
29
|
+
SEP = "[:\\-\u2013]"
|
|
30
|
+
TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
31
|
+
EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
52
32
|
|
|
53
|
-
# --- card layout constants (ported from web render-annotations.mjs) --------
|
|
54
33
|
CARD_W = 320
|
|
55
|
-
|
|
34
|
+
COLUMN_X = 24
|
|
56
35
|
CHANNEL = 190
|
|
57
|
-
DESIGN_X = COL_X + CARD_W + CHANNEL
|
|
58
|
-
RIGHT_MARGIN = 40
|
|
59
36
|
TOP = 60
|
|
60
37
|
CARD_H = 116
|
|
61
|
-
|
|
38
|
+
GAP = 14
|
|
39
|
+
RIGHT_MARGIN = 40
|
|
62
40
|
PINK = "#EC1E91"
|
|
63
41
|
GRAY = "#9aa0a6"
|
|
42
|
+
BACKGROUND = "#1e1e1e"
|
|
43
|
+
LABEL_CMS = "\u0130\u00e7erik (CMS)"
|
|
44
|
+
LABEL_PENDING = "\u00d6neri bekleniyor"
|
|
45
|
+
LABEL_UNMATCHED = "(e\u015fle\u015fmedi)"
|
|
64
46
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def slug(s):
|
|
118
|
-
s = (s or "screen").strip()
|
|
119
|
-
s = re.sub(r'[/\\:*?"<>|]+', "-", s)
|
|
120
|
-
s = re.sub(r"\s+", "-", s)
|
|
121
|
-
s = re.sub(r"-{2,}", "-", s)
|
|
122
|
-
return s.strip("-").lower() or "screen"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def esc(s):
|
|
126
|
-
return (str(s) if s is not None else "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# --- Figma token + HTTP (self-contained, mirrors fetch-annotations.py) ------
|
|
130
|
-
def figma_token(args):
|
|
131
|
-
if args.token:
|
|
132
|
-
return args.token
|
|
133
|
-
for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
|
|
134
|
-
v = os.environ.get(env)
|
|
135
|
-
if v:
|
|
136
|
-
return v.strip()
|
|
137
|
-
try:
|
|
138
|
-
r = subprocess.run(
|
|
139
|
-
["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
|
|
140
|
-
capture_output=True, text=True)
|
|
141
|
-
if r.returncode == 0 and r.stdout.strip():
|
|
142
|
-
return r.stdout.strip()
|
|
143
|
-
except Exception:
|
|
144
|
-
pass
|
|
145
|
-
return None
|
|
47
|
+
PAGE_OPEN = "<!doctype html><html><head>"
|
|
48
|
+
META_UTF8 = '<meta charset="utf-8">'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mac_app(bundle):
|
|
52
|
+
return f"/Applications/{bundle}.app/Contents/MacOS/{bundle}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
MAC_CHROMES = [mac_app(name) for name in ("Google Chrome", "Chromium", "Microsoft Edge")]
|
|
56
|
+
PATH_CHROMES = (
|
|
57
|
+
[f"google-chrome{tail}" for tail in ("", "-stable")]
|
|
58
|
+
+ [f"chromium{tail}" for tail in ("", "-browser")]
|
|
59
|
+
+ ["chrome"]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canon_id(node_id):
|
|
64
|
+
return str(node_id or "").strip().replace("-", ":")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def slug(text):
|
|
68
|
+
value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
|
|
69
|
+
return value or "screen"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_lang(text):
|
|
73
|
+
picked = {"tr": None, "en": None}
|
|
74
|
+
loose = []
|
|
75
|
+
for line in filter(None, (piece.strip() for piece in str(text or "").splitlines())):
|
|
76
|
+
tr_hit, en_hit = TR_RE.match(line), EN_RE.match(line)
|
|
77
|
+
if tr_hit and picked["tr"] is None:
|
|
78
|
+
picked["tr"] = tr_hit.group(1).strip()
|
|
79
|
+
elif en_hit and picked["en"] is None:
|
|
80
|
+
picked["en"] = en_hit.group(1).strip()
|
|
81
|
+
else:
|
|
82
|
+
loose.append(line)
|
|
83
|
+
if picked["tr"] is None and picked["en"] is None:
|
|
84
|
+
padded = loose + [None, None]
|
|
85
|
+
style = {0: "empty", 1: "single"}.get(len(loose), "two-line")
|
|
86
|
+
return {"tr": padded[0], "en": padded[1], "mode": style}
|
|
87
|
+
spare = iter(loose)
|
|
88
|
+
for lang in ("tr", "en"):
|
|
89
|
+
if picked[lang] is None:
|
|
90
|
+
picked[lang] = next(spare, None)
|
|
91
|
+
return dict(picked, mode="prefixed")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def norm(value):
|
|
95
|
+
text = unicodedata.normalize("NFC", str(value or "")).strip()
|
|
96
|
+
text = re.sub(r"\s+", " ", text)
|
|
97
|
+
text = re.sub(r"[.:\u2026]+$", "", text).strip()
|
|
98
|
+
return text.lower()
|
|
146
99
|
|
|
147
100
|
|
|
148
|
-
def
|
|
149
|
-
|
|
101
|
+
def build_index(rows):
|
|
102
|
+
by_id, by_value = {}, {}
|
|
103
|
+
for position, entry in enumerate(rows):
|
|
104
|
+
if entry.get("cmsNodeId"):
|
|
105
|
+
by_id.setdefault(canon_id(entry["cmsNodeId"]), position)
|
|
106
|
+
texts = [(entry.get(block) or {}).get(lang) for block in ("new", "cms", "legacy") for lang in ("tr", "en")]
|
|
107
|
+
texts.append(entry.get("element"))
|
|
108
|
+
for text in texts:
|
|
109
|
+
normalised = norm(text)
|
|
110
|
+
if normalised:
|
|
111
|
+
by_value.setdefault(normalised, position)
|
|
112
|
+
return {"id": by_id, "value": by_value}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def match_row(node, index):
|
|
116
|
+
node_id = canon_id(node.get("id"))
|
|
117
|
+
if node_id in index["id"]:
|
|
118
|
+
return index["id"][node_id]
|
|
119
|
+
for field in ("tr", "characters"):
|
|
120
|
+
key = norm(node.get(field))
|
|
121
|
+
if key and key in index["value"]:
|
|
122
|
+
return index["value"][key]
|
|
123
|
+
return None
|
|
150
124
|
|
|
151
125
|
|
|
152
|
-
def
|
|
153
|
-
req = urllib.request.Request(url, headers=headers)
|
|
126
|
+
def keychain_secret(service):
|
|
154
127
|
try:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
128
|
+
lookup = subprocess.run(
|
|
129
|
+
["security", "find-generic-password", "-s", service, "-w"],
|
|
130
|
+
capture_output=True, text=True, timeout=10,
|
|
131
|
+
)
|
|
132
|
+
except (OSError, subprocess.SubprocessError):
|
|
133
|
+
return None
|
|
134
|
+
secret = lookup.stdout.strip()
|
|
135
|
+
return secret if lookup.returncode == 0 and secret else None
|
|
136
|
+
|
|
163
137
|
|
|
138
|
+
def figma_token(explicit):
|
|
139
|
+
if explicit:
|
|
140
|
+
return explicit
|
|
141
|
+
from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
|
|
142
|
+
return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
|
|
164
143
|
|
|
165
|
-
|
|
144
|
+
|
|
145
|
+
def _open(url, headers, ca_file, timeout):
|
|
146
|
+
tls = ssl.create_default_context(cafile=ca_file) if ca_file else None
|
|
166
147
|
try:
|
|
167
|
-
with urllib.request.urlopen(url,
|
|
168
|
-
return
|
|
169
|
-
except
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if not isinstance(anns, list):
|
|
177
|
-
return {"tr": None, "en": None}
|
|
178
|
-
for a in anns:
|
|
179
|
-
if not isinstance(a, dict):
|
|
180
|
-
continue
|
|
181
|
-
lbl = a.get("label") or a.get("labelMarkdown") or ""
|
|
182
|
-
if lbl:
|
|
183
|
-
p = split_lang(lbl)
|
|
184
|
-
if p["tr"] is not None or p["en"] is not None:
|
|
185
|
-
return p
|
|
186
|
-
return {"tr": None, "en": None}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def collect_rest_nodes(file_key, node_id, token, ca_file, scale):
|
|
190
|
-
headers = {"X-Figma-Token": token}
|
|
191
|
-
data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(node_id, safe='')}",
|
|
192
|
-
headers, ca_file)
|
|
193
|
-
entry = (data.get("nodes") or {}).get(node_id) or {}
|
|
194
|
-
doc = entry.get("document")
|
|
195
|
-
if not doc:
|
|
196
|
-
sys.exit(f"node {node_id} not found in file {file_key}")
|
|
197
|
-
fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
|
|
198
|
-
frame = {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))}
|
|
199
|
-
nodes = []
|
|
148
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout, context=tls) as reply:
|
|
149
|
+
return reply.read()
|
|
150
|
+
except urllib.error.HTTPError as exc:
|
|
151
|
+
if exc.code == 429:
|
|
152
|
+
sys.exit("Figma is rate limiting (HTTP 429). Measure the boxes on the screenshot and supply them through --spec.")
|
|
153
|
+
snippet = exc.read()[:200].decode("utf-8", "replace")
|
|
154
|
+
sys.exit(f"Figma HTTP {exc.code} for {url}: {snippet}")
|
|
155
|
+
except urllib.error.URLError as exc:
|
|
156
|
+
sys.exit(f"Could not reach Figma: {exc.reason}")
|
|
200
157
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if isinstance(src, dict):
|
|
244
|
-
for lang in ("tr", "en"):
|
|
245
|
-
v = src.get(lang)
|
|
246
|
-
if v:
|
|
247
|
-
by_val.setdefault(norm(v), r)
|
|
248
|
-
if r.get("element"):
|
|
249
|
-
by_val.setdefault(norm(r["element"]), r)
|
|
250
|
-
return by_node, by_val
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def match_row(node, by_node, by_val):
|
|
254
|
-
nid = canon_id(node.get("id") or "")
|
|
255
|
-
if nid in by_node:
|
|
256
|
-
return by_node[nid]
|
|
257
|
-
for v in (node.get("tr"), node.get("characters")):
|
|
258
|
-
if v and norm(v) in by_val:
|
|
259
|
-
return by_val[norm(v)]
|
|
158
|
+
|
|
159
|
+
def http_json(url, token, ca_file, timeout=60):
|
|
160
|
+
return json.loads(_open(url, {"X-Figma-Token": token}, ca_file, timeout).decode("utf-8"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def http_bytes(url, ca_file, timeout=120):
|
|
164
|
+
return _open(url, {}, ca_file, timeout)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def as_data_uri(png_bytes):
|
|
168
|
+
return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def fetch_node(file_key, node_id, token, ca_file):
|
|
172
|
+
ids = urllib.parse.quote(node_id, safe="")
|
|
173
|
+
reply = http_json(f"{API}/files/{file_key}/nodes?ids={ids}", token, ca_file)
|
|
174
|
+
entry = (reply.get("nodes") or {}).get(node_id) or {}
|
|
175
|
+
return entry.get("document")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_image(file_key, node_id, scale, token, ca_file):
|
|
179
|
+
ids = urllib.parse.quote(node_id, safe="")
|
|
180
|
+
reply = http_json(f"{API}/images/{file_key}?ids={ids}&format=png&scale={scale}", token, ca_file)
|
|
181
|
+
link = (reply.get("images") or {}).get(node_id)
|
|
182
|
+
return as_data_uri(http_bytes(link, ca_file)) if link else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def walk(root):
|
|
186
|
+
stack = [root]
|
|
187
|
+
while stack:
|
|
188
|
+
node = stack.pop()
|
|
189
|
+
if isinstance(node, dict):
|
|
190
|
+
yield node
|
|
191
|
+
stack.extend(reversed(node.get("children") or []))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def annotation_copy(node):
|
|
195
|
+
for note in node.get("annotations") or []:
|
|
196
|
+
text = note.get("label") or note.get("labelMarkdown") if isinstance(note, dict) else note
|
|
197
|
+
parsed = split_lang(text)
|
|
198
|
+
if parsed["tr"] or parsed["en"]:
|
|
199
|
+
return parsed
|
|
260
200
|
return None
|
|
261
201
|
|
|
262
202
|
|
|
203
|
+
def image_source(value, base_dir):
|
|
204
|
+
if not value or str(value).startswith("data:"):
|
|
205
|
+
return value
|
|
206
|
+
path = os.path.join(base_dir, value) if not os.path.isabs(value) else value
|
|
207
|
+
if os.path.isfile(path):
|
|
208
|
+
with open(path, "rb") as stream:
|
|
209
|
+
return as_data_uri(stream.read())
|
|
210
|
+
sys.exit(f"the --spec image does not exist: {path}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def page_from_rest(file_key, node_id, scale, token, ca_file):
|
|
214
|
+
document = fetch_node(file_key, node_id, token, ca_file)
|
|
215
|
+
if not document:
|
|
216
|
+
sys.exit(f"Figma file {file_key} has no node {node_id}")
|
|
217
|
+
box = document.get("absoluteBoundingBox") or {}
|
|
218
|
+
fx, fy = box.get("x", 0), box.get("y", 0)
|
|
219
|
+
nodes = []
|
|
220
|
+
for node in walk(document):
|
|
221
|
+
bbox = node.get("absoluteBoundingBox")
|
|
222
|
+
if not bbox or node is document:
|
|
223
|
+
continue
|
|
224
|
+
copy = annotation_copy(node)
|
|
225
|
+
text = node.get("characters") if node.get("type") == "TEXT" else None
|
|
226
|
+
if not (text and text.strip()) and not copy:
|
|
227
|
+
continue
|
|
228
|
+
nodes.append({
|
|
229
|
+
"id": node.get("id"),
|
|
230
|
+
"characters": text,
|
|
231
|
+
"tr": copy["tr"] if copy else None,
|
|
232
|
+
"en": copy["en"] if copy else None,
|
|
233
|
+
"x": round(bbox["x"] - fx),
|
|
234
|
+
"y": round(bbox["y"] - fy),
|
|
235
|
+
"w": round(bbox["width"]),
|
|
236
|
+
"h": round(bbox["height"]),
|
|
237
|
+
"side": "left",
|
|
238
|
+
})
|
|
239
|
+
image = render_image(file_key, node_id, scale, token, ca_file)
|
|
240
|
+
if not image:
|
|
241
|
+
sys.exit(f"Figma returned no image for {node_id}")
|
|
242
|
+
return {
|
|
243
|
+
"nodeId": node_id,
|
|
244
|
+
"name": document.get("name"),
|
|
245
|
+
"image": image,
|
|
246
|
+
"frame": {"w": round(box.get("width", 0)), "h": round(box.get("height", 0))},
|
|
247
|
+
"nodes": nodes,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
263
251
|
def build_cards(page, rows):
|
|
264
|
-
|
|
265
|
-
# 1-based index of every row in the mapping → the SAME number build-artifact prints
|
|
266
|
-
# in the summary/detail tables (both enumerate rows in order). The overlay card badge
|
|
267
|
-
# uses it so a mark on the screen and its table row always carry the same number.
|
|
268
|
-
row_num = {id(r): i for i, r in enumerate(rows, 1)}
|
|
252
|
+
index = build_index(rows)
|
|
269
253
|
cards = []
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
254
|
+
taken = set()
|
|
255
|
+
extra_number = len(rows)
|
|
256
|
+
for node in page.get("nodes") or []:
|
|
257
|
+
position = match_row(node, index)
|
|
258
|
+
if position is not None and position in taken:
|
|
259
|
+
continue
|
|
260
|
+
entry = rows[position] if position is not None else None
|
|
261
|
+
cms = (entry.get("cms") if entry else {}) or {}
|
|
262
|
+
tr = node.get("tr") or cms.get("tr")
|
|
263
|
+
en = node.get("en") or cms.get("en")
|
|
264
|
+
new_key = entry.get("newKey") if entry else None
|
|
265
|
+
if not (new_key or tr or en):
|
|
266
|
+
continue
|
|
267
|
+
if position is None:
|
|
268
|
+
extra_number += 1
|
|
269
|
+
number = extra_number
|
|
270
|
+
else:
|
|
271
|
+
taken.add(position)
|
|
272
|
+
number = position + 1
|
|
273
|
+
box = {axis: float(node.get(axis, 0)) for axis in ("x", "y", "w", "h")}
|
|
280
274
|
cards.append({
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
"
|
|
284
|
-
"
|
|
285
|
-
"
|
|
286
|
-
"
|
|
287
|
-
"
|
|
275
|
+
"num": number,
|
|
276
|
+
"key": new_key or LABEL_UNMATCHED,
|
|
277
|
+
"tr": tr,
|
|
278
|
+
"en": en,
|
|
279
|
+
"text": node.get("characters") or "",
|
|
280
|
+
"annotated": bool(tr or en),
|
|
281
|
+
"side": "right" if node.get("side") == "right" else "left",
|
|
282
|
+
**box,
|
|
288
283
|
})
|
|
289
|
-
cards.sort(key=lambda c: c["y"])
|
|
290
284
|
return cards
|
|
291
285
|
|
|
292
286
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"""Lay out cards on EITHER side of the frame (per-card `side`: left|right). Cards
|
|
296
|
-
that don't read well on the left (e.g. right-column UI like the amenity grid's
|
|
297
|
-
right entries) are placed on the right so their connector stays short and doesn't
|
|
298
|
-
cross the whole frame. Each column is ordered by target y and greedily stacked;
|
|
299
|
-
each link gets its own lane and a numbered dot matching its card. Returns
|
|
300
|
-
(html, canvas_w, canvas_h)."""
|
|
301
|
-
frame_w = page["frame"]["w"] or 800
|
|
302
|
-
frame_h = page["frame"]["h"] or 600
|
|
303
|
-
left_card_right = COL_X + CARD_W
|
|
304
|
-
design_right = DESIGN_X + frame_w
|
|
305
|
-
right_col_x = design_right + CHANNEL
|
|
306
|
-
|
|
307
|
-
items = []
|
|
308
|
-
for c in cards:
|
|
309
|
-
tx = DESIGN_X + c["x"] + c["w"] / 2
|
|
310
|
-
ty = TOP + c["y"] + c["h"] / 2
|
|
311
|
-
items.append({"tx": tx, "ty": ty, "c": c, "side": c.get("side", "left")})
|
|
312
|
-
|
|
313
|
-
left = sorted([it for it in items if it["side"] != "right"], key=lambda it: it["ty"])
|
|
314
|
-
right = sorted([it for it in items if it["side"] == "right"], key=lambda it: it["ty"])
|
|
315
|
-
has_right = bool(right)
|
|
316
|
-
|
|
317
|
-
def place(col):
|
|
318
|
-
cursor = TOP
|
|
319
|
-
for it in col:
|
|
320
|
-
it["y"] = max(cursor, it["ty"] - CARD_H / 2, TOP)
|
|
321
|
-
cursor = it["y"] + CARD_H + CARD_GAP
|
|
322
|
-
return cursor
|
|
323
|
-
cur_l, cur_r = place(left), place(right)
|
|
324
|
-
|
|
325
|
-
canvas_w = (right_col_x + CARD_W + RIGHT_MARGIN) if has_right else (design_right + RIGHT_MARGIN)
|
|
326
|
-
canvas_h = max(TOP + frame_h + TOP, cur_l + TOP, cur_r + TOP)
|
|
327
|
-
|
|
328
|
-
# numbering: use the matched row's table number so the mark on the screen and its
|
|
329
|
-
# table row agree; fall back to sequential only for a card with no matched row.
|
|
330
|
-
seq = 1
|
|
331
|
-
for it in left + right:
|
|
332
|
-
n = it["c"].get("num")
|
|
333
|
-
if n is None:
|
|
334
|
-
n = seq
|
|
335
|
-
it["num"] = n
|
|
336
|
-
seq += 1
|
|
337
|
-
|
|
338
|
-
nL, nR = len(left), len(right)
|
|
339
|
-
lane_step_l = (CHANNEL - 30) / max(1, nL - 1) if nL > 1 else 0
|
|
340
|
-
lane_step_r = (CHANNEL - 30) / max(1, nR - 1) if nR > 1 else 0
|
|
341
|
-
|
|
342
|
-
connectors, card_html = [], []
|
|
343
|
-
|
|
344
|
-
def emit(it, idx, side):
|
|
345
|
-
c, tx, ty, y, num = it["c"], it["tx"], it["ty"], it["y"], it["num"]
|
|
346
|
-
card_mid = y + CARD_H / 2
|
|
347
|
-
colour = PINK if c["annotated"] else GRAY
|
|
348
|
-
if side == "right":
|
|
349
|
-
lane = design_right + 16 + idx * lane_step_r
|
|
350
|
-
path = f'M {right_col_x} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
|
|
351
|
-
left_px = right_col_x
|
|
352
|
-
else:
|
|
353
|
-
lane = left_card_right + 16 + idx * lane_step_l
|
|
354
|
-
path = f'M {left_card_right} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
|
|
355
|
-
left_px = COL_X
|
|
356
|
-
connectors.append(
|
|
357
|
-
f'<path d="{path}" '
|
|
358
|
-
f'stroke="{colour}" stroke-width="1.4" stroke-dasharray="5 4" fill="none" opacity="0.85"/>'
|
|
359
|
-
f'<circle cx="{tx:.0f}" cy="{ty:.0f}" r="9" fill="{colour}"/>'
|
|
360
|
-
f'<text x="{tx:.0f}" y="{ty + 3.5:.0f}" text-anchor="middle" font-size="11" '
|
|
361
|
-
f'font-weight="700" fill="#fff">{num}</text>')
|
|
362
|
-
badge = f'<span class="num" style="background:{colour}">{num}</span>'
|
|
363
|
-
if c["annotated"]:
|
|
364
|
-
pill = f'<span class="pill" style="background:{PINK}">İçerik (CMS)</span>'
|
|
365
|
-
rows = f'<div class="row"><span class="lang">TR:</span> {esc(c["tr"]) if c["tr"] else "-"}</div>'
|
|
366
|
-
if c["en"] is not None:
|
|
367
|
-
rows += f'<div class="row"><span class="lang">EN:</span> {esc(c["en"])}</div>'
|
|
368
|
-
klass = ""
|
|
369
|
-
else:
|
|
370
|
-
pill = f'<span class="pill" style="background:{GRAY}">Öneri bekleniyor</span>'
|
|
371
|
-
rows = f'<div class="row muted">metin: {esc(c["designText"]) or "-"}</div>'
|
|
372
|
-
klass = " keyonly"
|
|
373
|
-
card_html.append(
|
|
374
|
-
f'<div class="card{klass}" style="left:{left_px:.0f}px;top:{y:.0f}px">'
|
|
375
|
-
f'<div class="hd">{badge}{pill}</div>'
|
|
376
|
-
f'<div class="key">{esc(c["key"])}</div>{rows}</div>')
|
|
377
|
-
|
|
378
|
-
for i, it in enumerate(left):
|
|
379
|
-
emit(it, i, "left")
|
|
380
|
-
for i, it in enumerate(right):
|
|
381
|
-
emit(it, i, "right")
|
|
382
|
-
|
|
383
|
-
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
|
384
|
-
* {{ box-sizing:border-box; }}
|
|
385
|
-
body {{ margin:0; background:#1e1e1e; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; }}
|
|
386
|
-
.canvas {{ position:relative; width:{canvas_w}px; height:{canvas_h:.0f}px; }}
|
|
387
|
-
.design {{ position:absolute; left:{DESIGN_X}px; top:{TOP}px; width:{frame_w}px; height:{frame_h}px;
|
|
388
|
-
box-shadow:0 0 0 1px #333; }}
|
|
389
|
-
svg.links {{ position:absolute; left:0; top:0; width:{canvas_w}px; height:{canvas_h:.0f}px; pointer-events:none; }}
|
|
390
|
-
.card {{ position:absolute; width:{CARD_W}px; min-height:{CARD_H}px; background:#2b2b2b; color:#e8e8e8;
|
|
391
|
-
border-radius:10px; padding:10px 14px; box-shadow:0 1px 4px rgba(0,0,0,.4); }}
|
|
392
|
-
.card.keyonly {{ background:#f4f5f6; color:#202124; }}
|
|
393
|
-
.hd {{ display:flex; align-items:center; gap:7px; margin-bottom:6px; }}
|
|
394
|
-
.num {{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
|
|
395
|
-
border-radius:50%; color:#fff; font-size:11px; font-weight:700; flex:none; }}
|
|
396
|
-
.pill {{ display:inline-block; color:#fff; font-size:11px; font-weight:600; padding:2px 8px;
|
|
397
|
-
border-radius:10px; }}
|
|
398
|
-
.key {{ font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; color:#9ad; margin-bottom:6px;
|
|
399
|
-
word-break:break-all; }}
|
|
400
|
-
.card.keyonly .key {{ color:#1666c8; }}
|
|
401
|
-
.row {{ font-size:13px; line-height:1.35; }}
|
|
402
|
-
.row.muted {{ color:#5f6368; }}
|
|
403
|
-
.lang {{ color:{PINK}; font-weight:600; }}
|
|
404
|
-
.title {{ position:absolute; left:{COL_X}px; top:18px; color:#bbb; font-size:13px; }}
|
|
405
|
-
</style></head><body><div class="canvas">
|
|
406
|
-
<div class="title">{esc(title)}</div>
|
|
407
|
-
<img class="design" src="{page['image']}">
|
|
408
|
-
<svg class="links">{''.join(connectors)}</svg>
|
|
409
|
-
{''.join(card_html)}
|
|
410
|
-
</div></body></html>"""
|
|
411
|
-
return html, canvas_w, canvas_h
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
# --- rasterize -------------------------------------------------------------
|
|
415
|
-
def find_chrome():
|
|
416
|
-
for p in (
|
|
417
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
418
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
419
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
420
|
-
):
|
|
421
|
-
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
422
|
-
return p
|
|
423
|
-
from shutil import which
|
|
424
|
-
for c in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
|
|
425
|
-
w = which(c)
|
|
426
|
-
if w:
|
|
427
|
-
return w
|
|
428
|
-
return None
|
|
287
|
+
def centre_y(card):
|
|
288
|
+
return card["y"] + card["h"] / 2
|
|
429
289
|
|
|
430
290
|
|
|
431
|
-
def
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return
|
|
291
|
+
def stack_side(cards):
|
|
292
|
+
cards.sort(key=centre_y)
|
|
293
|
+
bottom = TOP - GAP
|
|
294
|
+
for card in cards:
|
|
295
|
+
target = TOP + card["y"] + card["h"] / 2 - CARD_H / 2
|
|
296
|
+
card["cardY"] = max(target, bottom + GAP, TOP)
|
|
297
|
+
bottom = card["cardY"] + CARD_H
|
|
298
|
+
return bottom
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def chip(kind, colour, content):
|
|
302
|
+
return f'<span class="{kind}" style="background:{colour}">{content}</span>'
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def card_html(card, left):
|
|
306
|
+
colour = PINK if card["annotated"] else GRAY
|
|
307
|
+
pill = LABEL_CMS if card["annotated"] else LABEL_PENDING
|
|
308
|
+
if card["annotated"]:
|
|
309
|
+
body = "".join(
|
|
310
|
+
f'<div class="line"><b>{label}</b> {html.escape(card[lang] or "-")}</div>'
|
|
311
|
+
for label, lang in (("TR", "tr"), ("EN", "en"))
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
body = f'<div class="line muted">{html.escape(card["text"] or "-")}</div>'
|
|
315
|
+
head = chip("num", colour, card["num"]) + chip("pill", colour, html.escape(pill))
|
|
316
|
+
return (
|
|
317
|
+
f'<div class="card" style="left:{left}px;top:{card["cardY"]:.0f}px;border-color:{colour}">'
|
|
318
|
+
f'<div class="head">{head}</div>'
|
|
319
|
+
f'<div class="key">{html.escape(card["key"])}</div>{body}</div>'
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def connector(card, lane, lane_count, start_x, target_x, from_left):
|
|
324
|
+
colour = PINK if card["annotated"] else GRAY
|
|
325
|
+
step = (CHANNEL - 40) / max(lane_count, 1)
|
|
326
|
+
bend = start_x + 20 + step * lane if from_left else start_x - 20 - step * lane
|
|
327
|
+
card_mid = card["cardY"] + CARD_H / 2
|
|
328
|
+
dot_y = TOP + centre_y(card)
|
|
329
|
+
x, y = f"{target_x:.0f}", f"{dot_y:.0f}"
|
|
330
|
+
label_y = f"{dot_y + 4:.0f}"
|
|
331
|
+
route = f"{start_x:.0f},{card_mid:.0f} {bend:.0f},{card_mid:.0f} {bend:.0f},{y} {x},{y}"
|
|
332
|
+
return (
|
|
333
|
+
f'<polyline points="{route}" fill="none" stroke="{colour}" stroke-width="2" stroke-dasharray="6 4"/>'
|
|
334
|
+
f'<circle cx="{x}" cy="{y}" r="11" fill="{colour}"/>'
|
|
335
|
+
f'<text x="{x}" y="{label_y}" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{card["num"]}</text>'
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_html(page, cards):
|
|
340
|
+
fw, fh = page["frame"]["w"], page["frame"]["h"]
|
|
341
|
+
sides = {"left": [], "right": []}
|
|
342
|
+
for card in cards:
|
|
343
|
+
sides[card["side"]].append(card)
|
|
344
|
+
screen_x = COLUMN_X + CARD_W + CHANNEL
|
|
345
|
+
right_x = screen_x + fw + CHANNEL
|
|
346
|
+
width = right_x + CARD_W + RIGHT_MARGIN if sides["right"] else screen_x + fw + RIGHT_MARGIN
|
|
347
|
+
height = int(max(TOP + fh, stack_side(sides["left"]), stack_side(sides["right"])) + TOP)
|
|
348
|
+
|
|
349
|
+
boxes, wires = [], []
|
|
350
|
+
for card_x, group in ((COLUMN_X, sides["left"]), (right_x, sides["right"])):
|
|
351
|
+
from_left = card_x == COLUMN_X
|
|
352
|
+
for lane, card in enumerate(group):
|
|
353
|
+
boxes.append(card_html(card, card_x))
|
|
354
|
+
if from_left:
|
|
355
|
+
wire = connector(card, lane, len(group), COLUMN_X + CARD_W, screen_x + card["x"], True)
|
|
356
|
+
else:
|
|
357
|
+
wire = connector(card, lane, len(group), right_x, screen_x + card["x"] + card["w"], False)
|
|
358
|
+
wires.append(wire)
|
|
359
|
+
|
|
360
|
+
mid = "center"
|
|
361
|
+
pin = "left:0;top:0;"
|
|
362
|
+
style = (
|
|
363
|
+
f"html,body{{margin:0;padding:0;background:{BACKGROUND};}}"
|
|
364
|
+
f"body{{width:{width}px;height:{height}px;position:relative;font-family:-apple-system,Helvetica,Arial,sans-serif;}}"
|
|
365
|
+
f".shot{{position:absolute;left:{screen_x}px;top:{TOP}px;width:{fw}px;height:{fh}px;}}"
|
|
366
|
+
f".card{{position:absolute;box-sizing:border-box;width:{CARD_W}px;height:{CARD_H}px;background:#fff;"
|
|
367
|
+
"border:2px solid;border-radius:10px;padding:8px 10px;overflow:hidden;color:#1f1f1f;}}"
|
|
368
|
+
".head{display:flex;gap:6px;align-items:center;margin-bottom:4px;}"
|
|
369
|
+
".num{color:#fff;border-radius:10px;min-width:20px;height:20px;font-size:12px;font-weight:700;"
|
|
370
|
+
f"display:inline-flex;align-items:{mid};justify-content:{mid};padding:0 4px;}}"
|
|
371
|
+
".pill{color:#fff;border-radius:9px;font-size:11px;padding:2px 8px;}"
|
|
372
|
+
".key{font-family:Menlo,monospace;font-size:11px;color:#444;margin-bottom:4px;white-space:nowrap;"
|
|
373
|
+
"overflow:hidden;text-overflow:ellipsis;}"
|
|
374
|
+
".line{font-size:12px;line-height:16px;max-height:32px;overflow:hidden;}"
|
|
375
|
+
".muted{color:#666;font-style:italic;}"
|
|
376
|
+
f"svg{{position:absolute;{pin}}}"
|
|
377
|
+
)
|
|
378
|
+
document = (
|
|
379
|
+
f"{PAGE_OPEN}{META_UTF8}<style>{style}</style></head><body>"
|
|
380
|
+
f'<img class="shot" src="{page["image"]}">'
|
|
381
|
+
f'<svg width="{width}" height="{height}">{"".join(wires)}</svg>'
|
|
382
|
+
f'{"".join(boxes)}</body></html>'
|
|
383
|
+
)
|
|
384
|
+
return document, width, height
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def find_chrome():
|
|
388
|
+
for path in [os.environ.get("CHROME_BIN")] + MAC_CHROMES:
|
|
389
|
+
if path and os.path.isfile(path) and os.access(path, os.X_OK):
|
|
390
|
+
return path
|
|
391
|
+
return next(filter(None, map(shutil.which, PATH_CHROMES)), None)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def wait_until_stable(proc, path, limit, pause):
|
|
395
|
+
deadline = time.time() + limit
|
|
396
|
+
previous = -1
|
|
397
|
+
while time.time() < deadline and proc.poll() is None:
|
|
398
|
+
current = os.path.getsize(path) if os.path.exists(path) else -1
|
|
399
|
+
if current > 0 and current == previous:
|
|
400
|
+
break
|
|
401
|
+
previous = current
|
|
402
|
+
time.sleep(pause)
|
|
403
|
+
if proc.poll() is None:
|
|
404
|
+
proc.kill()
|
|
405
|
+
proc.wait()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def chrome_screenshot(chrome, html_text, out_png, width, height, scale):
|
|
409
|
+
target = os.path.abspath(out_png)
|
|
410
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
411
|
+
page = os.path.join(scratch, "page.html")
|
|
412
|
+
with open(page, "w", encoding="utf-8") as sink:
|
|
413
|
+
sink.write(html_text)
|
|
414
|
+
if os.path.exists(target):
|
|
415
|
+
os.remove(target)
|
|
416
|
+
log_path = os.path.join(scratch, "chrome.log")
|
|
417
|
+
window = f"{int(width)},{int(height)}"
|
|
418
|
+
argv = [
|
|
419
|
+
chrome, "--hide-scrollbars", "--headless", "--disable-gpu",
|
|
420
|
+
f"--window-size={window}", f"--force-device-scale-factor={scale}",
|
|
421
|
+
f"--screenshot={target}", f"--user-data-dir={os.path.join(scratch, 'profile')}",
|
|
422
|
+
"file://" + page,
|
|
423
|
+
]
|
|
424
|
+
with open(log_path, "w") as log:
|
|
425
|
+
proc = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT)
|
|
426
|
+
wait_until_stable(proc, target, 120, 0.5)
|
|
427
|
+
if os.path.isfile(target) and os.path.getsize(target) > 0:
|
|
428
|
+
return
|
|
429
|
+
with open(log_path, errors="replace") as log:
|
|
430
|
+
raise RuntimeError((log.read() or "no output").strip()[-300:])
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def load_spec_pages(path):
|
|
434
|
+
with open(path, encoding="utf-8") as stream:
|
|
435
|
+
spec = json.load(stream)
|
|
436
|
+
folder = os.path.dirname(os.path.abspath(path))
|
|
437
|
+
pages = spec.get("pages") or []
|
|
438
|
+
for page in pages:
|
|
439
|
+
page["image"] = image_source(page.get("image"), folder)
|
|
440
|
+
return spec, pages
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def split_nodes(flag, mapping):
|
|
444
|
+
given = [piece for piece in (flag or "").split(",") if piece.strip()]
|
|
445
|
+
return given or list(mapping.get("figmaNodes") or [])
|
|
457
446
|
|
|
458
447
|
|
|
459
448
|
def main():
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
os.makedirs(args.out, exist_ok=True)
|
|
479
|
-
|
|
480
|
-
file_key = args.file or m.get("figmaFileKey")
|
|
481
|
-
node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()] or \
|
|
482
|
-
[str(n) for n in (m.get("figmaNodes") or [])]
|
|
483
|
-
|
|
484
|
-
# one overlay for the screen: use the FIRST node as the framing node (the screen frame)
|
|
485
|
-
if args.spec:
|
|
486
|
-
with open(args.spec, encoding="utf-8") as fh:
|
|
487
|
-
spec = json.load(fh)
|
|
488
|
-
pages = spec.get("pages") or []
|
|
449
|
+
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
450
|
+
cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
|
|
451
|
+
cli.add_argument("--spec", help="geometry from MCP or measured by hand: {fileKey, pages:[{nodeId, image, frame, nodes}]}")
|
|
452
|
+
cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
|
|
453
|
+
cli.add_argument("--nodes", help="node-ids separated by commas, the screen frame first; falls back to figmaNodes")
|
|
454
|
+
cli.add_argument("--scale", default=2, type=int)
|
|
455
|
+
cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
|
|
456
|
+
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
457
|
+
cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
|
|
458
|
+
cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
|
|
459
|
+
opts = cli.parse_args()
|
|
460
|
+
|
|
461
|
+
with open(opts.mapping, encoding="utf-8") as stream:
|
|
462
|
+
mapping = json.load(stream)
|
|
463
|
+
stem = opts.slug or slug(mapping.get("screen"))
|
|
464
|
+
|
|
465
|
+
if opts.spec:
|
|
466
|
+
spec, pages = load_spec_pages(opts.spec)
|
|
489
467
|
if not pages:
|
|
490
|
-
sys.exit("--spec
|
|
468
|
+
sys.exit("--spec lists no pages")
|
|
491
469
|
page = pages[0]
|
|
492
|
-
file_key =
|
|
470
|
+
file_key = spec.get("fileKey") or mapping.get("figmaFileKey")
|
|
493
471
|
else:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
472
|
+
file_key = opts.file or mapping.get("figmaFileKey")
|
|
473
|
+
nodes = split_nodes(opts.nodes, mapping)
|
|
474
|
+
if not (file_key and nodes):
|
|
475
|
+
cli.error("need a Figma file key and frame node-id (flags or mapping), or --spec")
|
|
476
|
+
token = figma_token(opts.token)
|
|
498
477
|
if not token:
|
|
499
|
-
sys.exit("No Figma token
|
|
500
|
-
"
|
|
501
|
-
page =
|
|
502
|
-
page["name"] = screen
|
|
478
|
+
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN, keychain FIGMA_ACCESS_TOKEN or --token). "
|
|
479
|
+
"Without one, measure the boxes on the screenshot and hand them over with --spec.")
|
|
480
|
+
page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
|
|
503
481
|
|
|
504
|
-
cards = build_cards(page, rows)
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
482
|
+
cards = build_cards(page, mapping.get("rows") or [])
|
|
483
|
+
document, width, height = build_html(page, cards)
|
|
484
|
+
os.makedirs(opts.target_dir, exist_ok=True)
|
|
485
|
+
|
|
486
|
+
def output(suffix):
|
|
487
|
+
return os.path.join(opts.target_dir, f"{stem}.overlay.{suffix}")
|
|
510
488
|
|
|
489
|
+
chrome = find_chrome()
|
|
490
|
+
if chrome:
|
|
491
|
+
written = output("png")
|
|
492
|
+
try:
|
|
493
|
+
chrome_screenshot(chrome, document, written, width, height, opts.scale)
|
|
494
|
+
except (RuntimeError, subprocess.SubprocessError) as exc:
|
|
495
|
+
sys.exit(f"Chrome could not take the screenshot: {exc}")
|
|
496
|
+
else:
|
|
497
|
+
written = output("html")
|
|
498
|
+
with open(written, "w", encoding="utf-8") as sink:
|
|
499
|
+
sink.write(document)
|
|
500
|
+
print(f"# Chrome, Chromium and Edge are all missing; saved {written} in place of a PNG", file=sys.stderr)
|
|
501
|
+
|
|
502
|
+
with_copy = len([card for card in cards if card["annotated"]])
|
|
511
503
|
manifest = {
|
|
512
|
-
"screen": screen,
|
|
504
|
+
"screen": mapping.get("screen"),
|
|
513
505
|
"file": os.path.basename(written),
|
|
514
506
|
"fileKey": file_key,
|
|
515
|
-
"nodeId":
|
|
507
|
+
"nodeId": page.get("nodeId"),
|
|
516
508
|
"cardCount": len(cards),
|
|
517
|
-
"annotatedCount":
|
|
518
|
-
"width":
|
|
509
|
+
"annotatedCount": with_copy,
|
|
510
|
+
"width": width,
|
|
511
|
+
"height": height,
|
|
512
|
+
"scale": opts.scale,
|
|
519
513
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
514
|
+
with open(output("manifest.json"), "w", encoding="utf-8") as sink:
|
|
515
|
+
json.dump(manifest, sink, indent=2, ensure_ascii=False)
|
|
516
|
+
sink.write("\n")
|
|
517
|
+
|
|
518
|
+
if not cards:
|
|
519
|
+
print("# warning: the overlay is empty; no node matched a row and no node had an annotation", file=sys.stderr)
|
|
520
|
+
print(f"# overlay: {len(cards)} card(s), {with_copy} with CMS copy -> {written}", file=sys.stderr)
|
|
526
521
|
|
|
527
522
|
|
|
528
523
|
if __name__ == "__main__":
|