From 08d47c3ea838dd59efc98ee075a813dd5211b025 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Tue, 15 Sep 2026 16:11:49 +0800 Subject: [PATCH] refactor: refactor render module --- doc/filters.md | 36 ++++++++++++ doc/rc-render-context.md | 47 ++++++++------- example/example.rc.liquid | 9 ++- src/metaglot/cmds/rc/render.py | 51 +++++++++++++++-- src/metaglot/filters.py | 10 +++- src/metaglot/pofile.py | 4 +- src/metaglot/render.py | 102 +++++++-------------------------- 7 files changed, 145 insertions(+), 114 deletions(-) create mode 100644 doc/filters.md diff --git a/doc/filters.md b/doc/filters.md new file mode 100644 index 0000000..37d3ebf --- /dev/null +++ b/doc/filters.md @@ -0,0 +1,36 @@ +# Liquid Filters + +MetaGlot renders output files through [Liquid](https://shopify.github.io/liquid/) +templates. The filters MetaGlot provides are grouped by rendering target and +described in this document. + +The rendering environment runs in strict mode: referencing a variable or a property +that does not exist fails the render. + +## General filters + +The following filters are available in every rendering. + +| Filter | Description | +|--------------|-------------| +| `to_string` | Convert the value to its string representation. | +| `format` | Treat the value as a Python format template and apply `str.format` with the arguments passed to the filter. | +| `to_integer` | Convert the value to an integer. | +| `to_float` | Convert the value to a float. | +| `is_integer` | Whether the value can be parsed as an integer. | +| `is_float` | Whether the value can be parsed as a float. | + +Examples: + +```liquid +{{ 1000 | to_string }} {% comment %}renders "1000"{% endcomment %} +{{ "ID: {0}" | format: 1001 }} {% comment %}renders "ID: 1001"{% endcomment %} +``` + +## RC filters + +The following filters are available only when rendering Windows RC files. + +| Filter | Description | +|-------------|-------------| +| `rc_escape` | Escapes a string for use inside a double-quoted C-style string literal, such as an RC string table entry: `\` becomes `\\` and `"` becomes `\"`. | diff --git a/doc/rc-render-context.md b/doc/rc-render-context.md index b525e88..8e83e62 100644 --- a/doc/rc-render-context.md +++ b/doc/rc-render-context.md @@ -3,42 +3,49 @@ This document describes the data that `metaglot rc render` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering Windows RC files. +MetaGlot reads templates as UTF-8 and writes the rendered file as UTF-8 (without BOM) +using the system's native line endings. This encoding is a built-in constraint of the +tool and is not configurable. Because the generated file is UTF-8, templates should +declare the encoding with `#pragma code_page(65001)` so that the RC compiler parses +the file correctly (see the `example` folder for a complete sample). + The renderer runs in strict mode: referencing a variable or a property that does not -exist fails the render. Output is written as UTF-8 (without BOM) using LF line endings. +exist fails the render. ## Top-level variables | Variable | Type | Description | |-------------|-------------------|-------------| -| `languages` | array of language | One entry per language, see below. The English (`en`) entry is always present and comes first; remaining entries follow in the order their PO files are matched. | +| `languages` | array of language | One entry per language, see below. | + +The order of the entries is not guaranteed: they appear in the order their PO files +are matched. The presence of an English entry is not guaranteed either. An English +entry exists only if an English PO file is provided; otherwise it is simply absent, +and templates must handle that themselves (the text of every entry already falls +back to the manifest source string, which is typically English). + +The language of a PO file is resolved from its `Language:` header, falling back to +the file name without extension; only values valid as Gettext `Language` fields are +accepted. Languages without a Windows language mapping, two PO files describing the +same language, and PO entries unknown to the manifest are rejected. ## Language object | Property | Type | Description | |---------------|--------|-------------| | `name` | string | Human-readable language name, e.g. `English (United States)`. | -| `locale` | string | Gettext-style locale code, e.g. `en`, `zh_CN`. | | `langid` | string | Windows language identifier in hexadecimal, e.g. `0x409`. | | `primary` | string | Windows primary language identifier in hexadecimal, e.g. `0x09`. | | `sublanguage` | string | Windows sublanguage identifier in hexadecimal, e.g. `0x01`. | -| `block_key` | string | Eight hex digits combining `langid` and code page 1200 (`0x4B0`), suitable as a `StringFileInfo` block key, e.g. `040904B0`. | -| `strings` | array of string entry | All manifest entries, in manifest order, see below. | -| `named` | mapping of string to string | Maps every entry key to its translated text, convenient for direct access such as `lang.named.file_description`. | +| `block_key` | string | Eight hex digits combining `langid` and the Unicode code page, suitable as a `StringFileInfo` block key, e.g. `040904B0`. | +| `code_page` | string | The version resource code page in decimal, always `1200` (Unicode, `0x04B0`). It is fixed by MetaGlot's UTF-8 read/write pipeline and shares its source with `block_key`; templates must not attempt to use another code page. | +| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.file_description`. | -The locale of a PO file is resolved from its `Language:` header, falling back to the -file name without extension. Locales without a Windows language mapping are rejected. - -## String entry object - -| Property | Type | Description | -|-----------|--------|-------------| -| `key` | string | The entry key from the manifest, e.g. `100` or `file_description`. Numeric resource IDs are represented as strings. | -| `msgid` | string | The untranslated source string. | -| `text` | string | The translated text for this language. Falls back to `msgid` when the entry is untranslated or marked fuzzy. Provided raw, without any escaping. | -| `context` | string | The entry context from the manifest, e.g. `File type name for: jpg, jpeg, jfif`. | +The resolved text of an entry falls back to the manifest source string when the +entry is untranslated, missing from the PO file, or marked fuzzy. ## Filters -| Filter | Description | -|-------------|-------------| -| `rc_escape` | Escapes a string for use inside a double-quoted RC string literal: `\` becomes `\\` and `"` becomes `\"`. | +All filters described in [filters.md](filters.md) are available when rendering RC +files: the general filters available in every rendering, and the RC filters such +as `rc_escape`. diff --git a/example/example.rc.liquid b/example/example.rc.liquid index 5a2caaf..5f956d5 100644 --- a/example/example.rc.liquid +++ b/example/example.rc.liquid @@ -10,8 +10,7 @@ STRINGTABLE BEGIN {% for i in (1000..1002) %} {% assign key = i | to_string %} -{% assign s = lang.named[key] %} - {{ s.key }} "{{ s.text | rc_escape }}" // {{ s.comment }} + {{ i }} "{{ lang.strings[key] | rc_escape }}" {% endfor %} END {% endfor %} @@ -35,18 +34,18 @@ BEGIN BEGIN VALUE "Comments", "https://github.com/SarasasChipWorkshop/metaglot" VALUE "CompanyName", "Sarasa's Chip Workshop" - VALUE "FileDescription", "{{ lang.named.file_description | rc_escape }}" + VALUE "FileDescription", "{{ lang.strings.file_description | rc_escape }}" VALUE "FileVersion", "1.0.0.0" VALUE "InternalName", "example" VALUE "LegalCopyright", "MIT/Expat License - Copyright (C) 2026 Sarasa's Chip Workshop" VALUE "OriginalFilename", "example.exe" - VALUE "ProductName", "{{ lang.named.product_name | rc_escape }}" + VALUE "ProductName", "{{ lang.strings.product_name | rc_escape }}" VALUE "ProductVersion", "1.0.0.0" END {% endfor %} END BLOCK "VarFileInfo" BEGIN - VALUE "Translation", {% for lang in languages %}{{ lang.langid }}, 1200{% unless forloop.last %}, {% endunless %}{% endfor %} + VALUE "Translation", {% for lang in languages %}{{ lang.langid }}, {{ lang.code_page }}{% unless forloop.last %}, {% endunless %}{% endfor %} END END diff --git a/src/metaglot/cmds/rc/render.py b/src/metaglot/cmds/rc/render.py index 8d87c2d..a0a4e9a 100644 --- a/src/metaglot/cmds/rc/render.py +++ b/src/metaglot/cmds/rc/render.py @@ -1,11 +1,50 @@ +import logging + +from ... import winlang from ...cli import RcRenderOpts -from ...render import render +from ...filters import register_general_filters, register_rc_filters +from ...langid import PoLang +from ...manifest import Manifest, load_manifest +from ...pofile import LanguagePack, resolve_glob_files, resolve_translations +from ...render import create_environment, render + +_CODE_PAGE = 1200 +"""The Unicode (UTF-16) code page used by VERSIONINFO resources. + +MetaGlot reads and writes everything as UTF-8, so this code page is a fixed +constraint of the pipeline, not a configuration point. +""" + + +def _lookup_windows_language(lang: PoLang) -> winlang.WindowsLanguage: + try: + return winlang.lookup(lang.value) + except KeyError: + raise ValueError(f"language '{lang}' has no Windows language mapping") from None + + +def _language_context(pack: LanguagePack, manifest: Manifest) -> dict: + language = _lookup_windows_language(pack.lang) + return { + "name": language.name, + "langid": f"0x{language.langid:X}", + "primary": f"0x{language.primary:02X}", + "sublanguage": f"0x{language.sublanguage:02X}", + "block_key": f"{language.langid:04X}{_CODE_PAGE:04X}", + "code_page": f"{_CODE_PAGE}", + "strings": pack.translations, + } def run(opts: RcRenderOpts) -> None: - render( - opts.in_manifest, - opts.in_po, - opts.in_template, - opts.out_rc, + manifest = load_manifest(opts.in_manifest) + packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) + languages = [_language_context(pack, manifest) for pack in packs.values()] + logging.info( + "rendering languages: %s", + ", ".join(language["name"] for language in languages), ) + env = create_environment() + register_general_filters(env) + register_rc_filters(env) + render(env, opts.in_template, {"languages": languages}, opts.out_rc) diff --git a/src/metaglot/filters.py b/src/metaglot/filters.py index 81433e0..a779677 100644 --- a/src/metaglot/filters.py +++ b/src/metaglot/filters.py @@ -18,6 +18,10 @@ def _is_float(s: str) -> bool: def register_general_filters(env: Environment) -> None: + """Register the general-purpose Liquid filters available in every rendering. + + :param env: The environment to register the filters on. + """ env.add_filter("to_string", str) env.add_filter("format", str.format) env.add_filter("to_integer", int) @@ -30,5 +34,9 @@ def _rc_escape(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') -def register_rc_specific_filters(env: Environment) -> None: +def register_rc_filters(env: Environment) -> None: + """Register the Liquid filters specific to Windows RC file rendering. + + :param env: The environment to register the filters on. + """ env.add_filter("rc_escape", _rc_escape) diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index 1797843..79308ab 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -49,7 +49,7 @@ def resolve_glob_files(patterns: Iterable[Path]) -> Iterator[Path]: def resolve_translations( - manifest: Manifest, po_paths: Iterator[Path] + manifest: Manifest, po_paths: Iterable[Path] ) -> dict[PoLang, LanguagePack]: """Resolve the translations of the given PO files against a manifest. @@ -141,7 +141,7 @@ def _load_po(path: Path) -> polib.POFile: """ try: return polib.pofile(str(path)) - except (OSError, IOError) as exc: + except OSError as exc: raise RuntimeError(f"failed to read PO file '{path}': {exc}") from exc diff --git a/src/metaglot/render.py b/src/metaglot/render.py index 280326a..ba15f01 100644 --- a/src/metaglot/render.py +++ b/src/metaglot/render.py @@ -4,86 +4,34 @@ from pathlib import Path from liquid import Environment, StrictUndefined from liquid.exceptions import LiquidError -from . import winlang -from .langid import PoLang -from .manifest import Manifest, load_manifest -from .pofile import LanguagePack, resolve_glob_files, resolve_translations +def create_environment() -> Environment: + """Create a Liquid environment for rendering. -def _rc_escape(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') + The environment runs in strict mode: referencing a variable or a property + that does not exist fails the render. No filters are registered; callers + register what they need with the functions of the ``filters`` module. - -def _create_environment() -> Environment: - env = Environment(undefined=StrictUndefined) - env.add_filter("rc_escape", _rc_escape) - return env - - -def _language_context( - locale: str, - language: winlang.WindowsLanguage, - pack: LanguagePack, - manifest: Manifest, -) -> dict: - return { - "name": language.name, - "locale": locale, - "langid": f"0x{language.langid:X}", - "primary": f"0x{language.primary:02X}", - "sublanguage": f"0x{language.sublanguage:02X}", - "block_key": f"{language.langid:04X}04B0", - "strings": [ - { - "key": key, - "msgid": entry.msgid, - "text": pack.translations[key], - "context": entry.context, - } - for key, entry in manifest.strings.items() - ], - "named": pack.translations, - } - - -def _lookup_windows_language(lang: PoLang) -> winlang.WindowsLanguage: - try: - return winlang.lookup(lang.value) - except KeyError: - raise ValueError( - f"language '{lang}' has no Windows language mapping" - ) from None + :return: The created environment. + """ + return Environment(undefined=StrictUndefined) def render( - manifest_path: Path, - po_patterns: list[Path], - template_path: Path, - output_path: Path, + env: Environment, template_path: Path, context: dict, output_path: Path ) -> None: - manifest = load_manifest(manifest_path) - packs = resolve_translations(manifest, resolve_glob_files(po_patterns)) + """Render a Liquid template and write the result to a file. - english = winlang.lookup("en") - resolved = [(pack, _lookup_windows_language(pack.lang)) for pack in packs.values()] - english_item = next( - (item for item in resolved if item[1].langid == english.langid), None - ) - others = [item for item in resolved if item[1].langid != english.langid] - if english_item is None: - english_pack = LanguagePack( - PoLang("en"), - {key: entry.msgid for key, entry in manifest.strings.items()}, - ) - ordered = [(english_pack, english), *others] - else: - ordered = [english_item, *others] - - languages = [ - _language_context(pack.lang.value, language, pack, manifest) - for pack, language in ordered - ] + The template is read as UTF-8. The rendered output is written as UTF-8 + using the system's native line endings. + :param env: The environment to render with. + :param template_path: The path of the template file. + :param context: The top-level variables passed to the template. + :param output_path: The path of the rendered file to write. + :raises ValueError: If the template file does not exist. + :raises RuntimeError: If the template cannot be read or rendered. + """ if not template_path.is_file(): raise ValueError(f"template file not found: {template_path}") try: @@ -91,21 +39,15 @@ def render( except OSError as exc: raise RuntimeError(f"failed to read template '{template_path}': {exc}") from exc - env = _create_environment() try: template = env.from_string( template_source, name=str(template_path), path=template_path ) - content = template.render(languages=languages) + content = template.render(**context) except LiquidError as exc: raise RuntimeError( f"failed to render template '{template_path}': {exc}" ) from exc - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(content, encoding="utf-8", newline="\n") - logging.info( - "wrote %s with languages: %s", - output_path, - ", ".join(language["name"] for language in languages), - ) + output_path.write_text(content, encoding="utf-8") + logging.info("Wrote rendered output to %s", output_path)