refactor: refactor render module

This commit is contained in:
2026-09-15 16:11:49 +08:00
parent 4a82292aff
commit 08d47c3ea8
7 changed files with 145 additions and 114 deletions
+36
View File
@@ -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 `\"`. |
+27 -20
View File
@@ -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`.
+4 -5
View File
@@ -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
+45 -6
View File
@@ -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)
+9 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+22 -80
View File
@@ -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)