Skip to content

Latest commit

 

History

History
231 lines (195 loc) · 12.5 KB

File metadata and controls

231 lines (195 loc) · 12.5 KB

Languages

Schist's chrome picks the first supported language the operating system says the user prefers. The shipped languages are listed in crates/i18n/locales.tsv; crates/i18n/data/iso-639-1.tsv holds the wider ISO language inventory used when preparing additional translations. The strings live in crates/i18n; this is how they get there, how the language is chosen on each platform, and what to do when adding a control or a language.

The ISO coverage report records the translation expansion's remaining gaps and verification limits. Presence in the ISO inventory does not mean that a language is shipped.

Which language

schist_i18n::init() runs first thing in schist_app::main, before the plugin registry is built (a command's title is read as it registers, and the menus are built from those). It asks the OS for the user's preferred languages in order and takes the first one Schist has, matching on the language alone — sv-FI is Swedish, de-AT German, zh-TW Chinese — and falling back to English:

Platform Where the preference comes from
macOS, iOS CFLocaleCopyPreferredLanguages: the Language & Region list, or the per-app language when one is set in Settings. CFBundleLocalizations in the Info.plists is what makes iOS offer the per-app choice.
Windows GetUserPreferredUILanguages: the display language.
Linux LANGUAGE, then LC_ALL, LC_MESSAGES, LANG; the usual POSIX spellings (sv_SE.UTF-8) are understood.
Android The activity's configuration, which is the per-app language when one is set (locales_config.xml in the manifest lists ours) and the device's otherwise; then the system properties.
Web navigator.languages. The loading page reads the same list for its own few strings and sets <html lang> and text direction.

SCHIST_LANG=de in the environment overrides all of this for one run on a native build; it is how a translation is checked without changing the system. The choice is made once at startup: changing the system language while Schist runs takes effect on the next launch, which is what the manifest's configChanges="…|locale" on Android asks for too.

The generic Norwegian tag no uses the Bokmål edition (nb). Nynorsk has its own nn translation. The Serbo-Croatian macrolanguage tag sh uses the Croatian edition (hr); Bosnian (bs) and Serbian (sr) have their own translations. tools/sync-i18n.py generates the generic catalogs and loaders from their source editions, so later changes stay in sync.

Every zh request lands on the Simplified catalog, Traditional included. A Traditional catalog would be a locales/zh-Hant directory and a Locale variant, and from_tag would then look at the script subtag.

The headless MCP server (schist-mcp) pins English: its tool catalog is read by a model, not a person, and the command descriptions are the tool descriptions.

In code

use schist_i18n::{t, tf, tn};

Button::new("ok", t("common.ok"))
ws.status = tf!("dialog.export.saved_as", name = path.display()).into();
let count = tn("common.n_layers", doc.tree.len() as u64);

t returns &'static str — each catalog is decompressed once on first use and kept for the process's lifetime — so the plugin traits keep their fn name(&self) -> &'static str signatures and the menu model its &'static str labels. A tool's dropdown, declared as a &'static [&'static str], goes through schist_i18n::choices, which translates a list of keys once per language and keeps it:

static AUTO_TARGETS: &[&str] = &["tool.move.target.layer", "tool.move.target.group"];
ToolOption::choice("target", t("tool.move.option.auto_select_target"), choices(AUTO_TARGETS), 0)

What not to translate: identifiers (tool.id(), command ids, option keys, PSD block names, the names of blend modes and adjustments as the file knows them), log lines, anything an agent reads over MCP, and the product names (Schist, Schist Cloud, Photoshop, Camera Raw, Neural Filters). Accepted input words such as any/yes/no and all/safe/flagged in the cloud filter fields also remain literal; tools/check-i18n.py checks their translated error messages and product names. Navigation instructions for the AI panel and model downloads must use their translated menu labels; the full catalog audit checks that each path matches. A string compared against something that arrives untranslated — a Photoshop plug-in's PiPL category, say — is compared with t_in(Locale::En, key), as panels::menus::add_photoshop_plugins does.

The kernel (crates/core) stays out of it. Its BlendMode::display_name and AdjustmentKind::display_name are the English names the PSD format uses; the app's ui::blend_mode_name and ui::adjustment_name are the localised ones, and chrome uses those.

Word order differs between languages, so a sentence is one string with placeholders, never a verb glued to a noun: menu.view.hide_rulers exists rather than menu.view.hide + menu.view.rulers. Counts use tn, which selects the language's integer cardinal category: .zero, .one, .two, .few, .many, or .other. Chinese and Japanese use only .other; other languages can require several forms, and .one does not always mean exactly 1. The rules come from Unicode CLDR 48, vendored with its license under crates/i18n/data. Regenerate the Rust rules and validator's category list with python3 tools/generate-i18n-plurals.py. The generated tests check CLDR's integer examples. Languages without CLDR rules use .other; their count strings must use wording that works with every count.

Numbers are not localised: fields show and parse a . decimal separator in every language, and dates are ISO. Locale metadata and translated strings do not change those input formats or mirror the native panel layout.

The catalogs

crates/i18n/locales/<tag>/*.lang, one directory per language and one file per area of the app, joined by the crate's build.rs:

common.lang       OK, Cancel, Width, Height, the file types — what every area shares
menu.lang         the menu bar (both implementations build from one model)
blend.lang        blend-mode names;  adjustments.lang  adjustment names
commands.lang     plugins/commands-core: command titles, descriptions, history entries
tools-<name>.lang one per tool plugin (tools-paint.lang, tools-select.lang, …)
filters.lang      the Filter menu's categories, then every filter and its parameters
codecs.lang       plugins/codecs-common: format names and import/export messages
panels.lang, dialogs.lang, workspace.lang, workspace-canvas.lang, library.lang, cloud.lang, ai.lang, app.lang
                  the application crates by module (the widget kit, crates/ui, takes every label from its caller and has none of its own)

build.rs generates a shared dictionary of up to 32 KiB from recurring keys and values in the first 32 KiB of the registered catalogs. DEFLATE's window limits how long a preset dictionary remains useful. The selection is deterministic and updates automatically when a catalog changes. Each joined language is compressed separately into OUT_DIR, then embedded with include_bytes! alongside the one dictionary. The format is zlib-wrapped DEFLATE: gzip does not support preset dictionaries. The pure Rust zlib-rs backend works on native and wasm builds.

On first lookup, the requested catalog is inflated with that dictionary, its checksum, length and UTF-8 are validated, and its text and parsed entries are cached with OnceLock. Languages that are never requested stay compressed. Switching locales reuses any catalog already loaded; references previously returned by t remain valid.

The format is one key = value per line; # comments; \n and \\ the only escapes; {name} a placeholder. Keys are area.item in snake case and the area names the file: menu.file.new is in menu.lang, tool.move.name in tools-basic.lang. Plugin-provided things key by the id the plugin registers — tool.<id>.name, tool.<id>.description, tool.<id>.option.<key>, filter.<id>.name, filter.<id>.param.<key>, command.<id>.title, command.<id>.description — so a string is findable from the thing it labels.

make check-i18n checks the Rust catalogs, platform declarations, and web loader:

  • every language has English's ordinary keys and its own required plural forms;
  • placeholders match English's, so a translation cannot drop {name};
  • every plural stem supplies the language's integer categories; extra categories preserve the placeholders from English's .other form. A translated .one may add {n} when English leaves it implicit: some languages use this category for counts such as 0 or 21, which must still be shown. The validators check this against the vendored CLDR integer examples;
  • no key is defined twice, none is empty, and every one is snake case with an area prefix;
  • every key the source tree asks for — every t("…"), tf!("…"), tn("…") and choices(&[…]) under crates/ and plugins/ — is defined in English.

A missing key is a bug, not a condition: t falls back to English, and past that to the key itself, logging once, so a control is never blank.

The Rust tests also check that every compressed catalog matches its source files, damaged streams are rejected, and the dictionary saves more bytes than it costs to embed. make check-i18n-wasm checks compilation for the browser target (requires wasm32-unknown-unknown to be installed).

Adding a string

  1. Put the English in the area's file, area.thing = Text.
  2. Add the same key to every language in locales.tsv. Photoshop's own localisations are the reference for its terms (Ebene, lager, 图层); the catalogs' comments say so where a term was taken from one.
  3. Use t("area.thing") in code. Run make check-i18n.

Adding a language

  1. Translate all files in locales/en/ into locales/<tag>/, preserving keys and placeholders. Use the categories in data/plural-categories.json for the language's plural stems. A stem is plural when English has both .one and .other; ordinary headings such as filter.category.other keep their original key. Translate complete sentences, not individual words.
  2. Validate with python3 tools/check-i18n.py --locale <tag> --audit and review the actual language. Structural checks cannot certify translation quality. --files checks selected files while work is in progress.
  3. Add the completed locale's record to locales.tsv: tag, Rust variant, native name, direction, and ISO 15924 script, separated by tabs. build.rs generates Locale::ALL, the metadata, and embedded sources together. Catalogs are decompressed and parsed on first use, so startup does not load every language.
  4. Translate web/locales/en.json into web/locales/<tag>.json, then run python3 tools/sync-i18n.py. This updates the generated loader dictionary, the macOS and iOS CFBundleLocalizations arrays, and Android's locale list.
  5. Ensure the web fonts cover the script and catalog characters, then run make check-i18n. python3 tools/check-i18n.py --all-iso additionally requires every language in the ISO inventory to be translated and registered.

Fonts

Natively the UI draws with the system font, and every platform falls back to a system face for a script it lacks — PingFang and Hiragino on macOS and iOS, Microsoft YaHei and Yu Gothic on Windows, Noto Sans CJK on Android and on any Linux with a CJK font installed (fonts-noto-cjk on Debian, noto-fonts-cjk on Arch); without one the Chinese or Japanese chrome shows as boxes, the way any Linux app's would.

A browser exposes no system fonts to a WebGPU canvas, so the web build ships its fonts. IBM Plex Sans is the primary UI face. Noto supplies additional Latin, Cyrillic, Greek, and script-specific faces; the mapping in web/fonts/locales.json ensures each browser fetches only the faces for its selected locale. python3 tools/web-i18n-fonts.py fetches the complete non-CJK script faces and the Korean regional face, with their copyright and license information. Keeping their shaping tables intact is necessary for connected and combining scripts.

Chinese and Japanese keep their separate regional subsets, NotoSansSC-Schist.otf and NotoSansJP-Schist.otf. Shared Han characters can have different regional shapes, so readers receive the face for their language. tools/web-cjk-font.sh regenerates those two subsets and must be rerun when catalog characters are missing from them. Font coverage checks run with make check-i18n.