From f82ee91ecdb5faba9f0d55d454b839ac60611b82 Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Thu, 23 Jul 2026 15:21:10 +0100 Subject: [PATCH 1/4] fix(internal): append CHAR_LIMIT to translation descriptions in export script Fixes #1613 by updating the export_translations script to automatically calculate and append [CHAR_LIMIT=xxx] to each translation_description that doesn't already have one. The limit defaults to ~1.5x the English string length, rounded up to the nearest 5. Also fixes Python 2 print statement for Python 3 compatibility. --- scripts/translations/export_translations.py | 36 ++++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py index 5c10b58e20..2fbd86e41a 100644 --- a/scripts/translations/export_translations.py +++ b/scripts/translations/export_translations.py @@ -1,5 +1,6 @@ # coding=UTF-8 +import math import os import re import sys @@ -9,6 +10,24 @@ PREFIXED_NAME_START = 'name="fui_' UNPREFIXED_NAME_START = 'name="' +CHAR_LIMIT_PATTERN = re.compile(r'\[CHAR_LIMIT=\d+\]') +TRANSLATION_DESC_PATTERN = re.compile(r'(translation_description="[^"]*?)(")') +XML_TAG_PATTERN = re.compile(r'<[^>]+>') +STRING_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') + + +def _extract_visible_text(value): + """Strip XML tags to get the visible text length.""" + return XML_TAG_PATTERN.sub('', value).strip() + + +def _calculate_char_limit(english_text): + """Return ~1.5x the English text length, rounded up to the nearest 5.""" + length = len(english_text) + limit = math.ceil(length * 1.5) + return int(math.ceil(limit / 5.0) * 5) + + class ExportTranslationsScript(BaseStringScript): def ProcessTag(self, line, type): @@ -16,13 +35,20 @@ def ProcessTag(self, line, type): if PREFIXED_NAME_START in joined: joined = joined.replace(PREFIXED_NAME_START, UNPREFIXED_NAME_START) - return joined.split('\n') - else: - return line + + if 'translation_description=' in joined and not CHAR_LIMIT_PATTERN.search(joined): + match = STRING_VALUE_PATTERN.search(joined) + if match: + visible = _extract_visible_text(match.group(1)) + if visible: + limit = _calculate_char_limit(visible) + joined = TRANSLATION_DESC_PATTERN.sub( + r'\1 [CHAR_LIMIT=%d]\2' % limit, joined, count=1) + + return joined.split('\n') def WriteFile(self, file_name, file_contents): - # Override to just print the contents - print file_contents + print(file_contents) if __name__ == '__main__': ets = ExportTranslationsScript() From 18db894c55927b981d3414dc3fd128642555f1be Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Thu, 23 Jul 2026 16:01:41 +0100 Subject: [PATCH 2/4] fix(internal): append CHAR_LIMIT to translation descriptions in export script Fixes #1613 by updating the export_translations script to automatically calculate and append [CHAR_LIMIT=xxx] to each translation_description that doesn't already have one. The limit defaults to ~1.5x the English string length, rounded up to the nearest 5. Handles plurals items individually, decodes XML entities and Android escape sequences for accurate length calculation, and preserves existing manual limits. Also fixes Python 2 print statement for Python 3 compatibility. --- scripts/translations/export_translations.py | 51 +++++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py index 2fbd86e41a..ffb96bcdc0 100644 --- a/scripts/translations/export_translations.py +++ b/scripts/translations/export_translations.py @@ -14,11 +14,24 @@ TRANSLATION_DESC_PATTERN = re.compile(r'(translation_description="[^"]*?)(")') XML_TAG_PATTERN = re.compile(r'<[^>]+>') STRING_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') +ITEM_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') + +XML_ENTITIES = {'&': '&', '<': '<', '>': '>', '"': '"', ''': "'"} +ESCAPE_PATTERN = re.compile(r'\\(u[0-9A-Fa-f]{4}|n|t|\'|")') + + +def _decode_entities(text): + """Decode XML entities and Android escape sequences to get true visible length.""" + for entity, char in XML_ENTITIES.items(): + text = text.replace(entity, char) + text = ESCAPE_PATTERN.sub('X', text) + return text def _extract_visible_text(value): - """Strip XML tags to get the visible text length.""" - return XML_TAG_PATTERN.sub('', value).strip() + """Strip XML tags and decode entities to get the visible text length.""" + text = XML_TAG_PATTERN.sub('', value).strip() + return _decode_entities(text) def _calculate_char_limit(english_text): @@ -28,6 +41,23 @@ def _calculate_char_limit(english_text): return int(math.ceil(limit / 5.0) * 5) +def _add_char_limit(text, value_pattern): + """Add [CHAR_LIMIT=xxx] to translation_description if missing.""" + if 'translation_description=' not in text: + return text + if CHAR_LIMIT_PATTERN.search(text): + return text + match = value_pattern.search(text) + if not match: + return text + visible = _extract_visible_text(match.group(1)) + if not visible: + return text + limit = _calculate_char_limit(visible) + return TRANSLATION_DESC_PATTERN.sub( + r'\1 [CHAR_LIMIT=%d]\2' % limit, text, count=1) + + class ExportTranslationsScript(BaseStringScript): def ProcessTag(self, line, type): @@ -36,14 +66,15 @@ def ProcessTag(self, line, type): if PREFIXED_NAME_START in joined: joined = joined.replace(PREFIXED_NAME_START, UNPREFIXED_NAME_START) - if 'translation_description=' in joined and not CHAR_LIMIT_PATTERN.search(joined): - match = STRING_VALUE_PATTERN.search(joined) - if match: - visible = _extract_visible_text(match.group(1)) - if visible: - limit = _calculate_char_limit(visible) - joined = TRANSLATION_DESC_PATTERN.sub( - r'\1 [CHAR_LIMIT=%d]\2' % limit, joined, count=1) + if type == self.TYPE_STR: + joined = _add_char_limit(joined, STRING_VALUE_PATTERN) + elif type == self.TYPE_PLUR: + result_lines = [] + for l in joined.split('\n'): + if '' in l: + l = _add_char_limit(l, ITEM_VALUE_PATTERN) + result_lines.append(l) + joined = '\n'.join(result_lines) return joined.split('\n') From 466f2b77b8f9c2ae4f46fbe4bee7bd3a46e22db0 Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Thu, 23 Jul 2026 16:24:05 +0100 Subject: [PATCH 3/4] fix(internal): use re.sub for multiline plurals item handling --- scripts/translations/export_translations.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py index ffb96bcdc0..f52fdacf1c 100644 --- a/scripts/translations/export_translations.py +++ b/scripts/translations/export_translations.py @@ -69,12 +69,12 @@ def ProcessTag(self, line, type): if type == self.TYPE_STR: joined = _add_char_limit(joined, STRING_VALUE_PATTERN) elif type == self.TYPE_PLUR: - result_lines = [] - for l in joined.split('\n'): - if '' in l: - l = _add_char_limit(l, ITEM_VALUE_PATTERN) - result_lines.append(l) - joined = '\n'.join(result_lines) + joined = re.sub( + r'(]*>.*?)', + lambda m: _add_char_limit(m.group(1), ITEM_VALUE_PATTERN), + joined, + flags=re.DOTALL + ) return joined.split('\n') From a11214241b66fc6337a05f22fd4ebb983f7f5bc6 Mon Sep 17 00:00:00 2001 From: Oluwatobi Shokunbi Date: Fri, 24 Jul 2026 15:38:39 +0100 Subject: [PATCH 4/4] fix(internal): address review feedback on char limit calculation - Collapse whitespace runs to single space before measuring visible text length, matching Android's rendering behavior - Scope CHAR_LIMIT existence check to translation_description attribute only, preventing false matches in nested xliff:g descriptions - Use single-pass regex for XML entity decoding to avoid mangling double-encoded entities like &lt; --- scripts/translations/export_translations.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/translations/export_translations.py b/scripts/translations/export_translations.py index f52fdacf1c..b2f2bb24e6 100644 --- a/scripts/translations/export_translations.py +++ b/scripts/translations/export_translations.py @@ -17,13 +17,15 @@ ITEM_VALUE_PATTERN = re.compile(r'>([^<]*(?:]*>[^<]*[^<]*)*)') XML_ENTITIES = {'&': '&', '<': '<', '>': '>', '"': '"', ''': "'"} +ENTITY_PATTERN = re.compile('|'.join( + re.escape(k) for k in sorted(XML_ENTITIES, key=len, reverse=True) +)) ESCAPE_PATTERN = re.compile(r'\\(u[0-9A-Fa-f]{4}|n|t|\'|")') def _decode_entities(text): """Decode XML entities and Android escape sequences to get true visible length.""" - for entity, char in XML_ENTITIES.items(): - text = text.replace(entity, char) + text = ENTITY_PATTERN.sub(lambda m: XML_ENTITIES[m.group()], text) text = ESCAPE_PATTERN.sub('X', text) return text @@ -31,7 +33,9 @@ def _decode_entities(text): def _extract_visible_text(value): """Strip XML tags and decode entities to get the visible text length.""" text = XML_TAG_PATTERN.sub('', value).strip() - return _decode_entities(text) + text = _decode_entities(text) + text = re.sub(r'\s+', ' ', text).strip() + return text def _calculate_char_limit(english_text): @@ -45,7 +49,8 @@ def _add_char_limit(text, value_pattern): """Add [CHAR_LIMIT=xxx] to translation_description if missing.""" if 'translation_description=' not in text: return text - if CHAR_LIMIT_PATTERN.search(text): + desc_match = TRANSLATION_DESC_PATTERN.search(text) + if desc_match and CHAR_LIMIT_PATTERN.search(desc_match.group(1)): return text match = value_pattern.search(text) if not match: