@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.
- package/CHANGELOG.md +42 -0
- package/README.md +10 -6
- package/README.tr.md +143 -0
- package/docs/architecture.md +23 -8
- package/docs/ecosystem.md +237 -0
- package/install/_plugin-skills.mjs +16 -2
- package/install/codex.mjs +9 -4
- package/install/templates/copilot-instructions.md +12 -9
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/extract-conventions.sh +44 -15
- package/pipeline/lib/fetch-figma-annotations.sh +8 -1
- package/pipeline/lib/fetch-fortify.sh +23 -8
- package/pipeline/lib/figma-screenshot.sh +11 -1
- package/pipeline/lib/issue-fetcher.sh +76 -9
- package/pipeline/lib/md2confluence-v3.py +16 -2
- package/pipeline/lib/plan-todos.sh +5 -2
- package/pipeline/lib/post-pr-review.sh +8 -6
- package/pipeline/lib/shadow-git.sh +50 -9
- package/pipeline/lib/submodule-detector.sh +8 -1
- package/pipeline/multi-agent-refs/_input-parser.md +1 -1
- package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
- package/pipeline/multi-agent-refs/channels/jira.md +13 -2
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +20 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +2 -2
- package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
- package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
- package/pipeline/multi-agent-refs/phases/modes.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
- package/pipeline/multi-agent-refs/phases.md +6 -0
- package/pipeline/multi-agent-refs/rules.md +2 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/audit-log-rotate.sh +10 -0
- package/pipeline/scripts/build-stack-plugins.mjs +8 -1
- package/pipeline/scripts/check-derived-drift.mjs +13 -1
- package/pipeline/scripts/diff-explain.mjs +41 -3
- package/pipeline/scripts/diff-risk-score.mjs +72 -8
- package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
- package/pipeline/scripts/learning-curve.mjs +8 -2
- package/pipeline/scripts/output-quality-check.sh +15 -4
- package/pipeline/scripts/phase-tracker.sh +21 -8
- package/pipeline/scripts/pre-commit-check.sh +69 -22
- package/pipeline/scripts/render-agent-log-cost.sh +8 -3
- package/pipeline/scripts/render-cost-summary.sh +42 -22
- package/pipeline/scripts/render-work-summary.sh +47 -13
- package/pipeline/scripts/review-scope.mjs +1 -1
- package/pipeline/scripts/run-aggregator.mjs +38 -14
- package/pipeline/scripts/smoke-schema-validation.sh +5 -1
- package/pipeline/scripts/test-gap-scan.mjs +45 -6
- package/pipeline/scripts/uninstall.mjs +39 -4
- package/pipeline/scripts/update-issue-progress.sh +12 -16
- package/pipeline/scripts/worktree-finalize.sh +23 -2
- package/pipeline/skills/.skills-index.json +11 -2
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
- 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 = " ".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"])} '
|
|
218
|
+
f'<strong>{e(t["platforms"])}:</strong> {e(", ".join(m.get("platforms", [])))} '
|
|
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} | '
|
|
221
|
+
f'{e(counts_line(t, rows))} | {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"})", ""] 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"" 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()
|