@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.2

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 (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  13. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  15. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  16. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  17. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  18. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  22. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  23. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  24. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/extract-conventions.sh +44 -15
  28. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  29. package/pipeline/lib/fetch-fortify.sh +23 -8
  30. package/pipeline/lib/figma-screenshot.sh +11 -1
  31. package/pipeline/lib/issue-fetcher.sh +76 -9
  32. package/pipeline/lib/md2confluence-v3.py +16 -2
  33. package/pipeline/lib/plan-todos.sh +5 -2
  34. package/pipeline/lib/post-pr-review.sh +8 -6
  35. package/pipeline/lib/shadow-git.sh +50 -9
  36. package/pipeline/lib/submodule-detector.sh +8 -1
  37. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  38. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  39. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  40. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  41. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  42. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  43. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  45. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  46. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  47. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  48. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  49. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  51. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  52. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  53. package/pipeline/multi-agent-refs/phases.md +6 -0
  54. package/pipeline/multi-agent-refs/rules.md +2 -0
  55. package/pipeline/schemas/prefs.schema.json +1 -1
  56. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  57. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  58. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  59. package/pipeline/scripts/diff-explain.mjs +41 -3
  60. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  61. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  62. package/pipeline/scripts/learning-curve.mjs +8 -2
  63. package/pipeline/scripts/output-quality-check.sh +15 -4
  64. package/pipeline/scripts/phase-tracker.sh +21 -8
  65. package/pipeline/scripts/pre-commit-check.sh +69 -22
  66. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  67. package/pipeline/scripts/render-cost-summary.sh +42 -22
  68. package/pipeline/scripts/render-work-summary.sh +47 -13
  69. package/pipeline/scripts/review-scope.mjs +1 -1
  70. package/pipeline/scripts/run-aggregator.mjs +38 -14
  71. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  72. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  73. package/pipeline/scripts/uninstall.mjs +39 -4
  74. package/pipeline/scripts/update-issue-progress.sh +12 -16
  75. package/pipeline/scripts/worktree-finalize.sh +23 -2
  76. package/pipeline/skills/.skills-index.json +11 -2
  77. package/pipeline/skills/shared/README.md +1 -1
  78. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  79. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  80. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  81. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  82. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  83. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  84. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  85. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  86. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  87. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  89. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  90. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  91. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  92. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  93. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  94. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  95. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  96. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  97. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  98. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  99. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  100. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  101. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  102. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  103. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  104. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  105. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  106. package/pipeline/skills/skills-index.md +3 -2
@@ -0,0 +1,865 @@
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.
32
+ """
33
+ import argparse
34
+ import html
35
+ import json
36
+ import os
37
+ import sys
38
+
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
+
45
+ # UI chrome strings - English default, Turkish alternate (--ui-lang).
46
+ 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
+ "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"],
82
+ },
83
+ }
84
+
85
+
86
+ def e(s):
87
+ return html.escape("" if s in (None, "") else str(s), quote=True)
88
+
89
+
90
+ def dash(s):
91
+ return s if s not in (None, "") else "-"
92
+
93
+
94
+ def md(s):
95
+ return ("" if s in (None, "") else str(s)).replace("|", "\\|").replace("\n", " ")
96
+
97
+
98
+ def vtext(t, vd):
99
+ return f'{VEMOJI.get(vd, "")} {t["verdict"].get(vd, vd)}'
100
+
101
+
102
+ LEGACY_KEY_PREFIX = ""
103
+
104
+
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
113
+
114
+
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]}'
119
+
120
+
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.
125
+
126
+ def cms_of(r):
127
+ return r.get("cms") or {}
128
+
129
+
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
+
141
+
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
+
146
+
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
+
152
+
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
+
161
+
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
+
168
+
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
+
175
+ def keyshots_of(m):
176
+ """{row number: relative file} for the embeddable (.png) shots, else {}."""
177
+ return m.get("_keyshots") or {}
178
+
179
+
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
198
+
199
+
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
+ """
357
+
358
+
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
+
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"
369
+
370
+
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])
382
+
383
+
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)
387
+
388
+
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>'
399
+
400
+
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
+ )
454
+
455
+
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>'
543
+ )
544
+
545
+
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)):
583
+ 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
666
+
667
+
668
+ def _nonempty(path):
669
+ return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0
670
+
671
+
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)}"
738
+ 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
+
754
+ return None
755
+
756
+
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
+ '# curl -sS -H "Authorization: Bearer $TOKEN" -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).")
781
+
782
+
783
+ 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))
862
+
863
+
864
+ if __name__ == "__main__":
865
+ main()