blun-king-cli 9.1.5 → 9.1.8
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/bin/launcher-mode.js +1 -0
- package/bin/launcher-runtime.js +60 -29
- package/bin/standard-tools-bootstrap.js +218 -0
- package/bin/update-notice.js +37 -15
- package/blun.mjs +4 -2
- package/package.json +50 -47
- package/standard-skills/blun-app-design-system/SKILL.md +30 -0
- package/standard-skills/blun-app-design-system/provenance.json +17 -0
- package/standard-skills/blun-app-design-system/references/tokens.md +48 -0
- package/standard-skills/blun-app-design-system/scripts/check-blun-design.mjs +49 -0
- package/standard-tools/language-guard/blun_language_guard.py +686 -0
- package/standard-tools/language-guard/check_diacritics.py +353 -0
- package/standard-tools/language-guard/guard_service_client.py +82 -0
- package/standard-tools/language-guard/language_quality.py +172 -0
- package/standard-tools/language-guard/translation_guard.py +916 -0
- package/standard-tools/manifest.json +76 -0
- package/skills/design-taste-frontend/SKILL.md +0 -1206
- package/skills/full-output-enforcement/SKILL.md +0 -49
- package/skills/high-end-visual-design/SKILL.md +0 -98
- package/skills/image-to-code/SKILL.md +0 -1228
- package/skills/industrial-brutalist-ui/SKILL.md +0 -92
- package/skills/minimalist-ui/SKILL.md +0 -85
- package/skills/motion-design-taste/SKILL.md +0 -74
- package/skills/premortem/SKILL.md +0 -148
- package/skills/redesign-existing-projects/SKILL.md +0 -178
- package/skills/screenshot-lesen/SKILL.md +0 -52
- package/skills/stitch-design-taste/DESIGN.md +0 -121
- package/skills/stitch-design-taste/SKILL.md +0 -184
- package/skills/web-lesen/SKILL.md +0 -58
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check that a translation preserves structure and protected tokens."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import unicodedata
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TOKEN_PATTERNS = (
|
|
19
|
+
("URL", re.compile(r"(?:https?://|mailto:)[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+")),
|
|
20
|
+
("email", re.compile(r"(?<![\w.+-])[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}")),
|
|
21
|
+
("Markdown destination", re.compile(r"(?<=\]\()[^\s)]+")),
|
|
22
|
+
("template", re.compile(r"\{\{[^{}]+\}\}|\$\{[^{}]+\}|%\{[^{}]+\}")),
|
|
23
|
+
("printf", re.compile(r"%(?:\d+\$)?[-+#0 ']*(?:\d+|\*)?(?:\.\d+|\.\*)?[hlLjzt]*[diouxXfFeEgGaAcspn%@]")),
|
|
24
|
+
("XML entity", re.compile(r"&(?:[A-Za-z][A-Za-z0-9]+|#\d+|#x[0-9A-Fa-f]+);")),
|
|
25
|
+
("HTML tag", re.compile(r"</?[A-Za-z][^<>]*?>")),
|
|
26
|
+
("escape", re.compile(r"\\(?:[nrtbfv\\\"']|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|x[0-9A-Fa-f]{2})")),
|
|
27
|
+
("inline code", re.compile(r"(?<!`)`[^`\n]+`(?!`)")),
|
|
28
|
+
("ICU argument", re.compile(r"\{\s*([A-Za-z_][\w.-]*)\s*(?=[,}])")),
|
|
29
|
+
("simple placeholder", re.compile(r"\{[A-Za-z_][\w.-]*\}")),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
FENCED_CODE = re.compile(r"```[^\n]*\n.*?```|~~~[^\n]*\n.*?~~~", re.DOTALL)
|
|
33
|
+
ICU_HEADER = re.compile(
|
|
34
|
+
r"\{\s*([A-Za-z_][\w.-]*)\s*,\s*(plural|selectordinal|select)\s*,",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
ICU_SELECTOR_AT_POSITION = re.compile(r"\s*([=\w.-]+)\s*(?=\{)")
|
|
38
|
+
TRANSLATABLE_HTML_ATTRIBUTES = {
|
|
39
|
+
"alt",
|
|
40
|
+
"aria-description",
|
|
41
|
+
"aria-label",
|
|
42
|
+
"placeholder",
|
|
43
|
+
"title",
|
|
44
|
+
}
|
|
45
|
+
HTML_CODE_ELEMENTS = {"script", "style"}
|
|
46
|
+
JSONLD_LINGUISTIC_KEYS = {
|
|
47
|
+
"alternativeHeadline", "articleBody", "caption", "description", "headline",
|
|
48
|
+
"keywords", "name", "text",
|
|
49
|
+
}
|
|
50
|
+
TRANSLATABLE_META_NAMES = {
|
|
51
|
+
"application-name",
|
|
52
|
+
"description",
|
|
53
|
+
"keywords",
|
|
54
|
+
"twitter:description",
|
|
55
|
+
"twitter:title",
|
|
56
|
+
}
|
|
57
|
+
TRANSLATABLE_META_PROPERTIES = {
|
|
58
|
+
"og:description",
|
|
59
|
+
"og:site_name",
|
|
60
|
+
"og:title",
|
|
61
|
+
"twitter:description",
|
|
62
|
+
"twitter:title",
|
|
63
|
+
}
|
|
64
|
+
MIN_TOTAL_SOURCE_UNITS = 80
|
|
65
|
+
MIN_SEGMENT_SOURCE_UNITS = 24
|
|
66
|
+
MIN_IDENTITY_SOURCE_CHARACTERS = 200
|
|
67
|
+
MIN_SEGMENT_IDENTITY_UNITS = 24
|
|
68
|
+
COPYRIGHT_MARKER = re.compile(
|
|
69
|
+
r"^(?:copyright\b|[^\n]{0,32}(?:©|\(c\)))",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def linguistic_units(text: str) -> int:
|
|
75
|
+
"""Count Unicode letters and numbers after excluding protected syntax."""
|
|
76
|
+
masked = FENCED_CODE.sub("", text)
|
|
77
|
+
for _, pattern in TOKEN_PATTERNS:
|
|
78
|
+
masked = pattern.sub("", masked)
|
|
79
|
+
return sum(unicodedata.category(character)[0] in {"L", "N"} for character in masked)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def canonical_identity_text(text: str) -> str:
|
|
83
|
+
"""Normalize transport-only differences without hiding changed characters."""
|
|
84
|
+
return unicodedata.normalize(
|
|
85
|
+
"NFC",
|
|
86
|
+
text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n"),
|
|
87
|
+
).strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _actionable_unchanged_segment(source: str, target: str) -> tuple[str, int] | None:
|
|
91
|
+
canonical_source = canonical_identity_text(source)
|
|
92
|
+
canonical_target = canonical_identity_text(target)
|
|
93
|
+
source_units = linguistic_units(canonical_source)
|
|
94
|
+
if (
|
|
95
|
+
canonical_source == canonical_target
|
|
96
|
+
and (
|
|
97
|
+
source_units >= MIN_SEGMENT_IDENTITY_UNITS
|
|
98
|
+
or COPYRIGHT_MARKER.match(canonical_source) is not None
|
|
99
|
+
)
|
|
100
|
+
):
|
|
101
|
+
return canonical_source, source_units
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def identity_errors(
|
|
106
|
+
source: str | list[str],
|
|
107
|
+
target: str | list[str],
|
|
108
|
+
location: str = "$",
|
|
109
|
+
segment_locations: list[str] | None = None,
|
|
110
|
+
) -> list[str]:
|
|
111
|
+
"""Block unchanged complete inputs or aligned linguistic segments."""
|
|
112
|
+
if isinstance(source, list) and isinstance(target, list):
|
|
113
|
+
if len(source) != len(target):
|
|
114
|
+
# Structure and volume checks own count mismatches. Pairing shifted
|
|
115
|
+
# lists here could blame an unrelated segment for being unchanged.
|
|
116
|
+
return []
|
|
117
|
+
errors: list[str] = []
|
|
118
|
+
for index, (source_segment, target_segment) in enumerate(zip(source, target)):
|
|
119
|
+
segment_location = (
|
|
120
|
+
segment_locations[index]
|
|
121
|
+
if segment_locations is not None and index < len(segment_locations)
|
|
122
|
+
else f"{location}[{index}]"
|
|
123
|
+
)
|
|
124
|
+
unchanged = _actionable_unchanged_segment(source_segment, target_segment)
|
|
125
|
+
if unchanged is not None:
|
|
126
|
+
_, source_units = unchanged
|
|
127
|
+
errors.append(
|
|
128
|
+
f"{segment_location}: linguistic segment is unchanged from the source "
|
|
129
|
+
f"across {source_units} units; untranslated segment is blocked"
|
|
130
|
+
)
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
if not isinstance(source, str) or not isinstance(target, str):
|
|
134
|
+
return []
|
|
135
|
+
canonical_source = canonical_identity_text(source)
|
|
136
|
+
canonical_target = canonical_identity_text(target)
|
|
137
|
+
unchanged = _actionable_unchanged_segment(canonical_source, canonical_target)
|
|
138
|
+
if unchanged is not None:
|
|
139
|
+
_, source_units = unchanged
|
|
140
|
+
measured = (
|
|
141
|
+
f"{len(canonical_source)} characters"
|
|
142
|
+
if len(canonical_source) >= MIN_IDENTITY_SOURCE_CHARACTERS
|
|
143
|
+
else f"{source_units} linguistic units"
|
|
144
|
+
)
|
|
145
|
+
return [
|
|
146
|
+
f"{location}: target is unchanged from the source across "
|
|
147
|
+
f"{measured}; translation identity is blocked"
|
|
148
|
+
]
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def unordered_identity_errors(
|
|
153
|
+
source_segments: list[str],
|
|
154
|
+
target_segments: list[str],
|
|
155
|
+
location: str = "$segments",
|
|
156
|
+
source_locations: list[str] | None = None,
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""Find unchanged linguistic segments even when target order changes."""
|
|
159
|
+
target_counts = Counter(canonical_identity_text(segment) for segment in target_segments)
|
|
160
|
+
errors: list[str] = []
|
|
161
|
+
for index, source_segment in enumerate(source_segments):
|
|
162
|
+
canonical_source = canonical_identity_text(source_segment)
|
|
163
|
+
if not target_counts[canonical_source]:
|
|
164
|
+
continue
|
|
165
|
+
unchanged = _actionable_unchanged_segment(source_segment, canonical_source)
|
|
166
|
+
if unchanged is None:
|
|
167
|
+
continue
|
|
168
|
+
target_counts[canonical_source] -= 1
|
|
169
|
+
_, source_units = unchanged
|
|
170
|
+
segment_location = (
|
|
171
|
+
source_locations[index]
|
|
172
|
+
if source_locations is not None and index < len(source_locations)
|
|
173
|
+
else f"{location}[{index}]"
|
|
174
|
+
)
|
|
175
|
+
errors.append(
|
|
176
|
+
f"{segment_location}: linguistic segment is unchanged from the source "
|
|
177
|
+
f"across {source_units} units; untranslated segment is blocked"
|
|
178
|
+
)
|
|
179
|
+
return errors
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _cjk_dominant(text: str) -> bool:
|
|
183
|
+
letters = [character for character in text if unicodedata.category(character).startswith("L")]
|
|
184
|
+
if not letters:
|
|
185
|
+
return False
|
|
186
|
+
cjk = sum(
|
|
187
|
+
"\u3040" <= character <= "\u30ff"
|
|
188
|
+
or "\u3400" <= character <= "\u9fff"
|
|
189
|
+
or "\uac00" <= character <= "\ud7af"
|
|
190
|
+
for character in letters
|
|
191
|
+
)
|
|
192
|
+
return cjk / len(letters) >= 0.4
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def volume_errors(source_segments: list[str], target_segments: list[str], location: str = "$") -> list[str]:
|
|
196
|
+
"""Detect major omissions/additions without claiming semantic equivalence."""
|
|
197
|
+
errors: list[str] = []
|
|
198
|
+
source_nonempty = [segment for segment in source_segments if linguistic_units(segment)]
|
|
199
|
+
target_nonempty = [segment for segment in target_segments if linguistic_units(segment)]
|
|
200
|
+
source_total = sum(linguistic_units(segment) for segment in source_nonempty)
|
|
201
|
+
target_total = sum(linguistic_units(segment) for segment in target_nonempty)
|
|
202
|
+
|
|
203
|
+
if source_total >= MIN_TOTAL_SOURCE_UNITS:
|
|
204
|
+
minimum_ratio = 0.18 if _cjk_dominant(" ".join(target_nonempty)) else 0.45
|
|
205
|
+
ratio = target_total / source_total if source_total else 1.0
|
|
206
|
+
if ratio < minimum_ratio:
|
|
207
|
+
errors.append(
|
|
208
|
+
f"{location}: target linguistic volume is {target_total}/{source_total} "
|
|
209
|
+
f"units ({ratio:.1%}); minimum for this script is {minimum_ratio:.0%}"
|
|
210
|
+
)
|
|
211
|
+
if ratio > 3.0:
|
|
212
|
+
errors.append(
|
|
213
|
+
f"{location}: target linguistic volume is {target_total}/{source_total} "
|
|
214
|
+
f"units ({ratio:.1%}); possible unsupported addition"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if len(source_nonempty) != len(target_nonempty):
|
|
218
|
+
errors.append(
|
|
219
|
+
f"{location}: linguistic segment count changed from {len(source_nonempty)} to {len(target_nonempty)}"
|
|
220
|
+
)
|
|
221
|
+
return errors
|
|
222
|
+
|
|
223
|
+
for index, (source_segment, target_segment) in enumerate(zip(source_nonempty, target_nonempty)):
|
|
224
|
+
source_units = linguistic_units(source_segment)
|
|
225
|
+
target_units = linguistic_units(target_segment)
|
|
226
|
+
if source_units < MIN_SEGMENT_SOURCE_UNITS:
|
|
227
|
+
continue
|
|
228
|
+
minimum_ratio = 0.12 if _cjk_dominant(target_segment) else 0.25
|
|
229
|
+
ratio = target_units / source_units
|
|
230
|
+
if ratio < minimum_ratio:
|
|
231
|
+
errors.append(
|
|
232
|
+
f"{location}[{index}]: target segment volume is {target_units}/{source_units} "
|
|
233
|
+
f"units ({ratio:.1%}); possible truncation"
|
|
234
|
+
)
|
|
235
|
+
return errors
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def json_segments(value: Any) -> list[str]:
|
|
239
|
+
if isinstance(value, dict):
|
|
240
|
+
# JSON object order is not semantic. Sort keys so source and target
|
|
241
|
+
# segments stay aligned even when a formatter reorders properties.
|
|
242
|
+
return [
|
|
243
|
+
segment
|
|
244
|
+
for key in sorted(value)
|
|
245
|
+
for segment in json_segments(value[key])
|
|
246
|
+
]
|
|
247
|
+
if isinstance(value, list):
|
|
248
|
+
return [segment for child in value for segment in json_segments(child)]
|
|
249
|
+
return [value] if isinstance(value, str) else []
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def json_located_segments(value: Any, path: str = "$") -> list[tuple[str, str]]:
|
|
253
|
+
"""Return JSON string values with stable semantic paths."""
|
|
254
|
+
if isinstance(value, dict):
|
|
255
|
+
return [
|
|
256
|
+
segment
|
|
257
|
+
for key in sorted(value)
|
|
258
|
+
for segment in json_located_segments(value[key], f"{path}.{key}")
|
|
259
|
+
]
|
|
260
|
+
if isinstance(value, list):
|
|
261
|
+
return [
|
|
262
|
+
segment
|
|
263
|
+
for index, child in enumerate(value)
|
|
264
|
+
for segment in json_located_segments(child, f"{path}[{index}]")
|
|
265
|
+
]
|
|
266
|
+
return [(path, value)] if isinstance(value, str) else []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def jsonld_segments(value: Any, key: str | None = None) -> list[str]:
|
|
270
|
+
"""Extract only Schema.org fields whose values are human-language copy."""
|
|
271
|
+
if isinstance(value, dict):
|
|
272
|
+
return [
|
|
273
|
+
segment
|
|
274
|
+
for child_key, child_value in value.items()
|
|
275
|
+
for segment in jsonld_segments(child_value, child_key)
|
|
276
|
+
]
|
|
277
|
+
if isinstance(value, list):
|
|
278
|
+
return [segment for child in value for segment in jsonld_segments(child, key)]
|
|
279
|
+
if isinstance(value, str) and key in JSONLD_LINGUISTIC_KEYS:
|
|
280
|
+
return [value]
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def xml_segments(text: str) -> list[str]:
|
|
285
|
+
try:
|
|
286
|
+
root = ET.fromstring(text)
|
|
287
|
+
except ET.ParseError:
|
|
288
|
+
return []
|
|
289
|
+
return [segment for segment in root.itertext() if segment.strip()]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class LinguisticHTMLParser(HTMLParser):
|
|
293
|
+
def __init__(self) -> None:
|
|
294
|
+
super().__init__(convert_charrefs=True)
|
|
295
|
+
self.segments: list[str] = []
|
|
296
|
+
self.locations: list[str] = []
|
|
297
|
+
self.stack: list[dict[str, Any]] = []
|
|
298
|
+
self.root_children: dict[str, int] = {}
|
|
299
|
+
self.root_text_count = 0
|
|
300
|
+
|
|
301
|
+
def _append_segment(self, value: str, location: str) -> None:
|
|
302
|
+
self.segments.append(value)
|
|
303
|
+
self.locations.append(location)
|
|
304
|
+
|
|
305
|
+
def _next_element_path(self, tag: str) -> str:
|
|
306
|
+
counts = self.stack[-1]["children"] if self.stack else self.root_children
|
|
307
|
+
index = counts.get(tag, 0)
|
|
308
|
+
counts[tag] = index + 1
|
|
309
|
+
parent = self.stack[-1]["path"] if self.stack else "$html"
|
|
310
|
+
return f"{parent}/{tag}[{index}]"
|
|
311
|
+
|
|
312
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
313
|
+
normalized_tag = tag.casefold()
|
|
314
|
+
attribute_map = {name.casefold(): value or "" for name, value in attrs}
|
|
315
|
+
element_path = self._next_element_path(normalized_tag)
|
|
316
|
+
self.stack.append({
|
|
317
|
+
"tag": normalized_tag,
|
|
318
|
+
"type": attribute_map.get("type", "").casefold(),
|
|
319
|
+
"path": element_path,
|
|
320
|
+
"children": {},
|
|
321
|
+
"text_count": 0,
|
|
322
|
+
})
|
|
323
|
+
meta_name = attribute_map.get("name", "").casefold()
|
|
324
|
+
meta_property = attribute_map.get("property", "").casefold()
|
|
325
|
+
for name, value in attrs:
|
|
326
|
+
normalized = name.casefold()
|
|
327
|
+
linguistic_meta = normalized == "content" and (
|
|
328
|
+
meta_name in TRANSLATABLE_META_NAMES or meta_property in TRANSLATABLE_META_PROPERTIES
|
|
329
|
+
)
|
|
330
|
+
if value and (normalized in TRANSLATABLE_HTML_ATTRIBUTES or linguistic_meta):
|
|
331
|
+
self._append_segment(value, f"{element_path}/@{normalized}")
|
|
332
|
+
|
|
333
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
334
|
+
self.handle_starttag(tag, attrs)
|
|
335
|
+
self.handle_endtag(tag)
|
|
336
|
+
|
|
337
|
+
def handle_endtag(self, tag: str) -> None:
|
|
338
|
+
for index in range(len(self.stack) - 1, -1, -1):
|
|
339
|
+
if self.stack[index]["tag"] == tag.casefold():
|
|
340
|
+
del self.stack[index:]
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
def handle_data(self, data: str) -> None:
|
|
344
|
+
current = self.stack[-1] if self.stack else None
|
|
345
|
+
current_tag = current["tag"] if current else ""
|
|
346
|
+
current_type = current["type"] if current else ""
|
|
347
|
+
if current_tag in HTML_CODE_ELEMENTS:
|
|
348
|
+
if current_tag == "script" and current_type == "application/ld+json":
|
|
349
|
+
try:
|
|
350
|
+
for index, segment in enumerate(jsonld_segments(json.loads(data))):
|
|
351
|
+
self._append_segment(segment, f"{current['path']}/jsonld[{index}]")
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
pass
|
|
354
|
+
return
|
|
355
|
+
if data.strip():
|
|
356
|
+
if current:
|
|
357
|
+
text_index = current["text_count"]
|
|
358
|
+
current["text_count"] = text_index + 1
|
|
359
|
+
location = f"{current['path']}/text()[{text_index}]"
|
|
360
|
+
else:
|
|
361
|
+
text_index = self.root_text_count
|
|
362
|
+
self.root_text_count += 1
|
|
363
|
+
location = f"$html/text()[{text_index}]"
|
|
364
|
+
self._append_segment(data, location)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def html_segments(text: str) -> list[str]:
|
|
368
|
+
return [segment for _, segment in html_located_segments(text)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def html_located_segments(text: str) -> list[tuple[str, str]]:
|
|
372
|
+
parser = LinguisticHTMLParser()
|
|
373
|
+
try:
|
|
374
|
+
parser.feed(text)
|
|
375
|
+
parser.close()
|
|
376
|
+
except Exception:
|
|
377
|
+
return []
|
|
378
|
+
return list(zip(parser.locations, parser.segments))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def icu_selectors(text: str, start: int) -> list[tuple[str, tuple[int, int]]]:
|
|
382
|
+
"""Read top-level selector names from one ICU plural/select expression."""
|
|
383
|
+
selectors: list[tuple[str, tuple[int, int]]] = []
|
|
384
|
+
depth = 0
|
|
385
|
+
position = start
|
|
386
|
+
while position < len(text):
|
|
387
|
+
character = text[position]
|
|
388
|
+
if character == "}" and depth == 0:
|
|
389
|
+
break
|
|
390
|
+
if depth == 0:
|
|
391
|
+
match = ICU_SELECTOR_AT_POSITION.match(text, position)
|
|
392
|
+
if match:
|
|
393
|
+
selectors.append((match.group(1), match.span(1)))
|
|
394
|
+
position = match.end()
|
|
395
|
+
continue
|
|
396
|
+
if character == "{":
|
|
397
|
+
depth += 1
|
|
398
|
+
elif character == "}" and depth:
|
|
399
|
+
depth -= 1
|
|
400
|
+
position += 1
|
|
401
|
+
return selectors
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def protected_tokens(text: str) -> Counter[tuple[str, str]]:
|
|
405
|
+
"""Return protected token counts, avoiding duplicate nested matches."""
|
|
406
|
+
tokens: Counter[tuple[str, str]] = Counter()
|
|
407
|
+
occupied: list[tuple[int, int]] = []
|
|
408
|
+
|
|
409
|
+
def overlaps(start: int, end: int) -> bool:
|
|
410
|
+
return any(start < other_end and end > other_start for other_start, other_end in occupied)
|
|
411
|
+
|
|
412
|
+
for match in FENCED_CODE.finditer(text):
|
|
413
|
+
tokens[("fenced code", match.group(0))] += 1
|
|
414
|
+
occupied.append(match.span())
|
|
415
|
+
|
|
416
|
+
icu_headers = list(ICU_HEADER.finditer(text))
|
|
417
|
+
for match in icu_headers:
|
|
418
|
+
tokens[("ICU argument", match.group(1))] += 1
|
|
419
|
+
tokens[("ICU formatter", match.group(2).lower())] += 1
|
|
420
|
+
occupied.append(match.span())
|
|
421
|
+
for selector, span in icu_selectors(text, match.end()):
|
|
422
|
+
tokens[("ICU selector", selector)] += 1
|
|
423
|
+
occupied.append(span)
|
|
424
|
+
if icu_headers:
|
|
425
|
+
tokens[("ICU number sign", "#")] += text.count("#")
|
|
426
|
+
|
|
427
|
+
for label, pattern in TOKEN_PATTERNS:
|
|
428
|
+
for match in pattern.finditer(text):
|
|
429
|
+
if overlaps(*match.span()):
|
|
430
|
+
continue
|
|
431
|
+
value = match.group(1) if label == "ICU argument" else match.group(0)
|
|
432
|
+
if label == "URL":
|
|
433
|
+
# Sentence punctuation after a bare URL is not part of the URL.
|
|
434
|
+
value = value.rstrip(".,;:!?")
|
|
435
|
+
tokens[(label, value)] += 1
|
|
436
|
+
occupied.append(match.span())
|
|
437
|
+
return tokens
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def compare_tokens(source: str, target: str, location: str) -> list[str]:
|
|
441
|
+
source_tokens = protected_tokens(source)
|
|
442
|
+
target_tokens = protected_tokens(target)
|
|
443
|
+
errors: list[str] = []
|
|
444
|
+
for token, count in (source_tokens - target_tokens).items():
|
|
445
|
+
errors.append(f"{location}: missing {count}× {token[0]} {token[1]!r}")
|
|
446
|
+
for token, count in (target_tokens - source_tokens).items():
|
|
447
|
+
errors.append(f"{location}: added {count}× {token[0]} {token[1]!r}")
|
|
448
|
+
return errors
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def scalar_type(value: Any) -> type[Any]:
|
|
452
|
+
# bool is a subclass of int; exact types matter in resource files.
|
|
453
|
+
return type(value)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def compare_json(source: Any, target: Any, path: str = "$") -> list[str]:
|
|
457
|
+
errors: list[str] = []
|
|
458
|
+
if scalar_type(source) is not scalar_type(target):
|
|
459
|
+
return [f"{path}: type changed from {type(source).__name__} to {type(target).__name__}"]
|
|
460
|
+
|
|
461
|
+
if isinstance(source, dict):
|
|
462
|
+
source_keys = set(source)
|
|
463
|
+
target_keys = set(target)
|
|
464
|
+
for key in sorted(source_keys - target_keys):
|
|
465
|
+
errors.append(f"{path}: missing key {key!r}")
|
|
466
|
+
for key in sorted(target_keys - source_keys):
|
|
467
|
+
errors.append(f"{path}: added key {key!r}")
|
|
468
|
+
for key in source.keys() & target.keys():
|
|
469
|
+
errors.extend(compare_json(source[key], target[key], f"{path}.{key}"))
|
|
470
|
+
return errors
|
|
471
|
+
|
|
472
|
+
if isinstance(source, list):
|
|
473
|
+
if len(source) != len(target):
|
|
474
|
+
errors.append(f"{path}: array length changed from {len(source)} to {len(target)}")
|
|
475
|
+
for index, (source_item, target_item) in enumerate(zip(source, target)):
|
|
476
|
+
errors.extend(compare_json(source_item, target_item, f"{path}[{index}]"))
|
|
477
|
+
return errors
|
|
478
|
+
|
|
479
|
+
if isinstance(source, str):
|
|
480
|
+
return compare_tokens(source, target, path)
|
|
481
|
+
|
|
482
|
+
if source != target:
|
|
483
|
+
errors.append(f"{path}: non-string value changed from {source!r} to {target!r}")
|
|
484
|
+
return errors
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def token_signature(text: str) -> tuple[tuple[tuple[str, str], int], ...]:
|
|
488
|
+
return tuple(sorted(protected_tokens(text).items()))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class TranslationHTMLParser(HTMLParser):
|
|
492
|
+
"""Reduce HTML to the parts a translation must preserve."""
|
|
493
|
+
|
|
494
|
+
def __init__(self) -> None:
|
|
495
|
+
super().__init__(convert_charrefs=False)
|
|
496
|
+
self.events: list[tuple[Any, ...]] = []
|
|
497
|
+
self.open_elements: list[tuple[str, dict[str, str | None]]] = []
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def attribute_signature(
|
|
501
|
+
tag: str, attrs: list[tuple[str, str | None]]
|
|
502
|
+
) -> tuple[Any, ...]:
|
|
503
|
+
signature: list[tuple[Any, ...]] = []
|
|
504
|
+
attribute_map = {name.casefold(): value for name, value in attrs}
|
|
505
|
+
meta_name = (attribute_map.get("name") or "").casefold()
|
|
506
|
+
meta_property = (attribute_map.get("property") or "").casefold()
|
|
507
|
+
content_is_linguistic = tag.casefold() == "meta" and (
|
|
508
|
+
meta_name in TRANSLATABLE_META_NAMES
|
|
509
|
+
or meta_property in TRANSLATABLE_META_PROPERTIES
|
|
510
|
+
)
|
|
511
|
+
for name, value in attrs:
|
|
512
|
+
normalized_name = name.casefold()
|
|
513
|
+
if normalized_name in TRANSLATABLE_HTML_ATTRIBUTES or (
|
|
514
|
+
normalized_name == "content" and content_is_linguistic
|
|
515
|
+
):
|
|
516
|
+
signature.append((name, "translatable", token_signature(value or "")))
|
|
517
|
+
else:
|
|
518
|
+
signature.append((name, "fixed", value))
|
|
519
|
+
return tuple(signature)
|
|
520
|
+
|
|
521
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
522
|
+
self.events.append(("start", tag, self.attribute_signature(tag, attrs)))
|
|
523
|
+
self.open_elements.append((tag, dict(attrs)))
|
|
524
|
+
|
|
525
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
526
|
+
self.events.append(("empty", tag, self.attribute_signature(tag, attrs)))
|
|
527
|
+
|
|
528
|
+
def handle_endtag(self, tag: str) -> None:
|
|
529
|
+
self.events.append(("end", tag))
|
|
530
|
+
for index in range(len(self.open_elements) - 1, -1, -1):
|
|
531
|
+
if self.open_elements[index][0] == tag:
|
|
532
|
+
del self.open_elements[index:]
|
|
533
|
+
break
|
|
534
|
+
|
|
535
|
+
def handle_data(self, data: str) -> None:
|
|
536
|
+
current = self.open_elements[-1] if self.open_elements else None
|
|
537
|
+
current_element = current[0] if current else None
|
|
538
|
+
if current_element == "script" and (current[1].get("type") or "").casefold() == "application/ld+json":
|
|
539
|
+
try:
|
|
540
|
+
self.events.append(("json-ld", jsonld_signature(json.loads(data))))
|
|
541
|
+
except json.JSONDecodeError:
|
|
542
|
+
self.events.append(("invalid json-ld", data))
|
|
543
|
+
return
|
|
544
|
+
if current_element in HTML_CODE_ELEMENTS:
|
|
545
|
+
self.events.append(("code", current_element, data))
|
|
546
|
+
return
|
|
547
|
+
signature = token_signature(data)
|
|
548
|
+
if signature:
|
|
549
|
+
self.events.append(("text tokens", signature))
|
|
550
|
+
|
|
551
|
+
def handle_comment(self, data: str) -> None:
|
|
552
|
+
self.events.append(("comment", data))
|
|
553
|
+
|
|
554
|
+
def handle_decl(self, decl: str) -> None:
|
|
555
|
+
self.events.append(("declaration", decl))
|
|
556
|
+
|
|
557
|
+
def handle_entityref(self, name: str) -> None:
|
|
558
|
+
self.events.append(("entity", name))
|
|
559
|
+
|
|
560
|
+
def handle_charref(self, name: str) -> None:
|
|
561
|
+
self.events.append(("character reference", name))
|
|
562
|
+
|
|
563
|
+
def handle_pi(self, data: str) -> None:
|
|
564
|
+
self.events.append(("processing instruction", data))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def jsonld_signature(value: Any, key: str | None = None) -> Any:
|
|
568
|
+
if isinstance(value, dict):
|
|
569
|
+
return ("object", tuple((item_key, jsonld_signature(item_value, item_key)) for item_key, item_value in value.items()))
|
|
570
|
+
if isinstance(value, list):
|
|
571
|
+
return ("array", tuple(jsonld_signature(item, key) for item in value))
|
|
572
|
+
if isinstance(value, str) and key in JSONLD_LINGUISTIC_KEYS:
|
|
573
|
+
return ("translatable", token_signature(value))
|
|
574
|
+
return (type(value).__name__, value)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def parse_html(text: str, location: str) -> tuple[list[tuple[Any, ...]], list[str]]:
|
|
578
|
+
parser = TranslationHTMLParser()
|
|
579
|
+
try:
|
|
580
|
+
parser.feed(text)
|
|
581
|
+
parser.close()
|
|
582
|
+
except Exception as exc: # HTMLParser can surface malformed declarations.
|
|
583
|
+
return [], [f"{location}: invalid HTML ({exc})"]
|
|
584
|
+
return parser.events, []
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def compare_html(source: str, target: str) -> list[str]:
|
|
588
|
+
source_events, source_errors = parse_html(source, "source")
|
|
589
|
+
target_events, target_errors = parse_html(target, "target")
|
|
590
|
+
errors = source_errors + target_errors
|
|
591
|
+
if errors:
|
|
592
|
+
return errors
|
|
593
|
+
|
|
594
|
+
if len(source_events) != len(target_events):
|
|
595
|
+
errors.append(
|
|
596
|
+
f"$: HTML event count changed from {len(source_events)} to {len(target_events)}"
|
|
597
|
+
)
|
|
598
|
+
for index, (source_event, target_event) in enumerate(zip(source_events, target_events)):
|
|
599
|
+
if source_event != target_event:
|
|
600
|
+
errors.append(
|
|
601
|
+
f"$: HTML event {index} changed from {source_event!r} to {target_event!r}"
|
|
602
|
+
)
|
|
603
|
+
if len(errors) >= 20:
|
|
604
|
+
errors.append("$: additional HTML differences omitted")
|
|
605
|
+
break
|
|
606
|
+
return errors
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def compare_xml(source: str, target: str) -> list[str]:
|
|
610
|
+
"""Compare XML/Android/XLIFF structure while allowing linguistic text."""
|
|
611
|
+
try:
|
|
612
|
+
source_root, target_root = ET.fromstring(source), ET.fromstring(target)
|
|
613
|
+
except ET.ParseError as error:
|
|
614
|
+
return [f"$: invalid XML ({error})"]
|
|
615
|
+
errors: list[str] = []
|
|
616
|
+
|
|
617
|
+
def walk(left: ET.Element, right: ET.Element, path: str) -> None:
|
|
618
|
+
if left.tag != right.tag:
|
|
619
|
+
errors.append(f"{path}: tag changed from {left.tag!r} to {right.tag!r}")
|
|
620
|
+
return
|
|
621
|
+
if left.attrib != right.attrib:
|
|
622
|
+
errors.append(f"{path}: attributes changed from {left.attrib!r} to {right.attrib!r}")
|
|
623
|
+
errors.extend(compare_tokens(left.text or "", right.text or "", path + ".text"))
|
|
624
|
+
errors.extend(compare_tokens(left.tail or "", right.tail or "", path + ".tail"))
|
|
625
|
+
if len(left) != len(right):
|
|
626
|
+
errors.append(f"{path}: child count changed from {len(left)} to {len(right)}")
|
|
627
|
+
for index, (left_child, right_child) in enumerate(zip(left, right)):
|
|
628
|
+
walk(left_child, right_child, f"{path}/{index}")
|
|
629
|
+
walk(source_root, target_root, "$/$root")
|
|
630
|
+
return errors
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
PO_ENTRY = re.compile(r'^(msgctxt|msgid|msgid_plural|msgstr(?:\[\d+\])?)\s+"(.*)"$', re.MULTILINE)
|
|
634
|
+
APPLE_STRING = re.compile(r'^\s*"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;\s*$', re.MULTILINE)
|
|
635
|
+
TIMESTAMP = re.compile(r"^\s*(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}\s+-->\s+(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}.*$", re.MULTILINE)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def compare_po(source: str, target: str) -> list[str]:
|
|
639
|
+
left, right = PO_ENTRY.findall(source), PO_ENTRY.findall(target)
|
|
640
|
+
left_keys = [(kind, value) for kind, value in left if kind in {"msgctxt", "msgid", "msgid_plural"}]
|
|
641
|
+
right_keys = [(kind, value) for kind, value in right if kind in {"msgctxt", "msgid", "msgid_plural"}]
|
|
642
|
+
errors = [] if left_keys == right_keys else ["$: PO contexts and msgids changed"]
|
|
643
|
+
left_values = [value for kind, value in left if kind.startswith("msgstr")]
|
|
644
|
+
right_values = [value for kind, value in right if kind.startswith("msgstr")]
|
|
645
|
+
if len(left_values) != len(right_values):
|
|
646
|
+
errors.append("$: PO msgstr count changed")
|
|
647
|
+
for index, (a, b) in enumerate(zip(left_values, right_values)):
|
|
648
|
+
errors.extend(compare_tokens(a, b, f"$.msgstr[{index}]"))
|
|
649
|
+
return errors
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def compare_apple_strings(source: str, target: str) -> list[str]:
|
|
653
|
+
left, right = APPLE_STRING.findall(source), APPLE_STRING.findall(target)
|
|
654
|
+
if [key for key, _ in left] != [key for key, _ in right]:
|
|
655
|
+
return ["$: Apple .strings keys or ordering changed"]
|
|
656
|
+
errors: list[str] = []
|
|
657
|
+
for (key, source_value), (_, target_value) in zip(left, right):
|
|
658
|
+
errors.extend(compare_tokens(source_value, target_value, f"$.{key}"))
|
|
659
|
+
return errors
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def compare_subtitles(source: str, target: str) -> list[str]:
|
|
663
|
+
left, right = TIMESTAMP.findall(source), TIMESTAMP.findall(target)
|
|
664
|
+
return [] if left == right else ["$: subtitle timestamps or cue settings changed"]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def po_segments(text: str) -> list[str]:
|
|
668
|
+
entries = PO_ENTRY.findall(text)
|
|
669
|
+
translated = [value for kind, value in entries if kind.startswith("msgstr") and value]
|
|
670
|
+
return translated or [value for kind, value in entries if kind in {"msgid", "msgid_plural"} and value]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def apple_segments(text: str) -> list[str]:
|
|
674
|
+
return [value for _, value in APPLE_STRING.findall(text)]
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def subtitle_segments(text: str) -> list[str]:
|
|
678
|
+
segments: list[str] = []
|
|
679
|
+
for line in text.splitlines():
|
|
680
|
+
stripped = line.strip()
|
|
681
|
+
if not stripped or stripped.isdigit() or TIMESTAMP.fullmatch(line):
|
|
682
|
+
continue
|
|
683
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
684
|
+
continue
|
|
685
|
+
if stripped.startswith("Dialogue:"):
|
|
686
|
+
# ASS dialogue has nine fixed comma-separated fields before Text.
|
|
687
|
+
fields = line.split(",", 9)
|
|
688
|
+
if len(fields) == 10 and fields[-1].strip():
|
|
689
|
+
segments.append(fields[-1])
|
|
690
|
+
continue
|
|
691
|
+
if stripped.startswith(("WEBVTT", "NOTE", "STYLE", "REGION", "Format:")):
|
|
692
|
+
continue
|
|
693
|
+
segments.append(line)
|
|
694
|
+
return segments
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def detect_content_format(text: str) -> str:
|
|
698
|
+
"""Infer a structured format from measurable syntax, without caller trust."""
|
|
699
|
+
stripped = text.lstrip("\ufeff\n\r\t ")
|
|
700
|
+
if not stripped:
|
|
701
|
+
return "text"
|
|
702
|
+
if stripped[:1] in {"{", "["}:
|
|
703
|
+
try:
|
|
704
|
+
json.loads(stripped)
|
|
705
|
+
return "json"
|
|
706
|
+
except json.JSONDecodeError:
|
|
707
|
+
pass
|
|
708
|
+
if re.search(
|
|
709
|
+
r"<(?:!doctype\s+html|html|head|body|main|section|article|nav|header|footer|div|p|h[1-6])\b",
|
|
710
|
+
stripped,
|
|
711
|
+
re.IGNORECASE,
|
|
712
|
+
):
|
|
713
|
+
return "html"
|
|
714
|
+
if stripped.startswith("<"):
|
|
715
|
+
try:
|
|
716
|
+
ET.fromstring(stripped)
|
|
717
|
+
return "xml"
|
|
718
|
+
except ET.ParseError:
|
|
719
|
+
pass
|
|
720
|
+
if re.search(r"^msg(?:id|str|ctxt)\b", text, re.MULTILINE):
|
|
721
|
+
return "po"
|
|
722
|
+
if APPLE_STRING.search(text):
|
|
723
|
+
return "strings"
|
|
724
|
+
if TIMESTAMP.search(text) or re.search(r"^Dialogue:", text, re.MULTILINE):
|
|
725
|
+
return "subtitle"
|
|
726
|
+
return "text"
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def linguistic_segments(text: str, selected_format: str) -> list[str]:
|
|
730
|
+
"""Extract human-language segments for volume checks in one known format."""
|
|
731
|
+
if selected_format == "json":
|
|
732
|
+
try:
|
|
733
|
+
return json_segments(json.loads(text.lstrip("\ufeff")))
|
|
734
|
+
except json.JSONDecodeError:
|
|
735
|
+
return [text]
|
|
736
|
+
if selected_format == "html":
|
|
737
|
+
return html_segments(text)
|
|
738
|
+
if selected_format == "xml":
|
|
739
|
+
return xml_segments(text)
|
|
740
|
+
if selected_format == "po":
|
|
741
|
+
return po_segments(text)
|
|
742
|
+
if selected_format == "strings":
|
|
743
|
+
return apple_segments(text)
|
|
744
|
+
if selected_format == "subtitle":
|
|
745
|
+
return subtitle_segments(text)
|
|
746
|
+
return [text]
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def translation_volume_errors(source: str, target: str) -> list[str]:
|
|
750
|
+
"""Run the unconditional, auto-detected volume gate used by MCP release."""
|
|
751
|
+
selected_format = detect_content_format(source)
|
|
752
|
+
return volume_errors(
|
|
753
|
+
linguistic_segments(source, selected_format),
|
|
754
|
+
linguistic_segments(target, selected_format),
|
|
755
|
+
"$",
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def structured_identity_errors(
|
|
760
|
+
source: str,
|
|
761
|
+
target: str,
|
|
762
|
+
selected_format: str,
|
|
763
|
+
) -> list[str]:
|
|
764
|
+
"""Compare aligned user-visible segments after whole-input identity passes."""
|
|
765
|
+
if selected_format == "text":
|
|
766
|
+
return []
|
|
767
|
+
if selected_format == "json":
|
|
768
|
+
try:
|
|
769
|
+
source_data = json.loads(source.lstrip("\ufeff"))
|
|
770
|
+
target_data = json.loads(target.lstrip("\ufeff"))
|
|
771
|
+
except json.JSONDecodeError:
|
|
772
|
+
return []
|
|
773
|
+
source_located = json_located_segments(source_data)
|
|
774
|
+
target_by_path = dict(json_located_segments(target_data))
|
|
775
|
+
common = [
|
|
776
|
+
(path, value, target_by_path[path])
|
|
777
|
+
for path, value in source_located
|
|
778
|
+
if path in target_by_path
|
|
779
|
+
]
|
|
780
|
+
return identity_errors(
|
|
781
|
+
[source_value for _, source_value, _ in common],
|
|
782
|
+
[target_value for _, _, target_value in common],
|
|
783
|
+
"$segments",
|
|
784
|
+
[path for path, _, _ in common],
|
|
785
|
+
)
|
|
786
|
+
if selected_format == "html":
|
|
787
|
+
source_located = html_located_segments(source)
|
|
788
|
+
return unordered_identity_errors(
|
|
789
|
+
[segment for _, segment in source_located],
|
|
790
|
+
html_segments(target),
|
|
791
|
+
"$segments",
|
|
792
|
+
[path for path, _ in source_located],
|
|
793
|
+
)
|
|
794
|
+
return unordered_identity_errors(
|
|
795
|
+
linguistic_segments(source, selected_format),
|
|
796
|
+
linguistic_segments(target, selected_format),
|
|
797
|
+
"$segments",
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def translation_identity_errors(source: str, target: str) -> list[str]:
|
|
802
|
+
"""Run the mandatory whole-input and auto-detected segment identity gate."""
|
|
803
|
+
whole_input_errors = identity_errors(source, target, "$")
|
|
804
|
+
if whole_input_errors:
|
|
805
|
+
return whole_input_errors
|
|
806
|
+
selected_format = detect_content_format(source)
|
|
807
|
+
return structured_identity_errors(source, target, selected_format)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def read_utf8(path: Path) -> tuple[str | None, list[str]]:
|
|
811
|
+
try:
|
|
812
|
+
return path.read_text(encoding="utf-8"), []
|
|
813
|
+
except UnicodeDecodeError as exc:
|
|
814
|
+
return None, [f"{path}: not valid UTF-8 ({exc})"]
|
|
815
|
+
except OSError as exc:
|
|
816
|
+
return None, [f"{path}: cannot read file ({exc})"]
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def normalization_errors(text: str, path: Path) -> list[str]:
|
|
820
|
+
if unicodedata.is_normalized("NFC", text):
|
|
821
|
+
return []
|
|
822
|
+
return [f"{path}: target text is not Unicode NFC-normalized"]
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def parse_json(text: str, path: Path) -> tuple[Any | None, list[str]]:
|
|
826
|
+
try:
|
|
827
|
+
return json.loads(text), []
|
|
828
|
+
except json.JSONDecodeError as exc:
|
|
829
|
+
return None, [f"{path}: invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"]
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def print_errors(errors: Iterable[str]) -> None:
|
|
833
|
+
for error in errors:
|
|
834
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def main() -> int:
|
|
838
|
+
parser = argparse.ArgumentParser(
|
|
839
|
+
description="Verify source-target non-identity, Unicode normalization, structure, volume, and protected tokens in a translation."
|
|
840
|
+
)
|
|
841
|
+
parser.add_argument("source", type=Path)
|
|
842
|
+
parser.add_argument("target", type=Path)
|
|
843
|
+
parser.add_argument("--format", choices=("auto", "text", "json", "html", "xml", "po", "strings", "subtitle"), default="auto")
|
|
844
|
+
args = parser.parse_args()
|
|
845
|
+
|
|
846
|
+
source_text, source_errors = read_utf8(args.source)
|
|
847
|
+
target_text, target_errors = read_utf8(args.target)
|
|
848
|
+
errors = source_errors + target_errors
|
|
849
|
+
if errors:
|
|
850
|
+
print_errors(errors)
|
|
851
|
+
return 2
|
|
852
|
+
assert source_text is not None and target_text is not None
|
|
853
|
+
|
|
854
|
+
errors.extend(normalization_errors(target_text, args.target))
|
|
855
|
+
whole_identity_errors = identity_errors(source_text, target_text, "$")
|
|
856
|
+
errors.extend(whole_identity_errors)
|
|
857
|
+
selected_format = args.format
|
|
858
|
+
if selected_format == "auto":
|
|
859
|
+
suffix = args.source.suffix.lower()
|
|
860
|
+
if suffix == ".json":
|
|
861
|
+
selected_format = "json"
|
|
862
|
+
elif suffix in {".html", ".htm"}:
|
|
863
|
+
selected_format = "html"
|
|
864
|
+
elif suffix in {".xml", ".xliff", ".xlf"}:
|
|
865
|
+
selected_format = "xml"
|
|
866
|
+
elif suffix in {".po", ".pot"}:
|
|
867
|
+
selected_format = "po"
|
|
868
|
+
elif suffix == ".strings":
|
|
869
|
+
selected_format = "strings"
|
|
870
|
+
elif suffix in {".srt", ".vtt", ".ass"}:
|
|
871
|
+
selected_format = "subtitle"
|
|
872
|
+
else:
|
|
873
|
+
selected_format = "text"
|
|
874
|
+
|
|
875
|
+
if not whole_identity_errors:
|
|
876
|
+
errors.extend(structured_identity_errors(source_text, target_text, selected_format))
|
|
877
|
+
|
|
878
|
+
if selected_format == "json":
|
|
879
|
+
source_data, parse_source_errors = parse_json(source_text, args.source)
|
|
880
|
+
target_data, parse_target_errors = parse_json(target_text, args.target)
|
|
881
|
+
errors.extend(parse_source_errors)
|
|
882
|
+
errors.extend(parse_target_errors)
|
|
883
|
+
if not parse_source_errors and not parse_target_errors:
|
|
884
|
+
errors.extend(compare_json(source_data, target_data))
|
|
885
|
+
errors.extend(volume_errors(json_segments(source_data), json_segments(target_data), "$"))
|
|
886
|
+
elif selected_format == "html":
|
|
887
|
+
errors.extend(compare_html(source_text, target_text))
|
|
888
|
+
errors.extend(volume_errors(html_segments(source_text), html_segments(target_text), "$"))
|
|
889
|
+
elif selected_format == "xml":
|
|
890
|
+
errors.extend(compare_xml(source_text, target_text))
|
|
891
|
+
errors.extend(volume_errors(xml_segments(source_text), xml_segments(target_text), "$"))
|
|
892
|
+
elif selected_format == "po":
|
|
893
|
+
errors.extend(compare_po(source_text, target_text))
|
|
894
|
+
errors.extend(volume_errors(po_segments(source_text), po_segments(target_text), "$"))
|
|
895
|
+
elif selected_format == "strings":
|
|
896
|
+
errors.extend(compare_apple_strings(source_text, target_text))
|
|
897
|
+
errors.extend(volume_errors(apple_segments(source_text), apple_segments(target_text), "$"))
|
|
898
|
+
elif selected_format == "subtitle":
|
|
899
|
+
errors.extend(compare_subtitles(source_text, target_text))
|
|
900
|
+
errors.extend(volume_errors(subtitle_segments(source_text), subtitle_segments(target_text), "$"))
|
|
901
|
+
else:
|
|
902
|
+
errors.extend(compare_tokens(source_text, target_text, "$"))
|
|
903
|
+
errors.extend(volume_errors([source_text], [target_text], "$"))
|
|
904
|
+
|
|
905
|
+
if errors:
|
|
906
|
+
print_errors(errors)
|
|
907
|
+
return 1
|
|
908
|
+
print(
|
|
909
|
+
"OK: source-target and structured-segment identity thresholds, measurable structure, protected tokens, linguistic volume, and Unicode NFC are intact. "
|
|
910
|
+
"This does not prove semantic fidelity, completeness, or native quality."
|
|
911
|
+
)
|
|
912
|
+
return 0
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
if __name__ == "__main__":
|
|
916
|
+
raise SystemExit(main())
|