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.
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.
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.
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
.otherform. A translated.onemay 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("…")andchoices(&[…])undercrates/andplugins/— 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).
- Put the English in the area's file,
area.thing = Text. - 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. - Use
t("area.thing")in code. Runmake check-i18n.
- Translate all files in
locales/en/intolocales/<tag>/, preserving keys and placeholders. Use the categories indata/plural-categories.jsonfor the language's plural stems. A stem is plural when English has both.oneand.other; ordinary headings such asfilter.category.otherkeep their original key. Translate complete sentences, not individual words. - Validate with
python3 tools/check-i18n.py --locale <tag> --auditand review the actual language. Structural checks cannot certify translation quality.--fileschecks selected files while work is in progress. - Add the completed locale's record to
locales.tsv: tag, Rust variant, native name, direction, and ISO 15924 script, separated by tabs.build.rsgeneratesLocale::ALL, the metadata, and embedded sources together. Catalogs are decompressed and parsed on first use, so startup does not load every language. - Translate
web/locales/en.jsonintoweb/locales/<tag>.json, then runpython3 tools/sync-i18n.py. This updates the generated loader dictionary, the macOS and iOSCFBundleLocalizationsarrays, and Android's locale list. - Ensure the web fonts cover the script and catalog characters, then run
make check-i18n.python3 tools/check-i18n.py --all-isoadditionally requires every language in the ISO inventory to be translated and registered.
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.