@mmerterden/multi-agent-pipeline 20.6.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/facts.json +1 -1
  3. package/manifest.json +71 -71
  4. package/package.json +1 -1
  5. package/pipeline/multi-agent-refs/features/design-conformance.md +62 -64
  6. package/pipeline/scripts/_notices.mjs +11 -0
  7. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  8. package/pipeline/skills/.skill-manifest.json +39 -39
  9. package/pipeline/skills/shared/README.md +1 -1
  10. package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +1 -0
  11. package/pipeline/skills/shared/external/android-architecture/SKILL.md +2 -0
  12. package/pipeline/skills/shared/external/android-performance/SKILL.md +2 -0
  13. package/pipeline/skills/shared/external/android-security/SKILL.md +2 -0
  14. package/pipeline/skills/shared/external/backlog/BACKLOG.md +1 -1
  15. package/pipeline/skills/shared/external/backlog/SKILL.md +56 -33
  16. package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +1 -0
  17. package/pipeline/skills/shared/external/compose-components/SKILL.md +2 -0
  18. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +3 -2
  19. package/pipeline/skills/shared/external/compose-testing/SKILL.md +2 -0
  20. package/pipeline/skills/shared/external/council/SKILL.md +1 -0
  21. package/pipeline/skills/shared/external/css-modern/SKILL.md +1 -0
  22. package/pipeline/skills/shared/external/database-patterns/SKILL.md +1 -0
  23. package/pipeline/skills/shared/external/evidence-github/SKILL.md +2 -0
  24. package/pipeline/skills/shared/external/evidence-registry/SKILL.md +2 -0
  25. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +2 -0
  26. package/pipeline/skills/shared/external/html-semantic/SKILL.md +1 -0
  27. package/pipeline/skills/shared/external/humanizer/SKILL.md +1 -0
  28. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +1 -0
  29. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +1 -0
  30. package/pipeline/skills/shared/external/ios-security/SKILL.md +2 -0
  31. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +91 -283
  32. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +119 -151
  33. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +60 -90
  34. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +119 -156
  35. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +726 -787
  36. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +253 -288
  37. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +243 -304
  38. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +88 -104
  39. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +181 -235
  40. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +198 -263
  41. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +461 -466
  42. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +145 -151
  43. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +123 -141
  44. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +146 -157
  45. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +22 -19
  46. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +156 -140
  47. package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +1 -0
  48. package/pipeline/skills/shared/external/play-store-review/SKILL.md +2 -0
  49. package/pipeline/skills/shared/external/python-patterns/SKILL.md +1 -0
  50. package/pipeline/skills/shared/external/react-best-practices/SKILL.md +1 -0
  51. package/pipeline/skills/shared/external/rest-api-design/SKILL.md +1 -0
  52. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +2 -0
  53. package/pipeline/skills/shared/external/room-database/SKILL.md +2 -0
  54. package/pipeline/skills/shared/external/search-first/SKILL.md +1 -0
  55. package/pipeline/skills/shared/external/signal-community/SKILL.md +2 -0
  56. package/pipeline/skills/shared/external/skill-creator/SKILL.md +80 -41
  57. package/pipeline/skills/shared/external/skill-creator/audit.md +63 -59
  58. package/pipeline/skills/shared/external/skill-creator/checklist.md +28 -20
  59. package/pipeline/skills/shared/external/skill-creator/examples.md +40 -40
  60. package/pipeline/skills/shared/external/skill-creator/label-check.md +48 -36
  61. package/pipeline/skills/shared/external/skill-creator/scripts/audit-panel.js +91 -100
  62. package/pipeline/skills/shared/external/skill-creator/template.md +51 -39
  63. package/pipeline/skills/shared/external/tailwind-css/SKILL.md +1 -0
  64. package/pipeline/skills/shared/external/testing-backend/SKILL.md +1 -0
  65. package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +1 -0
  66. package/pipeline/skills/shared/external/vue-composition/SKILL.md +1 -0
  67. package/pipeline/skills/shared/external/web-accessibility/SKILL.md +1 -0
  68. package/pipeline/skills/shared/external/web-performance/SKILL.md +1 -0
  69. package/pipeline/skills/shared/external/web-testing/SKILL.md +1 -0
@@ -1,864 +1,803 @@
1
1
  #!/usr/bin/env python3
2
- """Render a screen localization-reuse mapping into shareable artifacts.
3
-
4
- Input: a mapping JSON (schema in reference/format-and-output.md). Output, into --out:
5
- <slug>.confluence.xml - Confluence *storage format* (what the REST API wants)
6
- <slug>.md - Markdown mirror (PR / quick read)
7
- <slug>.preview.html - standalone HTML to eyeball in a browser before upload
8
- <slug>.docx - Word doc for the content team (--docx; pure-stdlib, offline)
9
- <slug>.pdf - read-only share copy (--pdf; via an installed renderer)
10
-
11
- The three text artifacts above are always written. `.docx` / `.pdf` are opt-in
12
- (`--docx` / `--pdf`, or `--all` for everything). The .docx is generated with the
13
- stdlib only (a .docx is a ZIP of OOXML) - no third-party dependency, no network.
14
- The .pdf is produced by whatever renderer the machine has (LibreOffice `soffice`,
15
- then Chrome/Chromium headless, then `wkhtmltopdf`); if none is installed the PDF is
16
- skipped with a note pointing at the .docx / .html (still no pip, no network).
17
-
18
- Document language defaults to **English**; pass `--ui-lang tr` for Turkish chrome.
19
- Author it in whatever language the content team actually reads. Only the template
20
- chrome is localised - the key/value data is rendered verbatim.
21
-
22
- Two-level layout: a Summary table (Old en/tr · New en/tr · CMS tr/en at a glance +
23
- keys + verdict) and Details (one block per element, all 8 languages, Old vs New vs
24
- CMS). The CMS columns carry the content team's actual final copy from Figma Dev Mode
25
- annotations (mapping `cms`, from scripts/fetch-annotations.py); a ⚠ marks a CMS value
26
- that drifts from the new design value. If the mapping has a top-level `overlay`
27
- (a key↔UI image from scripts/render-overlay.py), it is embedded as a "key map" section.
28
-
29
- Deterministic rendering only - no judgement, no network. Live Confluence upload is a
30
- separate step (scripts/publish-confluence.py - Server/DC Bearer, idempotent
31
- update-in-place); `--print-upload` prints the ready publish command.
2
+ """Turn a reuse-map mapping into the documents that get shared.
3
+
4
+ Every run produces <slug>.md, the Confluence storage-format body
5
+ <slug>.confluence.xml and <slug>.preview.html. A Word file and a PDF are added
6
+ on request (--docx, --pdf, or both via --all). Output depends only on the
7
+ mapping: no network access and no decisions are made here.
32
8
  """
33
9
  import argparse
34
10
  import html
35
11
  import json
36
12
  import os
13
+ import re
14
+ import shutil
15
+ import struct
16
+ import subprocess
37
17
  import sys
18
+ import tempfile
19
+ import time
20
+ import unicodedata
21
+ import zipfile
22
+ from collections import namedtuple
23
+ from xml.sax.saxutils import escape, quoteattr
24
+
25
+ LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
26
+ WARN = "\u26a0\ufe0f"
27
+ DASH = "-"
28
+ VERDICTS = ("reuse", "review", "new")
29
+ PLATFORM_NAMES = ("iOS", "Android", "Web")
30
+ LEGACY_KEY_FIELDS = ("legacyKeyIOS", "legacyKeyAndroid", "legacyKeyWeb")
31
+ FINAL_COPY = "content team's final copy"
32
+
33
+
34
+ def summary_columns(element, shot, new_key, legacy, old, new, verdict, note):
35
+ return [
36
+ "#", element, shot, new_key,
37
+ *(f"{legacy} {platform}" for platform in PLATFORM_NAMES),
38
+ *(f"{age} {lang}" for age in (old, new) for lang in ("en", "tr")),
39
+ "CMS TR", "CMS EN", verdict, note,
40
+ ]
41
+
42
+
43
+ def detail_columns(lang, old, new):
44
+ return [lang, f"{old} (legacy)", f"{new} (redesign)", "CMS"]
38
45
 
39
- VCOLOUR = {"reuse": "Green", "review": "Yellow", "new": "Blue"}
40
- VEMOJI = {"reuse": "✅", "review": "🔶", "new": "🆕"}
41
- PAGE_TITLE_PREFIX = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
42
- SUMMARY_LANGS = ["en", "tr"]
43
- DETAIL_LANGS = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
44
46
 
45
- # UI chrome strings - English default, Turkish alternate (--ui-lang).
46
47
  T = {
47
- "tr": {
48
- "title": "Lokalizasyon Yeniden Kullanım Haritası",
49
- "intro": "İçerik yönetimi için - hangi yeni tasarım metinleri mevcut bir çeviriyi yeniden kullanabilir. "
50
- "Karar bir öneridir; içerik ekibi onaylar.",
51
- "platforms": "Platformlar", "screen": "Ekran", "summary": "Özet",
52
- "details": "Detaylar - tüm diller", "from_export": "design-export'tan (tek depo)",
53
- "verdict": {"reuse": "YENİDEN KULLAN", "review": "İNCELE", "new": "YENİ"},
54
- "legend": {"reuse": "mevcut değeri/anahtarı yeniden kullan",
55
- "review": "aynı işlev, farklılık var - içerik ekibi karar verir",
56
- "new": "eski karşılığı yok"},
57
- "counts": ["yeniden kullan", "incele", "yeni", "öğe"],
58
- "cms_counts": ["içerik onaylı", "içerik farkı"],
59
- "keymap": "Ekran - anahtar eşlemesi",
60
- "drift_note": "⚠ = içerik ekibi (CMS) metni yeni tasarım değerinden farklı",
61
- "cols": ["#", "UI öğesi", "Ekran Görüntüsü", "Yeni anahtar", "Eski iOS", "Eski Android", "Eski Web",
62
- "Eski en", "Eski tr", "Yeni en", "Yeni tr", "İçerik TR", "İçerik EN", "Karar", "Not"],
63
- "dcols": ["Dil", "Eski (mevcut)", "Yeni (tasarım)", "İçerik (CMS)"],
64
- },
65
48
  "en": {
66
- "title": "Localization Reuse Map",
67
- "intro": "For content management - which redesign strings can reuse an existing translation. "
68
- "Verdict is a recommendation; content team confirms.",
69
- "platforms": "Platforms", "screen": "Screen", "summary": "Summary",
70
- "details": "Details - all languages", "from_export": "from design-export (single-repo)",
71
- "verdict": {"reuse": "REUSE", "review": "REVIEW", "new": "NEW"},
72
- "legend": {"reuse": "reuse the legacy value/key",
73
- "review": "same function, drift - content team decides",
74
- "new": "no legacy counterpart"},
75
- "counts": ["reuse", "review", "new", "elements"],
76
- "cms_counts": ["with CMS copy", "CMS differs"],
77
- "keymap": "Screen - key map",
78
- "drift_note": "⚠ = CMS copy differs from the new design value",
79
- "cols": ["#", "UI element", "Screenshot", "New key", "Legacy iOS", "Legacy Android", "Legacy Web",
80
- "Old en", "Old tr", "New en", "New tr", "CMS TR", "CMS EN", "Verdict", "Note"],
81
- "dcols": ["Lang", "Old (legacy)", "New (redesign)", "CMS"],
49
+ "title": "Localization reuse map",
50
+ "intro": (
51
+ "For each UI element, the redesign key and value next to the legacy key per platform, "
52
+ f"the legacy value and the {FINAL_COPY} from Figma. "
53
+ "The verdict is a recommendation; the content team makes the call."
54
+ ),
55
+ "verdict": {v: v.upper() for v in VERDICTS},
56
+ "legend": {
57
+ "reuse": "same function and an equivalent value; adopt the legacy value or key, no new translation",
58
+ "review": "same function but the wording, value or key differs; the content team chooses",
59
+ "new": "no legacy counterpart; mint a key and a value",
60
+ },
61
+ "platforms": "Platforms",
62
+ "figma": "Figma nodes",
63
+ "screen": "Screen",
64
+ "keymap": "Key map",
65
+ "summary": "Summary",
66
+ "details": "Details",
67
+ "elements": "elements",
68
+ "cms_rows": "rows carry CMS copy",
69
+ "cms_drift": "differ from the new value",
70
+ "drift_note": (
71
+ f"A warning mark on a CMS cell means the {FINAL_COPY} differs from the new value. "
72
+ "CMS copy is context for the decision, not an input to the verdict."
73
+ ),
74
+ "no_screenshot": "No screenshot attached.",
75
+ "key": "Key",
76
+ "columns": summary_columns("UI element", "Screenshot", "New key", "Legacy", "Old", "New", "Verdict", "Note"),
77
+ "detail_columns": detail_columns("Lang", "Old", "New"),
78
+ },
79
+ "tr": {
80
+ "title": "Lokalizasyon yeniden kullan\u0131m haritas\u0131",
81
+ "intro": "Her aray\u00fcz \u00f6\u011fesi i\u00e7in yeni anahtar ve de\u011fer; platform baz\u0131nda eski anahtar, eski de\u011fer ve i\u00e7erik ekibinin Figma'daki son metni yan yana. Karar bir \u00f6neridir; son s\u00f6z i\u00e7erik ekibinindir.",
82
+ "verdict": {"reuse": "YEN\u0130DEN KULLAN", "review": "\u0130NCELE", "new": "YEN\u0130"},
83
+ "legend": {
84
+ "reuse": "ayn\u0131 i\u015flev ve e\u015fde\u011fer de\u011fer; eski de\u011fer ya da anahtar kullan\u0131l\u0131r, yeni \u00e7eviri gerekmez",
85
+ "review": "ayn\u0131 i\u015flev ama metin, de\u011fer ya da anahtar farkl\u0131; se\u00e7imi i\u00e7erik ekibi yapar",
86
+ "new": "eski kar\u015f\u0131l\u0131\u011f\u0131 yok; yeni anahtar ve de\u011fer gerekir",
87
+ },
88
+ "platforms": "Platformlar",
89
+ "figma": "Figma node'lar\u0131",
90
+ "screen": "Ekran",
91
+ "keymap": "Anahtar haritas\u0131",
92
+ "summary": "\u00d6zet",
93
+ "details": "Ayr\u0131nt\u0131lar",
94
+ "elements": "\u00f6\u011fe",
95
+ "cms_rows": "sat\u0131rda CMS metni var",
96
+ "cms_drift": "yeni de\u011ferden farkl\u0131",
97
+ "drift_note": "CMS h\u00fccresindeki uyar\u0131 i\u015fareti, i\u00e7erik ekibinin son metninin yeni de\u011ferden farkl\u0131 oldu\u011funu g\u00f6sterir. CMS metni karar i\u00e7in ba\u011flamd\u0131r, karar\u0131n girdisi de\u011fildir.",
98
+ "no_screenshot": "Ekran g\u00f6r\u00fcnt\u00fcs\u00fc eklenmedi.",
99
+ "key": "Anahtar",
100
+ "columns": summary_columns(
101
+ "Aray\u00fcz \u00f6\u011fesi", "Ekran g\u00f6r\u00fcnt\u00fcs\u00fc", "Yeni anahtar",
102
+ "Eski", "Eski", "Yeni", "Karar", "Not",
103
+ ),
104
+ "detail_columns": detail_columns("Dil", "Eski", "Yeni"),
82
105
  },
83
106
  }
84
107
 
85
-
86
- def e(s):
87
- return html.escape("" if s in (None, "") else str(s), quote=True)
108
+ Look = namedtuple("Look", "status mark badge shade")
109
+ LOOKS = {
110
+ "reuse": Look("Green", "\u2705", "#1e8e3e", "E6F4EA"),
111
+ "review": Look("Yellow", "\U0001f536", "#e37400", "FDF3E2"),
112
+ "new": Look("Blue", "\U0001f195", "#1a73e8", "E8F0FE"),
113
+ }
114
+ UNKNOWN_LOOK = Look("Grey", "", "#80868b", None)
115
+ EMU_PER_PX = 9525
116
+ PNG_MAGIC = bytes([0x89]) + b"PNG\r\n\x1a\n"
88
117
 
89
118
 
90
- def dash(s):
91
- return s if s not in (None, "") else "-"
119
+ def look(row):
120
+ return LOOKS.get(row.get("verdict"), UNKNOWN_LOOK)
92
121
 
93
122
 
94
- def md(s):
95
- return ("" if s in (None, "") else str(s)).replace("|", "\\|").replace("\n", " ")
123
+ def slug(text):
124
+ value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
125
+ return value or "screen"
96
126
 
97
127
 
98
- def vtext(t, vd):
99
- return f'{VEMOJI.get(vd, "")} {t["verdict"].get(vd, vd)}'
128
+ def norm(value):
129
+ text = unicodedata.normalize("NFC", str(value or "")).strip()
130
+ text = re.sub(r"\s+", " ", text)
131
+ text = re.sub(r"[.:\u2026]+$", "", text).strip()
132
+ return text.lower()
100
133
 
101
134
 
102
- LEGACY_KEY_PREFIX = ""
135
+ def drifts(cms_value, new_value):
136
+ return bool(cms_value) and bool(new_value) and norm(cms_value) != norm(new_value)
103
137
 
104
138
 
105
- def legdisp(k):
106
- """Legacy keys are stored bare in the mapping. When the legacy backend namespaces
107
- them under a prefix the app strips, the content team still wants the full stored
108
- key - set the mapping's `legacyKeyPrefix` and it is re-added for display only.
109
- Parenthesised markers and empties pass through."""
110
- if k in (None, ""):
111
- return "-"
112
- return k if k.startswith("(") else LEGACY_KEY_PREFIX + k
139
+ def row_drifts(row):
140
+ cms, new = row.get("cms") or {}, row.get("new") or {}
141
+ return any(drifts(cms.get(lang), new.get(lang)) for lang in ("en", "tr"))
113
142
 
114
143
 
115
- def counts_line(t, rows):
116
- c = {k: sum(1 for r in rows if r.get("verdict") == k) for k in VCOLOUR}
117
- cn = t["counts"]
118
- return f'{c["reuse"]} {cn[0]} · {c["review"]} {cn[1]} · {c["new"]} {cn[2]} · {len(rows)} {cn[3]}'
144
+ def cms_cell(row, lang):
145
+ value = (row.get("cms") or {}).get(lang) or ""
146
+ if value and drifts(value, (row.get("new") or {}).get(lang)):
147
+ return f"{WARN} {value}"
148
+ return value
119
149
 
120
150
 
121
- # ---- CMS (content team) annotation copy ----
122
- # `cms` is the content team's actual final copy, fetched from Figma Dev Mode
123
- # annotations (see scripts/fetch-annotations.py). 8-lang-capable; the Summary shows
124
- # tr/en, the Details table shows every language that has a CMS value.
151
+ def lang_values(row, lang):
152
+ legacy = (row.get("legacy") or {}).get(lang) or ""
153
+ fresh = (row.get("new") or {}).get(lang) or ""
154
+ return legacy, fresh, cms_cell(row, lang)
125
155
 
126
- def cms_of(r):
127
- return r.get("cms") or {}
128
156
 
157
+ def legacy_key(value, prefix):
158
+ value = str(value or "").strip()
159
+ if not value:
160
+ return DASH
161
+ if value.startswith("("):
162
+ return value
163
+ return prefix + value
129
164
 
130
- def _cnorm(s):
131
- """Normalize for drift comparison: NFC, collapse whitespace, strip trailing .:..., lowercase.
132
- So casing/spacing/trailing-punctuation differences don't read as a real CMS drift."""
133
- import re
134
- import unicodedata
135
- if s in (None, ""):
136
- return ""
137
- s = unicodedata.normalize("NFC", str(s)).strip()
138
- s = re.sub(r"\s+", " ", s)
139
- return re.sub(r"[.:...]+$", "", s).strip().lower()
140
165
 
166
+ def load_keyshots(mapping, mapping_dir, out_dir):
167
+ manifest_name = mapping.get("keyshots")
168
+ if not manifest_name:
169
+ return {}
170
+ hits = [
171
+ os.path.join(folder, manifest_name)
172
+ for folder in (mapping_dir, out_dir, os.getcwd())
173
+ if os.path.isfile(os.path.join(folder, manifest_name))
174
+ ]
175
+ if not hits:
176
+ print(
177
+ f"# warning: no keyshots manifest called {manifest_name} next to the mapping, "
178
+ "under --out or in the working directory; the Screenshot column is left empty",
179
+ file=sys.stderr,
180
+ )
181
+ return {}
182
+ with open(hits[0], encoding="utf-8") as stream:
183
+ manifest = json.load(stream)
184
+ folder = os.path.dirname(os.path.abspath(hits[0]))
185
+ shots = {}
186
+ for shot in manifest.get("shots") or []:
187
+ if str(shot.get("file", "")).endswith(".png"):
188
+ shots[int(shot["row"])] = os.path.join(folder, shot["file"])
189
+ return shots
190
+
191
+
192
+ def node(name, attrs=(), *children):
193
+ opening = name + "".join(f" {k}={quoteattr(str(v))}" for k, v in attrs)
194
+ if not children:
195
+ return f"<{opening}/>"
196
+ return f"<{opening}>{''.join(children)}</{name}>"
197
+
198
+
199
+ def header_cells(labels, esc):
200
+ return "".join(f"<th>{esc(label)}</th>" for label in labels)
201
+
202
+
203
+ def storage_macro(kind, *children):
204
+ return node("ac:structured-macro", [("ac:name", kind)], *children)
205
+
206
+
207
+ def storage_image(filename, dimension, pixels):
208
+ target = node("ri:attachment", [("ri:filename", os.path.basename(filename))])
209
+ return node("ac:image", [(f"ac:{dimension}", pixels)], target)
210
+
211
+
212
+ class Doc:
213
+ def __init__(self, mapping, t, prefix, shots, out_dir):
214
+ self.m = mapping
215
+ self.t = t
216
+ self.prefix = prefix
217
+ self.shots = shots
218
+ self.out = out_dir
219
+ self.rows = mapping.get("rows") or []
220
+ self.counts = {v: sum(1 for r in self.rows if r.get("verdict") == v) for v in VERDICTS}
221
+ self.cms_rows = sum(1 for r in self.rows if (r.get("cms") or {}).get("tr") or (r.get("cms") or {}).get("en"))
222
+ self.drift_rows = sum(1 for r in self.rows if row_drifts(r))
223
+
224
+ def listed(self, field):
225
+ return ", ".join(self.m.get(field) or []) or DASH
226
+
227
+ def verdict(self, row):
228
+ return self.t["verdict"].get(row.get("verdict"), str(row.get("verdict") or "?").upper())
229
+
230
+ def count_line(self):
231
+ tally = " / ".join(f"{self.t['verdict'][v]} {self.counts[v]}" for v in VERDICTS)
232
+ return f"{tally} - {len(self.rows)} {self.t['elements']}"
233
+
234
+ def cms_line(self):
235
+ return f"CMS: {self.cms_rows} {self.t['cms_rows']}, {self.drift_rows} {self.t['cms_drift']}"
236
+
237
+ def summary_values(self, index, row):
238
+ old, new = row.get("legacy") or {}, row.get("new") or {}
239
+ keys = [legacy_key(row.get(field), self.prefix) for field in LEGACY_KEY_FIELDS]
240
+ texts = [side.get(lang) or "" for side in (old, new) for lang in ("en", "tr")]
241
+ return [
242
+ str(index), row.get("element") or "", None, row.get("newKey") or "",
243
+ *keys, *texts,
244
+ cms_cell(row, "tr"), cms_cell(row, "en"), None, row.get("note") or "",
245
+ ]
246
+
247
+ def detail_langs(self, row):
248
+ return LANGS + [lang for lang in (row.get("cms") or {}) if lang not in LANGS]
249
+
250
+ def md(self):
251
+ t, m = self.t, self.m
252
+
253
+ def flat(value):
254
+ return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
255
+
256
+ def table_line(values):
257
+ return "| " + " | ".join(values) + " |"
258
+
259
+ lines = [f"# {t['title']}: {m.get('screen')}", "", t["intro"], ""]
260
+ lines.append(f"**{t['platforms']}:** {self.listed('platforms')} ")
261
+ lines.append(f"**{t['figma']}:** {self.listed('figmaNodes')}")
262
+ lines += ["", f"## {t['screen']}", ""]
263
+ lines.append(f"![{t['screen']}]({m['screenshot']})" if m.get("screenshot") else t["no_screenshot"])
264
+ if m.get("overlay"):
265
+ lines += ["", f"## {t['keymap']}", "", f"![{t['keymap']}]({m['overlay']})"]
266
+ lines += ["", f"## {t['summary']}", "", self.count_line(), "", self.cms_line(), ""]
267
+ lines += [f"- {LOOKS[v].mark} **{t['verdict'][v]}**: {t['legend'][v]}" for v in VERDICTS]
268
+ lines += ["", f"> {WARN} {t['drift_note']}", ""]
269
+ lines.append(table_line(t["columns"]))
270
+ lines.append("|" + "---|" * len(t["columns"]))
271
+ for i, row in enumerate(self.rows, 1):
272
+ values = self.summary_values(i, row)
273
+ shot = self.shots.get(i)
274
+ values[2] = f"![{i}]({os.path.relpath(shot, self.out)})" if shot else DASH
275
+ values[3] = f"`{values[3]}`" if values[3] else DASH
276
+ values[4:7] = [DASH if v == DASH else f"`{v}`" for v in values[4:7]]
277
+ values[13] = f"{look(row).mark} {self.verdict(row)}".strip()
278
+ lines.append(table_line([flat(v) for v in values]))
279
+ lines += ["", f"## {t['details']}", ""]
280
+ for i, row in enumerate(self.rows, 1):
281
+ lines += [f"### {i}. {row.get('element') or ''} - {self.verdict(row)}", ""]
282
+ lines += [f"{t['key']}: `{row.get('newKey') or ''}`", ""]
283
+ lines.append(table_line(t["detail_columns"]))
284
+ lines.append("|---|---|---|---|")
285
+ for lang in self.detail_langs(row):
286
+ lines.append(table_line([lang] + [flat(v) for v in lang_values(row, lang)]))
287
+ lines.append("")
288
+ return "\n".join(lines) + "\n"
289
+
290
+ def status(self, row):
291
+ return storage_macro(
292
+ "status",
293
+ node("ac:parameter", [("ac:name", "colour")], look(row).status),
294
+ node("ac:parameter", [("ac:name", "title")], escape(self.verdict(row))),
295
+ )
296
+
297
+ def xml(self):
298
+ t, m = self.t, self.m
299
+ e = escape
300
+
301
+ def heading(label):
302
+ return f"<h2>{e(t[label])}</h2>"
303
+
304
+ def labelled(label, value):
305
+ return f"<strong>{e(t[label])}:</strong> {e(value)}"
306
+
307
+ legend = "".join(f"<li>{self.status({'verdict': v})} {e(t['legend'][v])}</li>" for v in VERDICTS)
308
+ facts = " | ".join([
309
+ labelled("screen", str(m.get("screen"))),
310
+ labelled("platforms", self.listed("platforms")),
311
+ labelled("figma", self.listed("figmaNodes")),
312
+ ])
313
+ notes = [t["intro"], self.count_line(), self.cms_line(), WARN + " " + t["drift_note"]]
314
+ info_body = f"<p>{e(notes[0])}</p><ul>{legend}</ul>" + "".join(f"<p>{e(n)}</p>" for n in notes[1:])
315
+ blocks = [
316
+ f"<p>{facts}</p>",
317
+ storage_macro("info", node("ac:rich-text-body", (), info_body)),
318
+ heading("screen"),
319
+ ]
320
+ if m.get("screenshot"):
321
+ blocks.append(f"<p>{storage_image(m['screenshot'], 'height', 500)}</p>")
322
+ else:
323
+ blocks.append(f"<p><em>{e(t['no_screenshot'])}</em></p>")
324
+ if m.get("overlay"):
325
+ blocks += [heading("keymap"), f"<p>{storage_image(m['overlay'], 'width', 900)}</p>"]
326
+ blocks.append(heading("summary"))
327
+ summary = []
328
+ for i, row in enumerate(self.rows, 1):
329
+ cells = []
330
+ for col, value in enumerate(self.summary_values(i, row)):
331
+ if col == 2:
332
+ shot = self.shots.get(i)
333
+ inner = storage_image(shot, "width", 240) if shot else DASH
334
+ elif col == 13:
335
+ inner = self.status(row)
336
+ elif col in (3, 4, 5, 6) and value and value != DASH:
337
+ inner = f"<code>{e(value)}</code>"
338
+ else:
339
+ inner = e(value)
340
+ cells.append(f"<td>{inner}</td>")
341
+ summary.append("<tr>" + "".join(cells) + "</tr>")
342
+ blocks.append(f"<table><tbody><tr>{header_cells(t['columns'], e)}</tr>{''.join(summary)}</tbody></table>")
343
+ blocks.append(heading("details"))
344
+ for i, row in enumerate(self.rows, 1):
345
+ blocks.append(f"<h3>{i}. {e(row.get('element') or '')} {self.status(row)}</h3>")
346
+ blocks.append(f"<p>{e(t['key'])}: <code>{e(row.get('newKey') or '')}</code></p>")
347
+ lang_rows = "".join(
348
+ f"<tr><td>{lang}</td>" + "".join(f"<td>{e(v)}</td>" for v in lang_values(row, lang)) + "</tr>"
349
+ for lang in self.detail_langs(row)
350
+ )
351
+ blocks.append(f"<table><tbody><tr>{header_cells(t['detail_columns'], e)}</tr>{lang_rows}</tbody></table>")
352
+ return "\n".join(blocks) + "\n"
353
+
354
+ def src(self, path):
355
+ if not path:
356
+ return ""
357
+ return "file://" + path if os.path.isabs(path) else path
358
+
359
+ def preview(self):
360
+ t, m = self.t, self.m
361
+ e = html.escape
362
+
363
+ def badge(row):
364
+ return f'<span class="badge" style="background:{look(row).badge}">{e(self.verdict(row))}</span>'
365
+
366
+ legend = "".join(f"<li>{badge({'verdict': v})} {e(t['legend'][v])}</li>" for v in VERDICTS)
367
+ summary = []
368
+ for i, row in enumerate(self.rows, 1):
369
+ shot = self.shots.get(i)
370
+ cells = []
371
+ for col, value in enumerate(self.summary_values(i, row)):
372
+ if col == 2:
373
+ inner = f'<img class="shot" src="{e(self.src(shot))}">' if shot else DASH
374
+ elif col == 13:
375
+ inner = badge(row)
376
+ elif col in (3, 4, 5, 6):
377
+ inner = f"<code>{e(value or DASH)}</code>"
378
+ else:
379
+ inner = e(value)
380
+ cells.append(f"<td>{inner}</td>")
381
+ summary.append("<tr>" + "".join(cells) + "</tr>")
382
+ details = []
383
+ for i, row in enumerate(self.rows, 1):
384
+ lang_rows = "".join(
385
+ f"<tr><td>{lang}</td>" + "".join(f"<td>{e(v)}</td>" for v in lang_values(row, lang)) + "</tr>"
386
+ for lang in self.detail_langs(row)
387
+ )
388
+ details.append(
389
+ f"<h3>{i}. {e(row.get('element') or '')} {badge(row)}</h3>"
390
+ f"<p>{e(t['key'])}: <code>{e(row.get('newKey') or '')}</code></p>"
391
+ f"<table><tr>{header_cells(t['detail_columns'], e)}</tr>{lang_rows}</table>"
392
+ )
393
+ if m.get("screenshot"):
394
+ screen = f'<img class="screen" src="{e(self.src(m.get("screenshot")))}">'
395
+ else:
396
+ screen = f"<p>{e(t['no_screenshot'])}</p>"
397
+ overlay = ""
398
+ if m.get("overlay"):
399
+ overlay = f'<h3>{e(t["keymap"])}</h3><img class="overlay" src="{e(self.src(m.get("overlay")))}">'
400
+ small = "11px"
401
+ style = (
402
+ "body{font-family:-apple-system,Helvetica,Arial,sans-serif;margin:24px;color:#202124;}"
403
+ "table{border-collapse:collapse;margin:8px 0 16px;font-size:12px;}"
404
+ "th,td{border:1px solid #dadce0;padding:4px 6px;vertical-align:top;text-align:left;}"
405
+ f"th{{background:#f5f6f8;}}code{{font-size:{small};}}"
406
+ f".badge{{color:#fff;border-radius:4px;padding:1px 6px;font-size:{small};font-weight:600;}}"
407
+ ".screen{max-height:500px;}.overlay{max-width:100%;}.shot{width:200px;}"
408
+ )
409
+ caption = f"{e(t['title'])}: {e(str(m.get('screen')))}"
410
+ sections = [
411
+ (t["screen"], screen + overlay),
412
+ (t["summary"], f"<table><tr>{header_cells(t['columns'], e)}</tr>{''.join(summary)}</table>"),
413
+ (t["details"], "".join(details)),
414
+ ]
415
+ numbered = "".join(f"<h2>{n}. {e(title)}</h2>{content}" for n, (title, content) in enumerate(sections, 1))
416
+ return (
417
+ f'<!doctype html><html><head><meta charset="utf-8"><title>{caption}</title>'
418
+ f"<style>{style}</style></head><body>"
419
+ f"<h1>{caption}</h1><p>{e(t['intro'])}</p>"
420
+ f"<p><b>{e(t['platforms'])}:</b> {e(self.listed('platforms'))} | "
421
+ f"<b>{e(t['figma'])}:</b> {e(self.listed('figmaNodes'))}</p>"
422
+ f"<ul>{legend}</ul><p>{e(self.count_line())}<br>{e(self.cms_line())}</p><p>{e(WARN + ' ' + t['drift_note'])}</p>"
423
+ f"{numbered}</body></html>\n"
424
+ )
425
+
426
+
427
+ def png_size(path):
428
+ try:
429
+ with open(path, "rb") as stream:
430
+ head = stream.read(24)
431
+ except OSError:
432
+ return None
433
+ if head.startswith(PNG_MAGIC):
434
+ return struct.unpack(">II", head[16:24])
435
+ return None
141
436
 
142
- def _drifts(cms_val, new_val):
143
- return (cms_val not in (None, "") and new_val not in (None, "")
144
- and _cnorm(cms_val) != _cnorm(new_val))
145
437
 
438
+ def fit(size, max_w, max_h):
439
+ w, h = size[0] * EMU_PER_PX, size[1] * EMU_PER_PX
440
+ ratio = min(max_w / w, max_h / h, 1.0)
441
+ return int(w * ratio), int(h * ratio)
146
442
 
147
- def row_drift(r):
148
- """True if any summary-lang CMS value differs (meaningfully) from the new value."""
149
- nw, cms = r.get("new") or {}, cms_of(r)
150
- return any(_drifts(cms.get(l), nw.get(l)) for l in SUMMARY_LANGS)
151
443
 
444
+ OOXML = "http://schemas.openxmlformats.org/"
445
+ NS_DRAWING = OOXML + "drawingml/2006/"
446
+ NS_DOC_REL = OOXML + "officeDocument/2006/relationships"
447
+ XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
448
+ MIME_OOXML = "application/vnd.openxmlformats-"
152
449
 
153
- def cms_disp(r, lang):
154
- """CMS value for a lang, with a ⚠ marker when it drifts from the new value; ' - ' if absent."""
155
- cms, nw = cms_of(r), r.get("new") or {}
156
- v = cms.get(lang)
157
- if v in (None, ""):
158
- return "-"
159
- return ("⚠ " if _drifts(v, nw.get(lang)) else "") + str(v)
160
450
 
451
+ def prefixed(ns):
452
+ def build(tag, *children, **attrs):
453
+ return node(f"{ns}:{tag}", [(f"{ns}:{k}", v) for k, v in attrs.items()], *children)
454
+ return build
161
455
 
162
- def cms_line(t, rows):
163
- have = sum(1 for r in rows if cms_of(r).get("tr") or cms_of(r).get("en"))
164
- drift = sum(1 for r in rows if row_drift(r))
165
- cc = t["cms_counts"]
166
- return f'{have} {cc[0]} · {drift} {cc[1]}'
167
456
 
457
+ w = prefixed("w")
168
458
 
169
- # ---- per-key keyshots (annotated screenpieces) ----
170
- # The mapping's top-level `keyshots` names the manifest render-key-shots.py wrote
171
- # (`<slug>.keyshots.manifest.json`); each shot is a cropped screenshot with a red box
172
- # around that row's element. The Summary table's "Ekran Görüntüsü" cell embeds it -
173
- # rows absent from the manifest render "-" (never a guessed crop, same rule as values).
174
459
 
175
- def keyshots_of(m):
176
- """{row number: relative file} for the embeddable (.png) shots, else {}."""
177
- return m.get("_keyshots") or {}
460
+ def plain(ns):
461
+ def build(tag, *children, **attrs):
462
+ return node(f"{ns}:{tag}", list(attrs.items()), *children)
463
+ return build
178
464
 
179
465
 
180
- def load_keyshots(m, bases):
181
- """Resolve + parse the manifest named by mapping `keyshots`; keep .png shots only
182
- (an .html Chrome-fallback can't be embedded). Stores {row: relpath} on the mapping
183
- and returns the manifest's directory (for resolving shot files), or None."""
184
- name = m.get("keyshots")
185
- if not name:
186
- return None
187
- for b in bases:
188
- cand = name if os.path.isabs(name) else os.path.join(b, name)
189
- if os.path.isfile(cand):
190
- with open(cand, encoding="utf-8") as fh:
191
- man = json.load(fh)
192
- m["_keyshots"] = {s["row"]: s["file"] for s in man.get("shots", [])
193
- if s.get("file", "").endswith(".png")}
194
- return os.path.dirname(os.path.abspath(cand))
195
- print(f"warning: keyshots manifest not found: {name} - table cells stay blank",
196
- file=sys.stderr)
197
- return None
466
+ a, pic, wp = plain("a"), plain("pic"), plain("wp")
198
467
 
199
468
 
200
- # ---- Confluence storage format ----
201
-
202
- def status_macro(t, vd):
203
- return (f'<ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">{VCOLOUR.get(vd,"Grey")}</ac:parameter>'
204
- f'<ac:parameter ac:name="title">{e(t["verdict"].get(vd, vd))}</ac:parameter></ac:structured-macro>')
205
-
206
-
207
- def render_storage(m, t):
208
- rows = m["rows"]
209
- shot = m.get("screenshot")
210
- shot_xml = (f'<ac:image ac:height="500"><ri:attachment ri:filename="{e(os.path.basename(shot))}"/></ac:image>'
211
- if shot else "<p><em>[screenshot]</em></p>")
212
- over = m.get("overlay")
213
- over_xml = (f'<h2>{e(t["keymap"])}</h2>'
214
- f'<ac:image ac:width="900"><ri:attachment ri:filename="{e(os.path.basename(over))}"/></ac:image>'
215
- if over else "")
216
- legend = " &nbsp; ".join(f'{status_macro(t, k)} {e(t["legend"][k])}' for k in VCOLOUR)
217
- head = (f'<p><strong>{e(t["screen"])}:</strong> {e(m["screen"])} &nbsp; '
218
- f'<strong>{e(t["platforms"])}:</strong> {e(", ".join(m.get("platforms", [])))} &nbsp; '
219
- f'<strong>Figma:</strong> {e(", ".join(m.get("figmaNodes", [])))}</p>'
220
- f'<ac:structured-macro ac:name="info"><ac:rich-text-body><p>{legend} &nbsp;|&nbsp; '
221
- f'{e(counts_line(t, rows))} &nbsp;|&nbsp; {e(cms_line(t, rows))}</p>'
222
- f'<p><em>{e(t["drift_note"])}</em></p></ac:rich-text-body></ac:structured-macro>')
223
- head_row = "".join(f"<th>{e(c)}</th>" for c in t["cols"])
224
- body = []
225
- shots = keyshots_of(m)
226
- for i, r in enumerate(rows, 1):
227
- nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
228
- ks = (f'<ac:image ac:width="240"><ri:attachment ri:filename="{e(os.path.basename(shots[i]))}"/></ac:image>'
229
- if i in shots else "-")
230
- tds = [str(i), e(r.get("element")), ks, f'<code>{e(r.get("newKey"))}</code>',
231
- f'<code>{e(legdisp(r.get("legacyKeyIOS")))}</code>', f'<code>{e(legdisp(r.get("legacyKeyAndroid")))}</code>',
232
- f'<code>{e(legdisp(r.get("legacyKeyWeb")))}</code>']
233
- tds += [e(dash(old.get(l))) for l in SUMMARY_LANGS] + [e(dash(nw.get(l))) for l in SUMMARY_LANGS]
234
- tds += [e(cms_disp(r, "tr")), e(cms_disp(r, "en"))]
235
- tds += [status_macro(t, r.get("verdict", "")), e(r.get("note"))]
236
- body.append("<tr>" + "".join(f"<td>{c}</td>" for c in tds) + "</tr>")
237
- summary = f"<h2>{e(t['summary'])}</h2><table><tbody><tr>{head_row}</tr>{''.join(body)}</tbody></table>"
238
- det = [f"<h2>{e(t['details'])}</h2>"]
239
- for i, r in enumerate(rows, 1):
240
- nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
241
- lr = "".join(f"<tr><td>{l}</td><td>{e(dash(old.get(l)))}</td><td>{e(dash(nw.get(l)))}</td>"
242
- f"<td>{e(dash(cms.get(l)))}</td></tr>" for l in DETAIL_LANGS)
243
- dh = "".join(f"<th>{e(c)}</th>" for c in t["dcols"])
244
- det.append(f"<h3>{i}. {e(r.get('element'))} {status_macro(t, r.get('verdict',''))}</h3>"
245
- f"<p>{e(t['cols'][3])} <code>{e(r.get('newKey'))}</code> · {e(t['cols'][4])} "
246
- f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code> · {e(t['cols'][5])} "
247
- f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code> · {e(t['cols'][6])} "
248
- f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code></p>"
249
- f"<table><tbody><tr>{dh}</tr>{lr}</tbody></table>")
250
- return head + f"<h2>{e(t['screen'])}</h2>{shot_xml}" + over_xml + summary + "".join(det)
251
-
252
-
253
- # ---- Markdown mirror ----
254
-
255
- def render_markdown(m, t):
256
- rows = m["rows"]
257
- over = m.get("overlay")
258
- over_md = ([f"## {t['keymap']}", "", f"![keymap]({os.path.basename(over)})", ""] if over else [])
259
- out = [f"# {t['title']} - {m['screen']}", "", f"_{t['intro']}_", "",
260
- f"**{t['platforms']}:** {', '.join(m.get('platforms', []))} · **Figma:** {', '.join(m.get('figmaNodes', []))}", "",
261
- f"## {t['screen']}", "", f"![{t['screen'].lower()}]({os.path.basename(m.get('screenshot','screenshot.png'))})", "",
262
- *over_md,
263
- f"## {t['summary']} - {counts_line(t, rows)} · {cms_line(t, rows)}", "",
264
- f"_{t['drift_note']}_", "",
265
- "| " + " | ".join(t["cols"]) + " |", "|" + "|".join("---" for _ in t["cols"]) + "|"]
266
- shots = keyshots_of(m)
267
- for i, r in enumerate(rows, 1):
268
- nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
269
- c = [str(i), md(r.get("element")), f"![{i}]({shots[i]})" if i in shots else "-",
270
- f'`{r.get("newKey")}`',
271
- f'`{legdisp(r.get("legacyKeyIOS"))}`', f'`{legdisp(r.get("legacyKeyAndroid"))}`',
272
- f'`{legdisp(r.get("legacyKeyWeb"))}`']
273
- c += [md(dash(old.get(l))) for l in SUMMARY_LANGS] + [md(dash(nw.get(l))) for l in SUMMARY_LANGS]
274
- c += [md(cms_disp(r, "tr")), md(cms_disp(r, "en"))]
275
- c += [vtext(t, r.get("verdict", "")), md(r.get("note"))]
276
- out.append("| " + " | ".join(c) + " |")
277
- out += ["", f"## {t['details']}", ""]
278
- for i, r in enumerate(rows, 1):
279
- nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
280
- out += [f"### {i}. {r.get('element')} - {vtext(t, r.get('verdict',''))}",
281
- f"{t['cols'][3]} `{r.get('newKey')}` · {t['cols'][4]} `{legdisp(r.get('legacyKeyIOS'))}` · "
282
- f"{t['cols'][5]} `{legdisp(r.get('legacyKeyAndroid'))}` · {t['cols'][6]} `{legdisp(r.get('legacyKeyWeb'))}`",
283
- "", "| " + " | ".join(t["dcols"]) + " |", "|" + "|".join("---" for _ in t["dcols"]) + "|"]
284
- for l in DETAIL_LANGS:
285
- out.append(f"| {l} | {md(dash(old.get(l)))} | {md(dash(nw.get(l)))} | {md(dash(cms.get(l)))} |")
286
- out.append("")
287
- return "\n".join(out) + "\n"
288
-
289
-
290
- # ---- Browser preview ----
291
-
292
- def render_html(m, t):
293
- rows = m["rows"]
294
- bgc = {"reuse": "#e6f4ea", "review": "#fdf3e2", "new": "#e8f0fe"}
295
- fgc = {"reuse": "#1f8a4c", "review": "#b06a00", "new": "#1666c8"}
296
-
297
- def badge(vd):
298
- return (f'<span style="background:{bgc.get(vd,"#eee")};color:{fgc.get(vd,"#555")};font-weight:600;'
299
- f'padding:2px 8px;border-radius:10px;font-size:12px;white-space:nowrap">{e(vtext(t, vd))}</span>')
300
-
301
- summ = ["<table><thead><tr>" + "".join(f"<th>{e(c)}</th>" for c in t["cols"]) + "</tr></thead><tbody>"]
302
- shots = keyshots_of(m)
303
- for i, r in enumerate(rows, 1):
304
- nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
305
- ks = (f'<img src="{e(shots[i])}" style="max-width:240px;border:1px solid #ddd;border-radius:6px">'
306
- if i in shots else "-")
307
- cells = ([str(i), e(r.get("element")), ks, f"<code>{e(r.get('newKey'))}</code>",
308
- f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code>", f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code>",
309
- f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code>"]
310
- + [e(dash(old.get(l))) for l in SUMMARY_LANGS] + [e(dash(nw.get(l))) for l in SUMMARY_LANGS]
311
- + [e(cms_disp(r, "tr")), e(cms_disp(r, "en"))]
312
- + [badge(r.get("verdict", "")), f"<span style='color:#555'>{e(r.get('note'))}</span>"])
313
- summ.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
314
- summ.append("</tbody></table>")
315
- det = []
316
- for i, r in enumerate(rows, 1):
317
- nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
318
- lr = "".join(f"<tr><td>{l}</td><td>{e(dash(old.get(l)))}</td><td>{e(dash(nw.get(l)))}</td>"
319
- f"<td>{e(dash(cms.get(l)))}</td></tr>" for l in DETAIL_LANGS)
320
- dh = "".join(f"<th>{e(c)}</th>" for c in t["dcols"])
321
- det.append(f"<h3>{i}. {e(r.get('element'))} {badge(r.get('verdict',''))}</h3>"
322
- f"<p>{e(t['cols'][3])} <code>{e(r.get('newKey'))}</code> · {e(t['cols'][4])} "
323
- f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code> · {e(t['cols'][5])} "
324
- f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code> · {e(t['cols'][6])} "
325
- f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code><br><span style='color:#777'>{e(r.get('note'))}</span></p>"
326
- f"<table style='max-width:780px'><thead><tr>{dh}</tr></thead><tbody>{lr}</tbody></table>")
327
- def img_src(p):
328
- # absolute path → file:// URL; bare filename → relative (preview.html sits in
329
- # the same out dir, and Chrome's html→pdf resolves it too)
330
- return f"file://{e(p)}" if os.path.isabs(p or "") else e(p)
331
-
332
- shot = m.get("screenshot")
333
- shot_html = (f'<img src="{img_src(shot)}" style="max-height:520px;border:1px solid #ddd;border-radius:8px">'
334
- if shot else '<div style="color:#999">[screenshot]</div>')
335
- over = m.get("overlay")
336
- over_html = (f'<h2>{e(t["keymap"])}</h2><img src="{img_src(over)}" '
337
- 'style="max-width:100%;border:1px solid #ddd;border-radius:8px">' if over else "")
338
- return f"""<!doctype html><meta charset=utf-8><title>{e(t['title'])} - {e(m['screen'])}</title>
339
- <style>
340
- body{{font:13.5px -apple-system,Segoe UI,Roboto,sans-serif;margin:26px;color:#1a1a1a;max-width:1280px}}
341
- h1{{font-size:22px}} h2{{font-size:17px;margin-top:30px;border-bottom:2px solid #eee;padding-bottom:4px}}
342
- h3{{font-size:15px;margin-top:20px}} code{{font:12px Menlo,monospace;color:#a3306e}}
343
- table{{border-collapse:collapse;width:100%;margin-top:8px}}
344
- th,td{{border:1px solid #e3e3e3;padding:6px 9px;text-align:left;vertical-align:top}}
345
- th{{background:#f5f6f8}} tr:nth-child(even) td{{background:#fafbfc}}
346
- </style>
347
- <h1>{e(t['title'])} - {e(m['screen'])}</h1>
348
- <p style="color:#555">{e(t['intro'])}</p>
349
- <h2>1 · {e(t['screen'])}</h2>{shot_html}
350
- <p style="color:#777">Figma {e(', '.join(m.get('figmaNodes',[])))} - {e(t['from_export'])}.</p>
351
- {over_html}
352
- <h2>2 · {e(t['summary'])} <span style="font-weight:400;color:#666"> - {e(counts_line(t, rows))} · {e(cms_line(t, rows))}</span></h2>
353
- <p style="color:#777">{e(t['drift_note'])}</p>
354
- {''.join(summ)}
355
- <h2>3 · {e(t['details'])}</h2>{''.join(det)}
356
- """
469
+ class Docx:
470
+ def __init__(self):
471
+ self.media = []
357
472
 
473
+ def image(self, path, rid, max_w, max_h, number):
474
+ size = png_size(path)
475
+ if not size:
476
+ return ""
477
+ cx, cy = fit(size, max_w, max_h)
478
+ media_name = f"image{number}.png"
479
+ self.media.append((rid, path, media_name))
480
+ extent = {"cx": cx, "cy": cy}
481
+ origin = {"x": 0, "y": 0}
482
+ shape = pic(
483
+ "spPr",
484
+ a("xfrm", a("off", **origin), a("ext", **extent)),
485
+ a("prstGeom", a("avLst"), prst="rect"),
486
+ )
487
+ fill = pic("blipFill", node("a:blip", [("r:embed", rid)]), a("stretch", a("fillRect")))
488
+ identity = pic("nvPicPr", pic("cNvPr", id=number, name=media_name), pic("cNvPicPr"))
489
+ picture = node("pic:pic", [("xmlns:pic", NS_DRAWING + "picture")], identity, fill, shape)
490
+ graphic = node(
491
+ "a:graphic", [("xmlns:a", NS_DRAWING + "main")],
492
+ a("graphicData", picture, uri=NS_DRAWING + "picture"),
493
+ )
494
+ inline = wp("inline", wp("extent", **extent), wp("docPr", id=number, name=f"Picture {number}"), graphic)
495
+ return w("r", w("drawing", inline))
358
496
 
359
- # ---- DOCX (pure stdlib - a .docx is a ZIP of OOXML) ----
360
- # No third-party dependency: we emit the minimal Office Open XML package by hand
361
- # (zipfile + XML strings), so the skill stays offline and dependency-free. Word
362
- # resolves the per-language fonts itself; we set bidi/rtl on Arabic cells.
363
497
 
364
- EMU_PER_PX = 9525 # at 96 dpi (914400 EMU / inch)
365
- DOCX_IMG_MAX_W_EMU = 5_000_000 # ~5.5 in wide
366
- DOCX_IMG_MAX_H_EMU = 5_500_000 # ~6 in tall - keep portrait phone shots on one landscape page
367
- DV_FILL = {"reuse": "E6F4EA", "review": "FDF3E2", "new": "E8F0FE"} # mirror the HTML badges
368
- HEAD_FILL = "F5F6F8"
498
+ def para(text, bold=False, size=None, rtl=False):
499
+ styling = []
500
+ if bold:
501
+ styling.append(w("b"))
502
+ if size:
503
+ styling.append(w("sz", val=size))
504
+ if rtl:
505
+ styling.append(w("rtl"))
506
+ run = [w("rPr", *styling)] if styling else []
507
+ run.append(node("w:t", [("xml:space", "preserve")], escape(str(text))))
508
+ lead = [w("pPr", w("bidi"))] if rtl else []
509
+ return w("p", *lead, w("r", *run))
369
510
 
370
511
 
371
- def _png_size(path):
372
- """(w, h) in px from a PNG header, or None. Stdlib only."""
373
- import struct
374
- try:
375
- with open(path, "rb") as fh:
376
- head = fh.read(24)
377
- except OSError:
378
- return None
379
- if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
380
- return None
381
- return struct.unpack(">II", head[16:24])
512
+ def tcell(content, fill=None, raw=False, rtl=False):
513
+ inner = content if raw else para(content, rtl=rtl)
514
+ shading = [w("tcPr", w("shd", val="clear", color="auto", fill=fill))] if fill else []
515
+ return w("tc", *shading, inner or w("p"))
382
516
 
383
517
 
384
- def _dxe(s):
385
- """Escape text for WordprocessingML (same rules as XML)."""
386
- return html.escape("" if s in (None, "") else str(s), quote=True)
518
+ EDGES = ("top", "left", "bottom", "right") + tuple("inside" + axis for axis in "HV")
387
519
 
388
520
 
389
- def _run(text, *, bold=False, color=None, rtl=False):
390
- rpr = []
391
- if bold:
392
- rpr.append("<w:b/>")
393
- if rtl:
394
- rpr.append("<w:rtl/>")
395
- if color:
396
- rpr.append(f'<w:color w:val="{color}"/>')
397
- rpr = f"<w:rPr>{''.join(rpr)}</w:rPr>" if rpr else ""
398
- return f'<w:r>{rpr}<w:t xml:space="preserve">{_dxe(text)}</w:t></w:r>'
521
+ def table(rows_xml):
522
+ lines = "".join(w(edge, val="single", sz=4, color="DADCE0") for edge in EDGES)
523
+ props = w("tblPr", w("tblW", w=0, type="auto"), w("tblBorders", lines))
524
+ return w("tbl", props, *rows_xml)
399
525
 
400
526
 
401
- def _para(runs, *, rtl=False, spacing_after=40):
402
- if isinstance(runs, str):
403
- runs = [_run(runs)]
404
- ppr = [f'<w:spacing w:after="{spacing_after}"/>']
405
- if rtl:
406
- ppr.append("<w:bidi/>")
407
- return f"<w:p><w:pPr>{''.join(ppr)}</w:pPr>{''.join(runs)}</w:p>"
408
-
409
-
410
- def _heading(text, level=1):
411
- sz = {1: "32", 2: "26", 3: "22"}.get(level, "24")
412
- return (f'<w:p><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr>'
413
- f'<w:r><w:rPr><w:b/><w:sz w:val="{sz}"/></w:rPr>'
414
- f'<w:t xml:space="preserve">{_dxe(text)}</w:t></w:r></w:p>')
415
-
416
-
417
- def _cell(content_para, *, fill=None, width=None):
418
- tcpr = []
419
- if width:
420
- tcpr.append(f'<w:tcW w:w="{width}" w:type="dxa"/>')
421
- if fill:
422
- tcpr.append(f'<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>')
423
- tcpr = f"<w:tcPr>{''.join(tcpr)}</w:tcPr>" if tcpr else ""
424
- return f"<w:tc>{tcpr}{content_para}</w:tc>"
425
-
426
-
427
- def _table(grid_widths, header_cells, body_rows):
428
- borders = ("<w:tblBorders>"
429
- + "".join(f'<w:{s} w:val="single" w:sz="4" w:space="0" w:color="D9D9D9"/>'
430
- for s in ("top", "left", "bottom", "right", "insideH", "insideV"))
431
- + "</w:tblBorders>")
432
- grid = "<w:tblGrid>" + "".join(f'<w:gridCol w:w="{w}"/>' for w in grid_widths) + "</w:tblGrid>"
433
- tblpr = ('<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="5000" w:type="pct"/>'
434
- '<w:tblLayout w:type="autofit"/>' + borders + "</w:tblPr>")
435
- hdr = "<w:tr>" + "".join(header_cells) + "</w:tr>"
436
- body = "".join("<w:tr>" + "".join(r) + "</w:tr>" for r in body_rows)
437
- return f"<w:tbl>{tblpr}{grid}{hdr}{body}</w:tbl>"
438
-
439
-
440
- def _image_para(rid, cx, cy, *, name="screenshot.png", uid=1):
441
- return (
442
- '<w:p><w:pPr><w:spacing w:after="120"/></w:pPr><w:r><w:drawing>'
443
- f'<wp:inline distT="0" distB="0" distL="0" distR="0">'
444
- f'<wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{uid}" name="{_dxe(name)}"/>'
445
- '<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
446
- '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
447
- '<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
448
- f'<pic:nvPicPr><pic:cNvPr id="{uid}" name="{_dxe(name)}"/><pic:cNvPicPr/></pic:nvPicPr>'
449
- f'<pic:blipFill><a:blip r:embed="{rid}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
450
- f'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>'
451
- '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
452
- '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>'
453
- )
527
+ def local_file(doc, name):
528
+ if not name:
529
+ return None
530
+ if os.path.isabs(name):
531
+ return name if os.path.isfile(name) else None
532
+ for base in (doc.base, doc.out):
533
+ candidate = os.path.join(base, name)
534
+ if os.path.isfile(candidate):
535
+ return candidate
536
+ return None
454
537
 
455
538
 
456
- def _docx_document_xml(m, t, *, has_image, img_cx, img_cy, has_overlay=False, ov_cx=0, ov_cy=0,
457
- keyshots=None):
458
- keyshots = keyshots or {} # {row number: (rid, cx, cy)} - per-key screenpieces
459
- rows = m["rows"]
460
- body = [_heading(f"{t['title']} - {m['screen']}", 1),
461
- _para([_run(t["intro"], color="555555")]),
462
- _para([_run(f"{t['platforms']}: ", bold=True), _run(", ".join(m.get("platforms", []))),
463
- _run(" · Figma: ", bold=True), _run(", ".join(m.get("figmaNodes", [])))])]
464
- # legend + counts
465
- leg = []
466
- for k in VCOLOUR:
467
- leg.append(_run(f"■ ", color=VCOLOUR.get(k, "808080")))
468
- leg.append(_run(f"{t['verdict'][k]} - {t['legend'][k]} "))
469
- body.append(_para(leg))
470
- body.append(_para([_run(counts_line(t, rows), bold=True)]))
471
-
472
- # section Screen
473
- body.append(_heading(t["screen"], 2))
474
- if has_image:
475
- body.append(_image_para("rId100", img_cx, img_cy))
476
- else:
477
- body.append(_para([_run("[screenshot]", color="999999")]))
478
- if has_overlay:
479
- body.append(_heading(t["keymap"], 2))
480
- body.append(_image_para("rId101", ov_cx, ov_cy, name="overlay.png", uid=2))
481
-
482
- # section Summary table
483
- body.append(_heading(t["summary"], 2))
484
- cols = t["cols"]
485
- grid = [600, 2400, 2300, 2800, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1400, 2400]
486
- head_cells = [_cell(_para([_run(c, bold=True)]), fill=HEAD_FILL) for c in cols]
487
- body_rows = []
488
- for i, r in enumerate(rows, 1):
489
- nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
490
- vd = r.get("verdict", "")
491
- if i in keyshots:
492
- rid, kcx, kcy = keyshots[i]
493
- ks_cell = _cell(_image_para(rid, kcx, kcy, name=f"keyshot{i}.png", uid=100 + i))
494
- else:
495
- ks_cell = _cell(_para("-"))
496
- cells = [_cell(_para(str(i))),
497
- _cell(_para(dash(r.get("element")))),
498
- ks_cell,
499
- _cell(_para([_run(r.get("newKey"))])),
500
- _cell(_para([_run(legdisp(r.get("legacyKeyIOS")))])),
501
- _cell(_para([_run(legdisp(r.get("legacyKeyAndroid")))])),
502
- _cell(_para([_run(legdisp(r.get("legacyKeyWeb")))]))]
503
- cells += [_cell(_para(dash(old.get(l)))) for l in SUMMARY_LANGS]
504
- cells += [_cell(_para(dash(nw.get(l)))) for l in SUMMARY_LANGS]
505
- cells.append(_cell(_para([_run(cms_disp(r, "tr"))]), fill="FCE4F1" if row_drift(r) else None))
506
- cells.append(_cell(_para([_run(cms_disp(r, "en"))])))
507
- cells.append(_cell(_para([_run(vtext(t, vd), bold=True)]), fill=DV_FILL.get(vd)))
508
- cells.append(_cell(_para(dash(r.get("note")))))
509
- body_rows.append(cells)
510
- body.append(_table(grid, head_cells, body_rows))
511
-
512
- # section Details - per element, all 8 languages
513
- body.append(_heading(t["details"], 2))
514
- for i, r in enumerate(rows, 1):
515
- nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
516
- vd = r.get("verdict", "")
517
- body.append(_heading(f"{i}. {r.get('element')} - {vtext(t, vd)}", 3))
518
- body.append(_para([_run(f"{cols[3]} "), _run(r.get("newKey")),
519
- _run(f" · {cols[4]} "), _run(legdisp(r.get("legacyKeyIOS"))),
520
- _run(f" · {cols[5]} "), _run(legdisp(r.get("legacyKeyAndroid"))),
521
- _run(f" · {cols[6]} "), _run(legdisp(r.get("legacyKeyWeb")))]))
522
- if r.get("note"):
523
- body.append(_para([_run(r.get("note"), color="777777")]))
524
- dh = [_cell(_para([_run(c, bold=True)]), fill=HEAD_FILL) for c in t["dcols"]]
525
- drows = []
526
- for l in DETAIL_LANGS:
527
- rtl = l == "ar"
528
- drows.append([_cell(_para([_run(l, bold=True)])),
529
- _cell(_para([_run(dash(old.get(l)), rtl=rtl)], rtl=rtl)),
530
- _cell(_para([_run(dash(nw.get(l)), rtl=rtl)], rtl=rtl)),
531
- _cell(_para([_run(dash(cms.get(l)), rtl=rtl)], rtl=rtl))])
532
- body.append(_table([800, 4200, 4200, 4200], dh, drows))
533
-
534
- sect = ('<w:sectPr><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>'
535
- '<w:pgMar w:top="720" w:right="720" w:bottom="720" w:left="720" '
536
- 'w:header="0" w:footer="0" w:gutter="0"/></w:sectPr>')
537
- return (
538
- '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
539
- '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
540
- 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
541
- 'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">'
542
- f'<w:body>{"".join(body)}{sect}</w:body></w:document>'
539
+ def table_row(cells):
540
+ return "<w:tr>" + "".join(cells) + "</w:tr>"
541
+
542
+
543
+ def heading(text):
544
+ return para(text, bold=True, size=28)
545
+
546
+
547
+ def summary_table(doc, dx, t):
548
+ lines = [table_row(tcell(c, fill="F5F6F8") for c in t["columns"])]
549
+ for i, row in enumerate(doc.rows, 1):
550
+ cells = []
551
+ for col, value in enumerate(doc.summary_values(i, row)):
552
+ if col == 2:
553
+ shot_file = doc.shots.get(i)
554
+ drawing = dx.image(shot_file, f"rId{200 + i}", 1_900_000, 2_600_000, 200 + i) if shot_file else ""
555
+ cells.append(tcell(f"<w:p>{drawing}</w:p>" if drawing else para(DASH), raw=True))
556
+ elif col == 13:
557
+ cells.append(tcell(doc.verdict(row), fill=look(row).shade))
558
+ elif col == 11:
559
+ tr_drift = drifts((row.get("cms") or {}).get("tr"), (row.get("new") or {}).get("tr"))
560
+ cells.append(tcell(value, fill="FCE4F1" if tr_drift else None))
561
+ else:
562
+ cells.append(tcell(value))
563
+ lines.append(table_row(cells))
564
+ return table(lines)
565
+
566
+
567
+ def detail_blocks(doc, t):
568
+ blocks = []
569
+ for i, row in enumerate(doc.rows, 1):
570
+ blocks.append(para(f"{i}. {row.get('element') or ''} - {doc.verdict(row)}", bold=True))
571
+ blocks.append(para(f"{t['key']}: {row.get('newKey') or ''}"))
572
+ lines = [table_row(tcell(c, fill="F5F6F8") for c in t["detail_columns"])]
573
+ for lang in doc.detail_langs(row):
574
+ lines.append(table_row(tcell(v, rtl=lang == "ar") for v in (lang, *lang_values(row, lang))))
575
+ blocks.append(table(lines))
576
+ return blocks
577
+
578
+
579
+ def page_setup():
580
+ margins = dict.fromkeys(("top", "right", "bottom", "left"), 720)
581
+ return w(
582
+ "sectPr",
583
+ w("pgSz", w=16838, h=11906, orient="landscape"),
584
+ w("pgMar", **margins, header=360, footer=360, gutter=0),
543
585
  )
544
586
 
545
587
 
546
- def _emu_fit(path, max_w, max_h):
547
- """Scaled (cx, cy) EMU for a PNG to fit a box, or a safe default if dims unknown."""
548
- size = _png_size(path)
549
- if size:
550
- w, h = size
551
- nat_w, nat_h = w * EMU_PER_PX, h * EMU_PER_PX
552
- scale = min(max_w / nat_w, max_h / nat_h, 1.0)
553
- return int(nat_w * scale), int(nat_h * scale)
554
- return max_w, max_w
555
-
556
-
557
- def write_docx(m, t, out_path, screenshot_path=None, overlay_path=None, keyshot_paths=None):
558
- """Write a self-contained .docx. Paths are absolute file paths or None;
559
- keyshot_paths is {row number: absolute PNG path} (per-key screenpieces)."""
560
- import zipfile
561
-
562
- img_bytes = None
563
- img_cx = img_cy = 0
564
- if screenshot_path and os.path.exists(screenshot_path):
565
- with open(screenshot_path, "rb") as fh:
566
- img_bytes = fh.read()
567
- img_cx, img_cy = _emu_fit(screenshot_path, DOCX_IMG_MAX_W_EMU, DOCX_IMG_MAX_H_EMU)
568
- has_image = img_bytes is not None
569
-
570
- # the overlay (key-map) is wide → allow the full landscape width
571
- ov_bytes = None
572
- ov_cx = ov_cy = 0
573
- if overlay_path and os.path.exists(overlay_path):
574
- with open(overlay_path, "rb") as fh:
575
- ov_bytes = fh.read()
576
- ov_cx, ov_cy = _emu_fit(overlay_path, 9_000_000, 5_500_000)
577
- has_overlay = ov_bytes is not None
578
-
579
- # per-key screenpieces → one image part + rel each (rId200+row); thumbnail-fit
580
- ks_meta, ks_bytes = {}, {}
581
- for row, path in (keyshot_paths or {}).items():
582
- if not (path and os.path.exists(path)):
588
+ def docx_parts(document, media):
589
+ image_rels = "".join(
590
+ node("Relationship", [("Id", rid), ("Type", NS_DOC_REL + "/image"), ("Target", f"media/{name}")])
591
+ for rid, _, name in media
592
+ )
593
+ defaults = [("rels", MIME_OOXML + "package.relationships+xml"), ("xml", "application/xml"), ("png", "image/png")]
594
+ types = "".join(node("Default", [("Extension", ext), ("ContentType", kind)]) for ext, kind in defaults)
595
+ types += node("Override", [
596
+ ("PartName", "/word/document.xml"),
597
+ ("ContentType", MIME_OOXML + "officedocument.wordprocessingml.document.main+xml"),
598
+ ])
599
+ root_rel = node("Relationship", [
600
+ ("Id", "rId1"), ("Type", NS_DOC_REL + "/officeDocument"), ("Target", "word/document.xml"),
601
+ ])
602
+ rels_ns = f'<Relationships xmlns="{OOXML}package/2006/relationships">'
603
+ return [
604
+ ("[Content_Types].xml", f'{XML_DECL}<Types xmlns="{OOXML}package/2006/content-types">{types}</Types>'),
605
+ ("_rels/.rels", f"{XML_DECL}{rels_ns}{root_rel}</Relationships>"),
606
+ ("word/_rels/document.xml.rels", f"{XML_DECL}{rels_ns}{image_rels}</Relationships>"),
607
+ ("word/document.xml", document),
608
+ ]
609
+
610
+
611
+ def build_docx(doc, path):
612
+ t, m = doc.t, doc.m
613
+ dx = Docx()
614
+ body = [
615
+ para(f"{t['title']}: {m.get('screen')}", bold=True, size=32),
616
+ para(t["intro"]),
617
+ para(f"{t['platforms']}: {doc.listed('platforms')} | {t['figma']}: {doc.listed('figmaNodes')}"),
618
+ ]
619
+ body += [para(f"{t['verdict'][v]}: {t['legend'][v]}") for v in VERDICTS]
620
+ body += [para(doc.count_line()), para(doc.cms_line()), para(f"{WARN} {t['drift_note']}")]
621
+ body.append(heading(t["screen"]))
622
+ screenshot = local_file(doc, m.get("screenshot"))
623
+ if screenshot:
624
+ body.append(w("p", dx.image(screenshot, "rId100", 5_000_000, 5_500_000, 100)))
625
+ else:
626
+ body.append(para(t["no_screenshot"]))
627
+ overlay = local_file(doc, m.get("overlay"))
628
+ if overlay:
629
+ body += [heading(t["keymap"]), w("p", dx.image(overlay, "rId101", 9_000_000, 5_500_000, 101))]
630
+ body += [heading(t["summary"]), summary_table(doc, dx, t), heading(t["details"])]
631
+ body += detail_blocks(doc, t)
632
+ namespaces = [
633
+ ("xmlns:w", OOXML + "wordprocessingml/2006/main"),
634
+ ("xmlns:r", NS_DOC_REL),
635
+ ("xmlns:wp", NS_DRAWING + "wordprocessingDrawing"),
636
+ ]
637
+ document = XML_DECL + node("w:document", namespaces, w("body", *body, page_setup()))
638
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
639
+ for member, text in docx_parts(document, dx.media):
640
+ archive.writestr(member, text)
641
+ for _, source, media_name in dx.media:
642
+ archive.write(source, f"word/media/{media_name}")
643
+
644
+
645
+ def mac_app(bundle, binary=None):
646
+ return f"/Applications/{bundle}.app/Contents/MacOS/{binary or bundle}"
647
+
648
+
649
+ BROWSER_APPS = [mac_app("Google Chrome"), mac_app("Chromium"), mac_app("Microsoft Edge")]
650
+ BROWSER_COMMANDS = (
651
+ [f"google-chrome{tail}" for tail in ("", "-stable")]
652
+ + [f"chromium{tail}" for tail in ("", "-browser")]
653
+ + ["chrome"]
654
+ )
655
+
656
+
657
+ def chrome_candidates():
658
+ listed = [os.environ.get("CHROME_BIN")] + BROWSER_APPS
659
+ found = [path for path in listed if path and os.path.isfile(path)]
660
+ found += [hit for hit in map(shutil.which, BROWSER_COMMANDS) if hit]
661
+ return found
662
+
663
+
664
+ def pdf_libreoffice(docx_path, out_dir):
665
+ binaries = [shutil.which("soffice"), shutil.which("libreoffice"), mac_app("LibreOffice", "soffice")]
666
+ expected = os.path.splitext(docx_path)[0] + ".pdf"
667
+ for binary in binaries:
668
+ if not (binary and os.path.isfile(binary)):
583
669
  continue
584
- with open(path, "rb") as fh:
585
- ks_bytes[row] = fh.read()
586
- cx, cy = _emu_fit(path, 1_900_000, 2_600_000)
587
- ks_meta[row] = (f"rId{200 + row}", cx, cy)
588
-
589
- content_types = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
590
- '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
591
- '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
592
- '<Default Extension="xml" ContentType="application/xml"/>'
593
- '<Default Extension="png" ContentType="image/png"/>'
594
- '<Override PartName="/word/document.xml" '
595
- 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
596
- '</Types>')
597
- root_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
598
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
599
- '<Relationship Id="rId1" '
600
- 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
601
- 'Target="word/document.xml"/></Relationships>')
602
- doc_rels_items = ""
603
- if has_image:
604
- doc_rels_items += ('<Relationship Id="rId100" '
605
- 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
606
- 'Target="media/screenshot.png"/>')
607
- if has_overlay:
608
- doc_rels_items += ('<Relationship Id="rId101" '
609
- 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
610
- 'Target="media/overlay.png"/>')
611
- for row, (rid, _, _) in sorted(ks_meta.items()):
612
- doc_rels_items += (f'<Relationship Id="{rid}" '
613
- 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
614
- f'Target="media/keyshot{row}.png"/>')
615
- doc_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
616
- '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
617
- f'{doc_rels_items}</Relationships>')
618
- document = _docx_document_xml(m, t, has_image=has_image, img_cx=img_cx, img_cy=img_cy,
619
- has_overlay=has_overlay, ov_cx=ov_cx, ov_cy=ov_cy,
620
- keyshots=ks_meta)
621
-
622
- with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
623
- z.writestr("[Content_Types].xml", content_types)
624
- z.writestr("_rels/.rels", root_rels)
625
- z.writestr("word/document.xml", document)
626
- z.writestr("word/_rels/document.xml.rels", doc_rels)
627
- if has_image:
628
- z.writestr("word/media/screenshot.png", img_bytes)
629
- if has_overlay:
630
- z.writestr("word/media/overlay.png", ov_bytes)
631
- for row, data in sorted(ks_bytes.items()):
632
- z.writestr(f"word/media/keyshot{row}.png", data)
633
-
634
-
635
- # ---- PDF (convert via whatever renderer the machine has - no pip, no network) ----
636
- # Multilingual (Arabic + Cyrillic + Latin) PDF needs real font shaping, so we don't
637
- # hand-roll it: we drive an installed converter. Order = best table fidelity first.
638
-
639
- def _chrome_candidates():
640
- env = os.environ.get("CHROME_BIN")
641
- cands = [env] if env else []
642
- cands += ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
643
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
644
- "/Applications/Chromium.app/Contents/MacOS/Chromium"]
645
- return cands
646
-
647
-
648
- def _which(cmd):
649
- import shutil
650
- if os.path.isabs(cmd):
651
- return cmd if os.path.exists(cmd) else None
652
- return shutil.which(cmd)
653
-
654
-
655
- def _run_tool(cmd, timeout):
656
- """Run a converter, swallowing TimeoutExpired/OSError. Some headless browsers write
657
- the PDF and then hang on shutdown, so we never trust the return code alone - the
658
- caller checks the output file. Returns the CompletedProcess, or None on error."""
659
- import subprocess
660
- try:
661
- return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
662
- except subprocess.TimeoutExpired:
663
- return None # process already killed by run(); output may still exist
664
- except OSError:
665
- return None
670
+ try:
671
+ subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", out_dir, docx_path],
672
+ capture_output=True, timeout=180)
673
+ except (OSError, subprocess.SubprocessError):
674
+ continue
675
+ if os.path.isfile(expected) and os.path.getsize(expected) > 0:
676
+ return expected
677
+ return None
666
678
 
667
679
 
668
- def _nonempty(path):
669
- return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0
680
+ def settle(proc, path, limit):
681
+ deadline = time.time() + limit
682
+ previous = -1
683
+ while time.time() < deadline and proc.poll() is None:
684
+ current = os.path.getsize(path) if os.path.exists(path) else -1
685
+ if current > 0 and current == previous:
686
+ break
687
+ previous = current
688
+ time.sleep(1)
689
+ if proc.poll() is None:
690
+ proc.kill()
691
+ proc.wait()
670
692
 
671
693
 
672
- def _run_until_file(cmd, out_path, timeout):
673
- """Launch cmd and return as soon as out_path is written and its size stops growing -
674
- then kill the process tree. For headless browsers that print the PDF in ~1s but hang
675
- on shutdown, this avoids blocking on the full timeout. Returns True if out_path exists."""
676
- import signal
677
- import subprocess
678
- import time
679
- if os.path.exists(out_path):
680
- os.remove(out_path)
681
- try:
682
- proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
683
- start_new_session=True)
684
- except OSError:
685
- return False
686
- deadline = time.time() + timeout
687
- last_size, stable = -1, 0
688
- try:
689
- while time.time() < deadline:
690
- if proc.poll() is not None: # exited on its own
691
- break
692
- if os.path.exists(out_path):
693
- size = os.path.getsize(out_path)
694
- stable = stable + 1 if size == last_size and size > 0 else 0
695
- last_size = size
696
- if stable >= 3: # ~0.45s unchanged → fully written
697
- break
698
- time.sleep(0.15)
699
- finally:
700
- if proc.poll() is None:
701
- try:
702
- os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
703
- except (ProcessLookupError, PermissionError):
704
- proc.terminate()
705
- try:
706
- proc.wait(timeout=5)
707
- except subprocess.TimeoutExpired:
708
- try:
709
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
710
- except (ProcessLookupError, PermissionError):
711
- proc.kill()
712
- return _nonempty(out_path)
713
-
714
-
715
- def to_pdf(out_pdf, *, docx_path=None, html_path=None):
716
- """Convert to PDF using the first available renderer. Returns the tool name used,
717
- or None if no converter is installed (caller prints a skip note)."""
718
- import tempfile
719
-
720
- # 1) LibreOffice - best fidelity for the table-heavy docx.
721
- soffice = next((_which(c) for c in
722
- ("soffice", "libreoffice",
723
- "/Applications/LibreOffice.app/Contents/MacOS/soffice") if _which(c)), None)
724
- if soffice and _nonempty(docx_path):
725
- with tempfile.TemporaryDirectory() as td:
726
- _run_tool([soffice, "--headless", "--convert-to", "pdf", "--outdir", td, docx_path], 180)
727
- produced = os.path.join(td, os.path.splitext(os.path.basename(docx_path))[0] + ".pdf")
728
- if _nonempty(produced):
729
- with open(produced, "rb") as src, open(out_pdf, "wb") as dst:
730
- dst.write(src.read())
731
- return "libreoffice"
732
-
733
- # 2) Chrome/Chromium headless - html → pdf. Chrome often writes the PDF and then
734
- # hangs on exit; _run_tool absorbs the timeout and we judge by the file.
735
- chrome = next((_which(c) for c in _chrome_candidates() if _which(c)), None)
736
- if chrome and html_path:
737
- url = f"file://{os.path.abspath(html_path)}"
694
+ def pdf_chrome(preview_path, pdf_path):
695
+ page = "file://" + os.path.abspath(preview_path)
696
+ for chrome in chrome_candidates():
738
697
  for headless in ("--headless=new", "--headless"):
739
- with tempfile.TemporaryDirectory() as td:
740
- ok = _run_until_file([chrome, headless, "--disable-gpu", "--no-sandbox",
741
- f"--user-data-dir={td}", "--no-pdf-header-footer",
742
- "--allow-file-access-from-files",
743
- f"--print-to-pdf={out_pdf}", url], out_pdf, 90)
744
- if ok:
745
- return "chrome"
746
-
747
- # 3) wkhtmltopdf - html → pdf.
748
- wk = _which("wkhtmltopdf")
749
- if wk and html_path:
750
- _run_tool([wk, "--enable-local-file-access", "--quiet", html_path, out_pdf], 120)
751
- if _nonempty(out_pdf):
752
- return "wkhtmltopdf"
753
-
698
+ with tempfile.TemporaryDirectory() as profile:
699
+ if os.path.exists(pdf_path):
700
+ os.remove(pdf_path)
701
+ argv = [chrome, headless, "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
702
+ f"--print-to-pdf={pdf_path}", page]
703
+ try:
704
+ proc = subprocess.Popen(argv, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
705
+ except OSError:
706
+ break
707
+ settle(proc, pdf_path, 90)
708
+ if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0:
709
+ return pdf_path
754
710
  return None
755
711
 
756
712
 
757
- # ---- UPLOAD HOOK ----
758
- # Live upload now lives in scripts/publish-confluence.py (Server/DC Bearer, idempotent
759
- # update-in-place). --print-upload prints the ready command + the equivalent raw curl.
760
-
761
- def upload_curl(slug, m):
762
- screen = m["screen"]
763
- over = " --overlay {}.overlay.png".format(slug) if m.get("overlay") else ""
764
- ks = " --keyshots {}.keyshots.manifest.json".format(slug) if m.get("keyshots") else ""
765
- return (
766
- "# LIVE publish (idempotent update-in-place under the localizations parent):\n"
767
- f"python3 publish-confluence.py --xml {slug}.confluence.xml --screen {json.dumps(screen)} "
768
- f"--screenshot screenshot.png{over}{ks} --attach {slug}.localization.xlsx\n"
769
- "#\n"
770
- "# Equivalent by hand (curl, Server/DC Bearer PAT - NOT Cloud user:token).\n"
771
- "# $CONFLUENCE_BASE_URL / $CONFLUENCE_SPACE / $CONFLUENCE_PARENT are your wiki's target:\n"
772
- '# TOKEN=$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\n'
773
- '# printf \'Authorization: Bearer %s\\n\' "$TOKEN" | curl -sS -H @- -X POST '
774
- '"$CONFLUENCE_BASE_URL/rest/api/content" \\\n'
775
- '# -H "Content-Type: application/json" -d @- <<JSON\n'
776
- f'# {{"type":"page","title":{json.dumps(PAGE_TITLE_PREFIX + "-" + screen)},'
777
- '"space":{"key":"$CONFLUENCE_SPACE"},'
778
- '"ancestors":[{"id":"$CONFLUENCE_PARENT"}],'
779
- f'"body":{{"storage":{{"value":"<{slug}.confluence.xml>","representation":"storage"}}}}}}\n# JSON\n'
780
- "# (then attach screenshot.png + the overlay by filename).")
713
+ def pdf_wkhtml(preview_path, pdf_path):
714
+ binary = shutil.which("wkhtmltopdf")
715
+ if not binary:
716
+ return None
717
+ try:
718
+ subprocess.run([binary, "--enable-local-file-access", preview_path, pdf_path], capture_output=True, timeout=120)
719
+ except (OSError, subprocess.SubprocessError):
720
+ return None
721
+ return pdf_path if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0 else None
722
+
723
+
724
+ def print_upload(name, mapping, screen):
725
+ prefix = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
726
+ flags = [f"--xml {name}.confluence.xml", f'--screen "{screen}"']
727
+ flags += [f"--{field} {mapping[field]}" for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)]
728
+ flags.append(f"--attach {name}.localization.xlsx")
729
+ print(" \\\n ".join(["python3 scripts/publish-confluence.py"] + flags))
730
+ print()
731
+ print("# The same call by hand, for Server/DC with a Bearer PAT read from the keychain (argv never sees the token):")
732
+ print("# printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc; chmod 600 auth.curlrc")
733
+ print("# curl -sS -K auth.curlrc -X POST -H 'Content-Type: application/json' \"$CONFLUENCE_BASE_URL/rest/api/content\" \\")
734
+ print(f"# --data '{{\"type\":\"page\",\"title\":\"{prefix} - {screen}\",\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
735
+ "\"ancestors\":[{\"id\":\"'$CONFLUENCE_PARENT'\"}],\"body\":{\"storage\":{\"value\":\"...\",\"representation\":\"storage\"}}}'")
736
+ print("# rm auth.curlrc")
737
+
738
+
739
+ def read_mapping(source):
740
+ if source == "-":
741
+ return json.load(sys.stdin), os.getcwd()
742
+ with open(source, encoding="utf-8") as stream:
743
+ return json.load(stream), os.path.dirname(os.path.abspath(source))
744
+
745
+
746
+ def make_pdf(docx_path, out_dir, stem, keep_docx):
747
+ preview = os.path.join(out_dir, f"{stem}.preview.html")
748
+ target = os.path.join(out_dir, f"{stem}.pdf")
749
+ made = pdf_libreoffice(docx_path, out_dir) or pdf_chrome(preview, target) or pdf_wkhtml(preview, target)
750
+ if not made:
751
+ fallback = docx_path if os.path.exists(docx_path) else preview
752
+ print(f"# no PDF: LibreOffice, Chrome and wkhtmltopdf are all missing; hand out {fallback} instead", file=sys.stderr)
753
+ return
754
+ print(f"wrote {made}")
755
+ if not keep_docx and os.path.exists(docx_path):
756
+ os.remove(docx_path)
781
757
 
782
758
 
783
759
  def main():
784
- ap = argparse.ArgumentParser(description=__doc__)
785
- ap.add_argument("mapping", help="path to the mapping JSON (- for stdin)")
786
- ap.add_argument("--out", default=".")
787
- ap.add_argument("--slug")
788
- ap.add_argument("--ui-lang", choices=["tr", "en"], default="en", help="document chrome language (default en)")
789
- ap.add_argument("--docx", action="store_true", help="also write <slug>.docx (Word; stdlib, offline)")
790
- ap.add_argument("--pdf", action="store_true", help="also write <slug>.pdf (via an installed renderer)")
791
- ap.add_argument("--all", action="store_true", help="write every format (implies --docx --pdf)")
792
- ap.add_argument("--print-upload", action="store_true")
793
- args = ap.parse_args()
794
- want_docx = args.docx or args.all
795
- want_pdf = args.pdf or args.all
796
-
797
- src = sys.stdin if args.mapping == "-" else open(args.mapping, encoding="utf-8")
798
- m = json.load(src)
799
- if "rows" not in m or "screen" not in m:
800
- ap.error("mapping JSON needs at least 'screen' and 'rows' (see reference/format-and-output.md)")
801
- global LEGACY_KEY_PREFIX
802
- LEGACY_KEY_PREFIX = m.get("legacyKeyPrefix", "") or ""
803
- t = T[args.ui_lang]
804
- slug = args.slug or "".join(ch if ch.isalnum() else "-" for ch in m["screen"].lower()).strip("-")
805
- while "--" in slug:
806
- slug = slug.replace("--", "-")
807
- os.makedirs(args.out, exist_ok=True)
808
- ks_bases = ([os.path.dirname(os.path.abspath(args.mapping))] if args.mapping != "-" else [])
809
- ks_bases += [os.path.abspath(args.out), os.getcwd()]
810
- ks_dir = load_keyshots(m, ks_bases) # fills m["_keyshots"] for the renderers
811
- files = {f"{slug}.confluence.xml": render_storage(m, t),
812
- f"{slug}.md": render_markdown(m, t),
813
- f"{slug}.preview.html": render_html(m, t)}
814
- for name, content in files.items():
815
- with open(os.path.join(args.out, name), "w", encoding="utf-8") as fh:
816
- fh.write(content)
817
- print("wrote", os.path.join(args.out, name))
818
-
819
- # Resolve a bare filename (screenshot / overlay) to an absolute path for embedding
820
- # (docx) / loading (pdf): look beside the mapping, then in --out, then cwd.
821
- def resolve_asset(name):
822
- if not name:
823
- return None
824
- bases = [os.path.dirname(os.path.abspath(args.mapping))] if args.mapping != "-" else []
825
- bases += [os.path.abspath(args.out), os.getcwd()]
826
- for b in bases:
827
- cand = name if os.path.isabs(name) else os.path.join(b, name)
828
- if os.path.exists(cand):
829
- return os.path.abspath(cand)
830
- return None
831
-
832
- shot_abs = resolve_asset(m.get("screenshot"))
833
- overlay_abs = resolve_asset(m.get("overlay"))
834
-
835
- docx_path = os.path.join(args.out, f"{slug}.docx")
836
- html_path = os.path.join(args.out, f"{slug}.preview.html")
837
- ks_abs = ({row: os.path.join(ks_dir, rel) for row, rel in keyshots_of(m).items()}
838
- if ks_dir else None)
839
- if want_docx or want_pdf:
840
- # PDF via LibreOffice needs the docx; build it whenever either format is requested.
841
- write_docx(m, t, docx_path, screenshot_path=shot_abs, overlay_path=overlay_abs,
842
- keyshot_paths=ks_abs)
843
- if want_docx:
844
- print("wrote", docx_path)
845
- if want_pdf:
846
- pdf_path = os.path.join(args.out, f"{slug}.pdf")
847
- tool = to_pdf(pdf_path, docx_path=docx_path, html_path=html_path)
848
- if tool:
849
- print(f"wrote {pdf_path} (via {tool})")
850
- # docx was only built as a means to the pdf and wasn't requested → clean it up
851
- if not want_docx and os.path.exists(docx_path):
852
- os.remove(docx_path)
853
- else:
854
- # leave the docx in place as the fallback we point the user at
855
- keep = f"{slug}.docx" if (want_docx or os.path.exists(docx_path)) else f"{slug}.preview.html"
856
- print(f"skipped {slug}.pdf - no PDF renderer found "
857
- f"(install LibreOffice/Chrome/wkhtmltopdf); share {keep} or {slug}.preview.html instead",
858
- file=sys.stderr)
859
-
860
- if args.print_upload:
861
- print("\n" + upload_curl(slug, m))
760
+ cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
761
+ cli.add_argument("mapping", help="reuse-map JSON file; pass - to read it from stdin")
762
+ cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
763
+ cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
764
+ cli.add_argument("--ui-lang", choices=["en", "tr"], default="en", help="language used for headings, the legend and labels")
765
+ cli.add_argument("--docx", action="store_true", help="add a Word document to the outputs")
766
+ cli.add_argument("--pdf", action="store_true", help="add a PDF to the outputs")
767
+ cli.add_argument("--all", action="store_true", help="shorthand for --docx plus --pdf")
768
+ cli.add_argument("--print-upload", action="store_true", help="show the publish command and a hand-rolled curl version of it")
769
+ opts = cli.parse_args()
770
+
771
+ mapping, mapping_dir = read_mapping(opts.mapping)
772
+ if not isinstance(mapping, dict) or "screen" not in mapping or "rows" not in mapping:
773
+ cli.error("mapping must be a JSON object with screen and rows")
774
+
775
+ stem = opts.slug or slug(mapping.get("screen"))
776
+ out_dir = os.path.abspath(opts.target_dir)
777
+ os.makedirs(out_dir, exist_ok=True)
778
+ shots = load_keyshots(mapping, mapping_dir, out_dir)
779
+ doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir)
780
+ doc.base = mapping_dir
781
+
782
+ renderers = [("md", doc.md), ("confluence.xml", doc.xml), ("preview.html", doc.preview)]
783
+ for suffix, render in renderers:
784
+ target = os.path.join(out_dir, f"{stem}.{suffix}")
785
+ with open(target, "w", encoding="utf-8") as sink:
786
+ sink.write(render())
787
+ print(f"wrote {target}")
788
+
789
+ keep_docx = opts.docx or opts.all
790
+ need_pdf = opts.pdf or opts.all
791
+ docx_path = os.path.join(out_dir, f"{stem}.docx")
792
+ if keep_docx or need_pdf:
793
+ build_docx(doc, docx_path)
794
+ if keep_docx:
795
+ print(f"wrote {docx_path}")
796
+ if need_pdf:
797
+ make_pdf(docx_path, out_dir, stem, keep_docx)
798
+
799
+ if opts.print_upload:
800
+ print_upload(stem, mapping, mapping.get("screen"))
862
801
 
863
802
 
864
803
  if __name__ == "__main__":