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