Source code for dsw_document_template_tool._translation_tree.output_polish

"""Language-specific polish for synced translated template output."""

from __future__ import annotations

import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

_CJK = "\u3400-\u9fff"
_CJK_OR_JINJA_END_CLASS = _CJK + ")】》』」}"
_FULLWIDTH_PUNCTUATION_BEFORE_CJK = "。:;,、)】》』」"
_FULLWIDTH_BOUNDARY_BEFORE_SILENT_JINJA_CJK = "。:;)】》』」"
_VISIBLE_BOUNDARY_BEFORE_SILENT_JINJA_CJK = _CJK_OR_JINJA_END_CLASS + "。:;>"
_FAIRSHARING_MACRO_LINE_PATTERN = re.compile(
    r"(?m)^([ \t]*):\s*(\{\{\s*macros\.integrationFairSharing\([^}\n]+\)\s*\}\})\."
)
_LOOP_COMMA_PERIOD_PATTERN = re.compile(r'\{\{\s*", "\s+if\s+not\s+loop\.last\s+else\s+"\."\s*\}\}')
_JOIN_COMMA_PATTERN = re.compile(r'\|\s*join\(", "\)')
_SENTENCE_LIST_JOIN_SPACE_PATTERN = re.compile(
    r"""(?P<name>\b(?:[A-Za-z_][A-Za-z0-9_]*)?sentences)\|\s*join\((?P<quote>["']) (?P=quote)\)""",
    re.IGNORECASE,
)
_INLINE_COLON_PREFIX_PATTERN = re.compile(r'\{\{\s*": "\s*~')
_INLINE_PERIOD_FALLBACK_PATTERN = re.compile(r'\s+else\s+"\."\s*\}\}')
_INLINE_TAG_BEFORE_FULLWIDTH_PAREN_PATTERN = re.compile(r"(</(?:em|span|strong)>)\s+(")
_JINJA_STRING_TRAILING_FULLWIDTH_GAP_PATTERN = re.compile(
    rf"(?<=[{_FULLWIDTH_PUNCTUATION_BEFORE_CJK}])[ \t]+(?=['\"])",
)
_DOT_FILTER_PLACEHOLDERS_WITH_TRANSLATED_PUNCTUATION = (
    "swPIDReply",
    "publishedDataHowLongFixed",
    "publishedDataIdentifierSpecifyReply",
    "embargoPeriod",
)
_COST_DESCRIPTION_DOT_PATTERN = re.compile(
    r'\{\{\s*" - "\s*\+\s*projectCostItemDescriptionReply\|dot\s*\}\}'
)
_COST_DESCRIPTION_DOT_REPLACEMENT = (
    "{%- set __tr_dot_value = projectCostItemDescriptionReply|trim -%}"
    '{%- if __tr_dot_value -%}{{ " - " + __tr_dot_value }}'
    '{{ "。" if __tr_dot_value[-1] not in ".。!!??" else "" }}{%- endif -%}'
)
_REMAINING_DOT_FILTER_PATTERN = re.compile(r"\|dot\b")
_MACHINE_CONTROLLED_REGION_PATTERN = re.compile(
    r"(?:\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}|<[^>]*>)",
    re.DOTALL,
)


[docs] @dataclass(frozen=True) class SilentSpacingCollapse: """Silent Jinja tags to keep while removing following visible whitespace.""" silent_tags: list[str] next_index: int remove_spacing: bool
def _is_cjk(char: str) -> bool: return "\u3400" <= char <= "\u9fff" def _read_silent_jinja_tag(text: str, start: int) -> tuple[str, int] | None: """Read a non-outputting Jinja tag at `start`, if one is present.""" if text.startswith("{%", start): end_marker = "%}" elif text.startswith("{#", start): end_marker = "#}" else: return None end = text.find(end_marker, start + 2) if end == -1: return None end += len(end_marker) return text[start:end], end def _collapse_fullwidth_spacing_before_cjk(text: str) -> str: """Remove visible whitespace between Chinese punctuation and Chinese text. Jinja control/comment tags do not emit visible text, but the surrounding template newlines do. Keep those tags while stripping only the whitespace that would become an unwanted half-width gap in rendered zh-Hant output. """ result: list[str] = [] index = 0 while index < len(text): char = text[index] result.append(char) index += 1 if char not in _FULLWIDTH_PUNCTUATION_BEFORE_CJK: continue lookahead = index silent_tags: list[str] = [] allow_silent_tags = char in _FULLWIDTH_BOUNDARY_BEFORE_SILENT_JINJA_CJK while True: while lookahead < len(text) and text[lookahead] in " \t\r\n": lookahead += 1 if not allow_silent_tags: break silent_tag = _read_silent_jinja_tag(text, lookahead) if silent_tag is None: break tag_text, lookahead = silent_tag silent_tags.append(tag_text) if lookahead > index and lookahead < len(text) and _is_cjk(text[lookahead]): result.extend(silent_tags) index = lookahead return "".join(result) def _collapse_silent_jinja_leading_spacing_before_cjk(text: str) -> str: """Remove indentation emitted after silent Jinja branches before zh-Hant text.""" result: list[str] = [] index = 0 last_visible_char = "" while index < len(text): silent_tag = _read_silent_jinja_tag(text, index) if silent_tag is not None: tag_text, tag_end = silent_tag collapse = _silent_spacing_collapse_after_tag( text=text, tag_text=tag_text, tag_end=tag_end, last_visible_char=last_visible_char, ) if collapse is not None: if not collapse.remove_spacing: result.append(text[index : collapse.next_index]) index = collapse.next_index continue while result and result[-1].isspace(): result.pop() result.extend(collapse.silent_tags) index = collapse.next_index continue result.append(tag_text) index = tag_end continue char = text[index] result.append(char) if not char.isspace(): last_visible_char = char index += 1 return "".join(result) def _silent_spacing_collapse_after_tag( *, text: str, tag_text: str, tag_end: int, last_visible_char: str, ) -> SilentSpacingCollapse | None: if last_visible_char not in _VISIBLE_BOUNDARY_BEFORE_SILENT_JINJA_CJK: return None lookahead = tag_end silent_tags = [tag_text] while True: while lookahead < len(text) and text[lookahead] in " \t\r\n": lookahead += 1 next_silent_tag = _read_silent_jinja_tag(text, lookahead) if next_silent_tag is None: break next_tag_text, lookahead = next_silent_tag silent_tags.append(next_tag_text) remove_spacing = ( lookahead > tag_end and lookahead < len(text) and (_is_cjk(text[lookahead]) or text[lookahead] == "(") ) return SilentSpacingCollapse( silent_tags=silent_tags, next_index=lookahead, remove_spacing=remove_spacing, )
[docs] def polish_translated_output_dir(*, output_dir: Path, target_lang: str) -> None: """Apply safe, text-level polish after translations are synced. Translation units intentionally avoid owning surrounding Jinja punctuation. For zh-Hant output, a few punctuation marks from the English template remain outside translation blocks, so normalize them at the final output boundary. """ if target_lang != "zh_Hant": return for path in (output_dir / "src").rglob("*.j2"): path.write_text( polish_zh_hant_template_text(path.read_text(encoding="utf-8")), encoding="utf-8", )
[docs] def polish_zh_hant_template_text(text: str) -> str: """Normalize punctuation patterns that are outside translation units.""" text = _normalize_dot_filters(text) text = _normalize_template_punctuation(text) text = _collapse_silent_jinja_leading_spacing_before_cjk(text) text = _collapse_fullwidth_spacing_before_cjk(text) text = re.sub(rf"(?<=[{_CJK}])、\s+", "、", text) text = re.sub(rf"(?<=[{_CJK}]),\s+(?=[{_CJK}])", "、", text) return text
def _normalize_dot_filters(text: str) -> str: for placeholder in _DOT_FILTER_PLACEHOLDERS_WITH_TRANSLATED_PUNCTUATION: text = re.sub( r"\{\{\s*" + re.escape(placeholder) + r"\|dot\s*\}\}", "{{ " + placeholder + "|trim }}", text, ) text = _COST_DESCRIPTION_DOT_PATTERN.sub(_COST_DESCRIPTION_DOT_REPLACEMENT, text) text = _REMAINING_DOT_FILTER_PATTERN.sub("|trim", text) return text def _normalize_template_punctuation(text: str) -> str: text = _FAIRSHARING_MACRO_LINE_PATTERN.sub(r"\1:\2。", text) text = _LOOP_COMMA_PERIOD_PATTERN.sub('{{ "、" if not loop.last else "。" }}', text) text = _JOIN_COMMA_PATTERN.sub('|join("、")', text) text = _SENTENCE_LIST_JOIN_SPACE_PATTERN.sub(r'\g<name>|join("")', text) text = _INLINE_COLON_PREFIX_PATTERN.sub('{{ ":" ~', text) text = _INLINE_PERIOD_FALLBACK_PATTERN.sub(' else "。" }}', text) text = text.replace( "{% if not loop.last %}, {% endif %}", "{% if not loop.last %}、{% endif %}", ) text = re.sub(r"(\{%-\s*else\s*-%})\.", r"\1。", text) text = _JINJA_STRING_TRAILING_FULLWIDTH_GAP_PATTERN.sub("", text) text = _INLINE_TAG_BEFORE_FULLWIDTH_PAREN_PATTERN.sub(r"\1(", text) return _transform_visible_template_text(text, _normalize_visible_punctuation) def _transform_visible_template_text(text: str, transform: Callable[[str], str]) -> str: """Transform rendered prose while preserving Jinja and HTML tag contents.""" result: list[str] = [] previous_end = 0 for match in _MACHINE_CONTROLLED_REGION_PATTERN.finditer(text): visible_text = text[previous_end : match.start()] if previous_end: # Preserve the boundary context (notably a closing Jinja brace) # without passing the protected region itself to the transform. visible_text = transform(text[previous_end - 1] + visible_text)[1:] else: visible_text = transform(visible_text) result.append(visible_text) result.append(match.group()) previous_end = match.end() visible_text = text[previous_end:] if previous_end: visible_text = transform(text[previous_end - 1] + visible_text)[1:] else: visible_text = transform(visible_text) result.append(visible_text) return "".join(result) def _normalize_visible_punctuation(text: str) -> str: text = re.sub(rf"(?<=[{_CJK_OR_JINJA_END_CLASS}])\s*:\s*", ":", text) text = re.sub(rf"(?<=[{_CJK_OR_JINJA_END_CLASS}])\s+(", "(", text) text = re.sub(rf"(?<=[{_CJK_OR_JINJA_END_CLASS}])\.", "。", text) return text.replace("。.", "。")