Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
stenzek
GitHub Repository: stenzek/duckstation
Path: blob/master/scripts/verify_translation_placeholders.py
4201 views
1
2
#!/usr/bin/env python3
3
"""
4
Qt Translation Placeholder Verifier
5
6
This script verifies that placeholders in Qt translation files are consistent
7
between source and translation strings.
8
9
Placeholder rules:
10
- {} placeholders: translation must have same total count as source
11
- {n} placeholders: translation must use same numbers as source (can repeat)
12
"""
13
14
import xml.etree.ElementTree as ET
15
import re
16
import sys
17
from typing import List, Set, Tuple, Dict
18
from pathlib import Path
19
20
21
def extract_placeholders(text: str) -> Tuple[int, Set[int]]:
22
"""
23
Extract placeholder information from a string.
24
25
Returns:
26
Tuple of (unnamed_count, set_of_numbered_placeholders)
27
"""
28
# Find all placeholders
29
placeholders = re.findall(r'\{(\d*)\}', text)
30
31
unnamed_count = 0
32
numbered_set = set()
33
34
for placeholder in placeholders:
35
if placeholder == '':
36
unnamed_count += 1
37
else:
38
numbered_set.add(int(placeholder))
39
40
return unnamed_count, numbered_set
41
42
43
def verify_placeholders(source: str, translation: str) -> Tuple[bool, str]:
44
"""
45
Verify that placeholders in source and translation are consistent.
46
47
Returns:
48
Tuple of (is_valid, error_message)
49
"""
50
if not translation.strip():
51
return True, "" # Empty translations are valid
52
53
source_unnamed, source_numbered = extract_placeholders(source)
54
trans_unnamed, trans_numbered = extract_placeholders(translation)
55
56
# Check if mixing numbered and unnumbered placeholders
57
if source_unnamed > 0 and len(source_numbered) > 0:
58
return False, "Source mixes numbered and unnumbered placeholders"
59
60
if trans_unnamed > 0 and len(trans_numbered) > 0:
61
return False, "Translation mixes numbered and unnumbered placeholders"
62
63
# If source uses unnumbered placeholders
64
if source_unnamed > 0:
65
# Translation can use either unnumbered or numbered placeholders
66
if trans_unnamed > 0:
67
# Both use unnumbered - simple count match
68
if source_unnamed != trans_unnamed:
69
return False, f"Placeholder count mismatch: source has {source_unnamed}, translation has {trans_unnamed}"
70
elif len(trans_numbered) > 0:
71
# Source uses unnumbered, translation uses numbered
72
# Check that numbered placeholders are 0-based and consecutive up to source count
73
expected_numbers = set(range(source_unnamed))
74
if trans_numbered != expected_numbers:
75
if max(trans_numbered) >= source_unnamed:
76
return False, f"Numbered placeholders exceed source count: translation uses {{{max(trans_numbered)}}}, but source only has {source_unnamed} placeholders"
77
elif min(trans_numbered) != 0:
78
return False, f"Numbered placeholders must start from 0: found {{{min(trans_numbered)}}}"
79
else:
80
missing = expected_numbers - trans_numbered
81
return False, f"Missing numbered placeholders: {{{','.join(map(str, sorted(missing)))}}}"
82
83
# If source uses numbered placeholders
84
elif len(source_numbered) > 0:
85
if trans_unnamed > 0:
86
return False, "Source uses numbered {n} but translation uses unnumbered {}"
87
if source_numbered != trans_numbered:
88
missing = source_numbered - trans_numbered
89
extra = trans_numbered - source_numbered
90
error_parts = []
91
if missing:
92
error_parts.append(f"missing {{{','.join(map(str, sorted(missing)))}}}")
93
if extra:
94
error_parts.append(f"extra {{{','.join(map(str, sorted(extra)))}}}")
95
return False, f"Numbered placeholder mismatch: {', '.join(error_parts)}"
96
97
# If translation has placeholders but source doesn't
98
elif trans_unnamed > 0 or len(trans_numbered) > 0:
99
return False, "Translation has placeholders but source doesn't"
100
101
return True, ""
102
103
104
def verify_translation_file(file_path: str) -> List[Dict]:
105
"""
106
Verify all translations in a Qt translation file.
107
108
Returns:
109
List of error dictionaries with details about invalid translations
110
"""
111
try:
112
tree = ET.parse(file_path)
113
root = tree.getroot()
114
except ET.ParseError as e:
115
return [{"error": f"XML parsing error: {e}", "line": None}]
116
except FileNotFoundError:
117
return [{"error": f"File not found: {file_path}", "line": None}]
118
119
errors = []
120
121
# Find all message elements
122
for message in root.findall('.//message'):
123
source_elem = message.find('source')
124
translation_elem = message.find('translation')
125
location_elem = message.find('location')
126
127
if source_elem is None or translation_elem is None:
128
continue
129
130
source_text = source_elem.text or ""
131
translation_text = translation_elem.text or ""
132
133
is_valid, error_msg = verify_placeholders(source_text, translation_text)
134
135
if not is_valid:
136
error_info = {
137
"source": source_text,
138
"translation": translation_text,
139
"error": error_msg,
140
"line": location_elem.get('line') if location_elem is not None else None,
141
"filename": location_elem.get('filename') if location_elem is not None else None
142
}
143
errors.append(error_info)
144
145
return errors
146
147
148
def main():
149
if len(sys.argv) != 2:
150
print(f"Usage: {sys.argv[0]} <translation_file.ts>")
151
sys.exit(1)
152
153
file_path = sys.argv[1]
154
155
if not Path(file_path).exists():
156
print(f"Error: File '{file_path}' not found")
157
sys.exit(1)
158
159
print(f"Verifying placeholders in: {file_path}")
160
161
errors = verify_translation_file(file_path)
162
163
if not errors:
164
print("All placeholders are valid.")
165
sys.exit(0)
166
167
print(f"Found {len(errors)} placeholder errors:")
168
169
for i, error in enumerate(errors, 1):
170
print(f"\nError {i}:")
171
if error.get('filename') and error.get('line'):
172
print(f" Location: {error['filename']}:{error['line']}")
173
print(f" Source: {error['source']}")
174
print(f" Translation: {error['translation']}")
175
print(f" Issue: {error['error']}")
176
177
sys.exit(1)
178
179
180
if __name__ == "__main__":
181
main()
182