From 6f9a712813aca2af39c60da80e439d6b43f24121 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Mon, 21 Sep 2026 20:08:27 +0800 Subject: [PATCH] feat: add InnoSetup file support --- DEVNOTE.md | 11 + doc/filters.md | 8 + doc/inno-render-context.md | 98 +++++++++ example/example.isl.liquid | 4 + example/example.iss.liquid | 30 +++ example/example.iss.toml | 14 ++ src/metaglot/cmds/render/__init__.py | 13 +- src/metaglot/cmds/render/inno.py | 297 +++++++++++++++++++++++++++ src/metaglot/filters.py | 22 ++ 9 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 doc/inno-render-context.md create mode 100644 example/example.isl.liquid create mode 100644 example/example.iss.liquid create mode 100644 example/example.iss.toml create mode 100644 src/metaglot/cmds/render/inno.py diff --git a/DEVNOTE.md b/DEVNOTE.md index 6ccdc9f..616e571 100644 --- a/DEVNOTE.md +++ b/DEVNOTE.md @@ -51,6 +51,17 @@ Based on these document, we know`.plist` file is ordinary XML file, so we create [Apple document](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledevelopmentregion) doesn't explicitly declare the standard of `` part in expected `.strings` file path `Contents/Resources/.lproj/InfoPlist.strings`. We only can tract its history based on [Apple old document](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html). First it is "human-readable names". such as `English`, `German`, and etc. Then it becomes `language_region`, like `en_GB`. And finally, it possible be a BCP 47 string now based on multiple modern Apple documents. So we are forced to follow this vague standard to design the representation of it and may change it in future if we meet with some issues or more clear standard specification. +### InnoSetup Script Files + +InnoSetup installation involves multiple file formats. All formats of them can be browsed from their official documents: + +- `.iss` script file: https://jrsoftware.org/ishelp/index.php?topic=languages (the `[Languages]` section) and https://jrsoftware.org/ishelp/index.php?topic=custommessages (the `[CustomMessages]` section) +- `.isl` messages file: https://jrsoftware.org/ishelp/index.php?topic=langoptions (the `[LangOptions]` section) and https://jrsoftware.org/ishelp/index.php?topic=messages (the `[Messages]` section) + +Based on these document, we create corresponding Liquid filters for both files, since they share the same message value format. + +Unlike the other targets, InnoSetup has no standard representation of language identifiers: both the internal language names declared in the `[Languages]` section and the message file names are chosen freely by the packager, as its official document says. So there is no language representation or convertion code for this target. The PO language tag carries over verbatim, and the pairing between a language and its base `.isl` file is expressed explicitly by the template author. + ## Bump Version Up ### Bump Application Version Up diff --git a/doc/filters.md b/doc/filters.md index 216ab5e..c8bed6a 100644 --- a/doc/filters.md +++ b/doc/filters.md @@ -83,3 +83,11 @@ The following filters are available when rendering macOS bundle files. `xml_esca |---|---|---| | `xml_escape` | Info.plist | The same filter described in the AppStream section above; Info.plist is XML too. | | `plist_strings_escape` | InfoPlist.strings | Escapes a string for use inside an InfoPlist.strings value: `\` becomes `\\`, `"` becomes `\"`, newline, carriage return and tab become `\n`, `\r` and `\t`. Non-ASCII characters carry over unchanged; use the `\Uxxxx` form manually for the rare cases a literal escape is required. | + +## Inno Filters + +The following filter is available to both the `.iss` and the `.isl` file renderings of the Inno Setup pipeline, which share the same message value escaping. + +| Filter | Description | +|---|---| +| `inno_escape` | Escapes a string for use inside an Inno Setup message value: linefeed and carriage return become `%n`. The `%1`..`%9` argument placeholders and a literal `%` are left untouched - the `%%` escape is only defined for messages that take arguments, so managing `%` is the translator's responsibility. | diff --git a/doc/inno-render-context.md b/doc/inno-render-context.md new file mode 100644 index 0000000..f5699dd --- /dev/null +++ b/doc/inno-render-context.md @@ -0,0 +1,98 @@ +# Inno Render Context + +This document describes the data that `metaglot render inno` provides to user-provided [Liquid](https://shopify.github.io/liquid/) templates when rendering Inno Setup installation files. + +Like the MSIX and plist renderings, an Inno rendering involves multiple output files: one `.iss` script referencing localized custom messages through `{cm:...}` constants, plus one incremental `.isl` messages file per language carrying the `[CustomMessages]` entries. The output location of every file is resolved by a path template command line argument instead of a fixed output file argument. + +MetaGlot reads templates as UTF-8 and writes every rendered file as UTF-8 (without BOM) using the system's native line endings. Inno Setup 6.3 and later recommend exactly this encoding for `.iss` and `.isl` files, so the output is used as-is. The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render. + +Every component below renders with its **own environment**, so the filters available to one component never leak into another. Path templates resolve to paths relative to the output folder (an absolute rendered path is used as-is); their result is stripped of surrounding whitespace and must not be empty. + +## Language Identification + +Inno Setup has **no standard representation of language identifiers**: the `[Languages]` internal `Name:` and the message file names are both chosen freely by the packager. MetaGlot therefore performs **no conversion** for this target - the PO language tag (`zh_CN`, `en`) carries over verbatim, and the same tag serves as the single join key across everything the pipeline generates: + +- the `Name:` value of a `[Languages]` entry, +- the incremental `.isl` file name (e.g. `Strings\zh_CN.isl`), +- and the matching branch of the base file pairing in the `.iss` template (see below). + +The pairing between a language and its base `.isl` - the official full translation whose `[LangOptions]` provides the language identity (the Select Language dialog name and the auto-detection identifier) - is knowledge of the template author and is expressed explicitly, for example: + +```liquid +{% for lang in langs %} +{% case lang %} +{% when 'zh_CN' %} +{% assign base = 'compiler:Languages\ChineseSimplified.isl' %} +{% when 'ja' %} +{% assign base = 'compiler:Languages\Japanese.isl' %} +{% else %} +{% assign base = 'compiler:Default.isl' %} +{% endcase %} +Name: "{{ lang }}"; MessagesFile: "{{ base }},Strings\{{ lang }}.isl" +{% endfor %} +``` + +The multi-line decision block renders some blank lines into the INI output, which is harmless; Liquid's whitespace control markers (`{%-`/`-%}`) can remove them once used consistently across templates. + +Keep the `case` branches aligned with the languages you ship PO files for: an unmatched language falls to the `{% else %}` base (usually `compiler:Default.isl`) and inherits the English language identity. Review the rendered `[Languages]` section after adding a new language. + +## Path Template Contexts + +The `--iss-path` and `--isl-path` arguments are in-memory Liquid templates rendered into output paths. Only the general filters described in [filters.md](filters.md) are available to them. + +### `--iss-path` + +| Variable | Type | Description | +|---|---|---| +| (none) | | The context is empty: the path is typically the plain literal `example.iss`. | + +The template is rendered once. Referencing any variable fails the render. + +### `--isl-path` + +| Variable | Type | Description | +|---|---|---| +| `lang` | string | The PO language tag of the `.isl` messages file being placed, e.g. `en`, `zh_CN`. | + +The template is rendered once per language, for example: + +```liquid +Strings/{{ lang }}.isl +``` + +## File Render Contexts + +The `--iss-template` and `--isl-template` arguments point to template files. Besides the general filters, the Inno filter (`inno_escape`) is available to both. + +### `--iss-template` + +| Variable | Type | Description | +|---|---|---| +| `default` | language | The manifest's source language entry, see below. | +| `langs` | array of string | The PO language tag of every language, source language first. | + +The `.iss` script references the localized text through `{cm:...}` constants (e.g. `AppName={cm:app_name}`) and enumerates `[Languages]` from `langs` with the base file pairing shown above. `default` is available for values written directly instead of via constants: + +```liquid +AppName={{ default.strings.app_name | inno_escape }} +``` + +The source language always leads `langs` and always gets an `.isl` messages file: it comes from a PO file when one provides it, and is otherwise synthesized from the manifest's source strings. + +### `--isl-template` + +The members of the current language are exposed at the top level. The structure is identical for every language; only the data differs. There is no `default`. + +| Variable | Type | Description | +|---|---|---| +| `name` | string | The PO language tag of this `.isl` messages file, e.g. `en_US`, `zh_CN`. | +| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `strings.app_name`. | + +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. The template is rendered once per language. + +The recommended shape of a generated `.isl` is a plain `[CustomMessages]` section: language identity (the `[LangOptions]` fields) is inherited from the base `.isl` listed before it in `MessagesFile`, following the merge order of Inno Setup. + +```liquid +[CustomMessages] +app_name={{ strings.app_name | inno_escape }} +``` diff --git a/example/example.isl.liquid b/example/example.isl.liquid new file mode 100644 index 0000000..494a994 --- /dev/null +++ b/example/example.isl.liquid @@ -0,0 +1,4 @@ +[CustomMessages] +app_name={{ strings.app_name | inno_escape }} +app_versioned_name={{ strings.app_versioned_name | inno_escape }} +task_desktop_icon={{ strings.task_desktop_icon | inno_escape }} diff --git a/example/example.iss.liquid b/example/example.iss.liquid new file mode 100644 index 0000000..974413a --- /dev/null +++ b/example/example.iss.liquid @@ -0,0 +1,30 @@ +[Setup] +AppId=ExampleApp +AppName={cm:app_name} +AppVersion=1.0.0 +AppVerName={cm:app_versioned_name,1.0.0} +DefaultDirName={autopf}\{cm:app_name} +OutputBaseFilename=example-setup +Compression=lzma +SolidCompression=yes + +[Languages] +{% for lang in langs %} +{% case lang %} +{% when 'zh_CN' %} +{% assign base = 'compiler:Languages\ChineseSimplified.isl' %} +{% when 'ja' %} +{% assign base = 'compiler:Languages\Japanese.isl' %} +{% else %} +{% assign base = 'compiler:Default.isl' %} +{% endcase %} +Name: "{{ lang }}"; MessagesFile: "{{ base }},Strings\{{ lang }}.isl" +{% endfor %} +[Tasks] +Name: "desktopicon"; Description: "{cm:task_desktop_icon}" + +[Files] +Source: "example.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{autodesktop}\{cm:app_name}"; Filename: "{app}\example.exe"; Tasks: desktopicon diff --git a/example/example.iss.toml b/example/example.iss.toml new file mode 100644 index 0000000..0544ce4 --- /dev/null +++ b/example/example.iss.toml @@ -0,0 +1,14 @@ +version = 1 +source_language = "en" + +[strings.app_name] +msgid = "Example" +comment = "Name of the application, referenced as {cm:app_name} in the .iss script." + +[strings.app_versioned_name] +msgid = "Example %1" +comment = "Versioned name of the application, referenced as {cm:app_versioned_name,}. The %1 placeholder receives the version." + +[strings.task_desktop_icon] +msgid = "Create a desktop shortcut" +comment = "Description of the desktop icon task, referenced as {cm:task_desktop_icon}." diff --git a/src/metaglot/cmds/render/__init__.py b/src/metaglot/cmds/render/__init__.py index 0f76d89..48fe26d 100644 --- a/src/metaglot/cmds/render/__init__.py +++ b/src/metaglot/cmds/render/__init__.py @@ -2,12 +2,13 @@ import enum from argparse import ArgumentParser from dataclasses import dataclass from typing import Any -from . import rc, desktop, appstream, msix, plist +from . import rc, desktop, appstream, msix, plist, inno from .rc import RcRenderOpts from .desktop import DesktopRenderOpts from .appstream import AppStreamRenderOpts from .msix import MsixRenderOpts from .plist import PlistRenderOpts +from .inno import InnoRenderOpts _SUBCMD_DEST = "metadata" @@ -20,6 +21,7 @@ class RenderOpts: | AppStreamRenderOpts | MsixRenderOpts | PlistRenderOpts + | InnoRenderOpts ) """The option of one of subcommand.""" @@ -30,6 +32,7 @@ class _Metadata(enum.StrEnum): Appstream = "appstream" Msix = "msix" Plist = "plist" + Inno = "inno" def register(parser: ArgumentParser) -> None: @@ -54,6 +57,10 @@ def register(parser: ArgumentParser) -> None: _Metadata.Plist.value, help="Render macOS bundle Info.plist files." ) plist.register(subcmd) + subcmd = cmds.add_parser( + _Metadata.Inno.value, help="Render Inno Setup installation files." + ) + inno.register(subcmd) def parse(args: dict[str, Any]) -> RenderOpts: @@ -68,6 +75,8 @@ def parse(args: dict[str, Any]) -> RenderOpts: opts = RenderOpts(msix.parse(args)) case _Metadata.Plist: opts = RenderOpts(plist.parse(args)) + case _Metadata.Inno: + opts = RenderOpts(inno.parse(args)) case _: raise ValueError(f"unhandled render command: {args[_SUBCMD_DEST]}") return opts @@ -85,5 +94,7 @@ def run(opts: RenderOpts) -> None: msix.run(msix_opts) case PlistRenderOpts() as plist_opts: plist.run(plist_opts) + case InnoRenderOpts() as inno_opts: + inno.run(inno_opts) case _: raise RuntimeError(f"unhandled render options: {opts.opts!r}") diff --git a/src/metaglot/cmds/render/inno.py b/src/metaglot/cmds/render/inno.py new file mode 100644 index 0000000..ac94a51 --- /dev/null +++ b/src/metaglot/cmds/render/inno.py @@ -0,0 +1,297 @@ +import logging +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from ...filters import register_general_filters, register_inno_filters +from ...manifest import load_manifest +from ...pofile import ( + LanguagePack, + build_default_pack, + resolve_glob_files, + resolve_translations, +) +from ...render import create_environment, render, render_path + +_MANIFEST_DEST = "manifest" +_PO_DEST = "po" +_ISS_TEMPLATE_DEST = "iss_template" +_ISL_TEMPLATE_DEST = "isl_template" +_ISS_PATH_DEST = "iss_path" +_ISL_PATH_DEST = "isl_path" +_OUTPUT_DEST = "output" + + +@dataclass(frozen=True) +class InnoRenderOpts: + in_manifest: Path + """The path to input manifest file including translation strings.""" + in_po: list[Path] + """The path to input PO files to read, each may be a glob pattern.""" + in_iss_template: Path + """The path to the input user-provided Liquid for the .iss script file.""" + in_isl_template: Path + """The path to the input user-provided Liquid for the .isl messages files.""" + in_iss_path: str + """The Liquid template resolving the output path of the .iss script file.""" + in_isl_path: str + """The Liquid template resolving the output path of every .isl messages file.""" + out_dir: Path + """The path of the output folder holding every rendered file.""" + + +def register(parser: ArgumentParser) -> None: + parser.add_argument( + "-m", + "--manifest", + dest=_MANIFEST_DEST, + action="store", + type=Path, + required=True, + help="The path to input manifest file including translation strings.", + metavar="FILE", + ) + parser.add_argument( + "-p", + "--po", + dest=_PO_DEST, + action="extend", + nargs="+", + type=Path, + required=True, + help="The PO files to read, each may be a glob pattern.", + metavar="GLOB", + ) + parser.add_argument( + "--iss-template", + dest=_ISS_TEMPLATE_DEST, + action="store", + type=Path, + required=True, + help="The path to the input user-provided Liquid for the .iss script file.", + metavar="FILE", + ) + parser.add_argument( + "--isl-template", + dest=_ISL_TEMPLATE_DEST, + action="store", + type=Path, + required=True, + help=( + "The path to the input user-provided Liquid for the .isl " + "messages files." + ), + metavar="FILE", + ) + parser.add_argument( + "--iss-path", + dest=_ISS_PATH_DEST, + action="store", + type=str, + required=True, + help=( + "The Liquid template resolving the output path of the .iss " + "script file, relative to the output folder." + ), + metavar="TEMPLATE", + ) + parser.add_argument( + "--isl-path", + dest=_ISL_PATH_DEST, + action="store", + type=str, + required=True, + help=( + "The Liquid template resolving the output path of every .isl " + "messages file, relative to the output folder. It is rendered " + "once per language with the language tag exposed as 'lang'." + ), + metavar="TEMPLATE", + ) + parser.add_argument( + "-o", + "--output", + dest=_OUTPUT_DEST, + action="store", + type=Path, + required=True, + help="The path of the output folder holding every rendered file.", + metavar="DIR", + ) + + +def parse(args: dict[str, Any]) -> InnoRenderOpts: + return InnoRenderOpts( + args[_MANIFEST_DEST], + args[_PO_DEST], + args[_ISS_TEMPLATE_DEST], + args[_ISL_TEMPLATE_DEST], + args[_ISS_PATH_DEST], + args[_ISL_PATH_DEST], + args[_OUTPUT_DEST], + ) + + +class _InnoStringsView: + """Route template string key lookups to their resolved text.""" + + __pack: LanguagePack + """The language pack the lookups route to.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + + def __getitem__(self, key: str) -> str: + return self.__pack.translations[key].text + + +class _InnoIssPathContext: + """The Liquid-facing context of the .iss path template. + + The context is empty: the .iss path is typically a plain literal + like 'example.iss', and referencing any variable fails the render. + """ + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return () + + def __getitem__(self, key: str) -> object: + raise KeyError(key) + + +class _InnoIslPathContext: + """The Liquid-facing context of one .isl path template rendering. + + It is rendered once per language, with the PO language tag of the + messages file being placed exposed as ``lang``. Inno Setup has no + standard representation of language identifiers, so no conversion + happens: the tag is the Gettext PO tag verbatim. + """ + + __lang: str + """The PO language tag of the .isl messages file being placed.""" + + def __init__(self, lang: str): + self.__lang = lang + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("lang",) + + def __getitem__(self, key: str) -> object: + if key == "lang": + return self.__lang + raise KeyError(key) + + +class _InnoIslTemplateContext: + """The Liquid-facing top-level context of every .isl messages file + rendering. + + The members of the current language are exposed at the top level. + The structure is identical for every language; only the data differs. + The class also serves as the value of ``default`` inside the .iss + template context, where it exposes the source language the same way. + """ + + __pack: LanguagePack + """The wrapped language pack.""" + __strings: _InnoStringsView + """The view of the pack's translations.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + self.__strings = _InnoStringsView(pack) + + def keys(self) -> tuple[str, ...]: + """Return the names of the exposed template variables.""" + return ("name", "strings") + + def __getitem__(self, key: str) -> object: + match key: + case "name": + return self.__pack.lang.value + case "strings": + return self.__strings + case _: + raise KeyError(key) + + +class _InnoIssTemplateContext: + """The Liquid-facing top-level context of the .iss script file + rendering.""" + + __default: _InnoIslTemplateContext + """The view of the manifest's source language.""" + __langs: tuple[str, ...] + """The PO language tags of every language, source language first.""" + + def __init__(self, source: LanguagePack, langs: tuple[str, ...]): + self.__default = _InnoIslTemplateContext(source) + self.__langs = langs + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("default", "langs") + + def __getitem__(self, key: str) -> object: + match key: + case "default": + return self.__default + case "langs": + return self.__langs + case _: + raise KeyError(key) + + +def run(opts: InnoRenderOpts) -> None: + manifest = load_manifest(opts.in_manifest) + packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) + # The source language always leads the language list and always gets + # an .isl messages file: its entry is the fallback localization of + # the installer. + source = packs.pop(manifest.source_language, None) + if source is None: + source = build_default_pack(manifest, manifest.source_language) + ordered = [source, *packs.values()] + # Inno Setup has no standard language identifier representation, so + # the PO language tags carry over verbatim. + langs = tuple(pack.lang.value for pack in ordered) + logging.info("rendering languages: %s", ", ".join(langs)) + + # Every component renders with its own environment, so their filters + # never interfere with each other. + iss_path_env = create_environment() + register_general_filters(iss_path_env) + isl_path_env = create_environment() + register_general_filters(isl_path_env) + iss_template_env = create_environment() + register_general_filters(iss_template_env) + register_inno_filters(iss_template_env) + isl_template_env = create_environment() + register_general_filters(isl_template_env) + register_inno_filters(isl_template_env) + + iss_out = opts.out_dir / render_path( + iss_path_env, opts.in_iss_path, _InnoIssPathContext() + ) + iss_out.parent.mkdir(parents=True, exist_ok=True) + render( + iss_template_env, + opts.in_iss_template, + _InnoIssTemplateContext(source, langs), + iss_out, + ) + + for pack, lang in zip(ordered, langs): + isl_out = opts.out_dir / render_path( + isl_path_env, opts.in_isl_path, _InnoIslPathContext(lang) + ) + isl_out.parent.mkdir(parents=True, exist_ok=True) + render( + isl_template_env, + opts.in_isl_template, + _InnoIslTemplateContext(pack), + isl_out, + ) diff --git a/src/metaglot/filters.py b/src/metaglot/filters.py index 3a83447..d450ad0 100644 --- a/src/metaglot/filters.py +++ b/src/metaglot/filters.py @@ -163,3 +163,25 @@ def register_plist_strings_filters(env: Environment) -> None: :param env: The environment to register the filters on. """ env.add_filter("plist_strings_escape", _plist_strings_escape) + + +def _inno_escape(value: str) -> str: + """Escape a string for use inside an Inno Setup message value. + + Linefeed and carriage return become ``%n``. The ``%1``..``%9`` + argument placeholders and a literal ``%`` are left untouched: the + ``%%`` escape is only defined for messages that take arguments, so + managing ``%`` is the translator's responsibility. + """ + return value.replace("\n", "%n").replace("\r", "%n") + + +def register_inno_filters(env: Environment) -> None: + """Register the Liquid filters for Inno Setup file rendering. + + Both the .iss and the .isl file renderings share the same message + value escaping, so one registration function serves both. + + :param env: The environment to register the filters on. + """ + env.add_filter("inno_escape", _inno_escape)