i18n: add Russian (ru) interface translation (#300)

* I18N.et: add Estonian interface translation

* I18N.et: record the verified headless fork push step

* i18n: send interface text that skipped translation through the catalog

A test-only pseudo-language wraps every catalog lookup in ⟦…⟧ marks; a new
audit test lays the app out in it (every tab, File page, dialog and pane,
the right-click menu, and a hover and a click on every ribbon control,
including the Table and Equation tabs) and fails on any visible text
outside the marks. The document is drawn as an image, so only interface
text is checked; document text, font and style names, shortcuts, icon
lettering, contributor handles and command error messages are allowed.

What it found, now translated in every language that has the strings:
- Right-click menu items (Cut, Paste, Font…, Paragraph…, Link…, New Comment).
- Paragraph dialog: the alignment choices showed code values (`left`);
  the label now sits before the dropdown.
- Table Design › Table Style Options checkboxes.
- Table Layout › Alignment tooltips showed code values (`topLeft`).
- Design › Fonts: the theme's name is translated, its font names are not.
- `pt` units, the Insert › Table grid label, the default watermark text,
  the font dialog's preview sample, and the untitled document's name.
- Contributors and Models dialogs: headings, sort and name choices, summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n: add Russian (ru) interface translation

- ru.tsv: 1,068 entries covering every interface string, written clean-room
  from the English labels and reviewed by a native speaker. `ru`, `ru-RU`,
  `ru_RU.UTF-8` and other regions resolve to it; File > Options picks it by hand.
- Built-in style names (Normal, Heading 2, TOC 3, Strong…) show in the
  interface language in the Styles gallery, the Styles pane and the Style
  Inspector. Display only: documents, commands and saved files keep the
  English names, and styles the author made keep theirs.
- Rendered previews use the interface language: the Styles gallery samples
  (`АаБбВвГг`) and the Design tab's style-set tiles.
- Catalogs gain a `preview` context for sample text that must fit a small
  tile (`Title` is «Заголовок» as a style but «Пример» in the tiles); it falls
  back to the plain entry, and unknown contexts are still rejected.
- Tests: locale detection and saved preference, full coverage, Cyrillic glyphs
  in the bundled interface fonts, style-name display, catalog contexts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(ru): translate everything the interface audit finds

Adds 356 Russian entries: the equation gallery (categories, templates and
built-in equations), equation options, themes, watermark presets, paper
sizes, table styles, greetings, outline levels, the strings #313 routes
through the catalog, and the new AutoSave messages. The two rows #305 made
obsolete (📂 Browse…, ➕ New comment) are gone.

The interface audit now also fails when a string it sees translated has no
Russian entry, so Russian stays complete as the interface grows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303)

Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser.
table.draw takes a pen stroke in page points: a rectangle outside a table
inserts a one-cell table of that size at the paragraph under its start; a
straight vertical or horizontal line inside a table splits the cells it
crosses (new grid column or row; untouched cells span it). table.eraser
merges the two cells beside the border nearest a point. Each stroke is one
undo step. In the app the buttons toggle pen and eraser modes with a live
preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks
don't move the caret while a tool is on. Splitting through part of a
vertically merged cell and erasing beside one are refused with a clear
error. Catalog 384/430 -> 387/430.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #315 review fixes: eraser undo test, sanitized tap dots, custGeom path cap, ink docs

- draw.eraser with a point erases through draw.erase: test that it is one
  undo step (undo/redo) and marks the document dirty.
- Render and PDF: a single-point stroke's dot is drawn at the sanitized
  point, not the raw first one.
- DOCX custGeom reading stops once MAX_PATHS paths (subpaths included) are
  collected, across a:path elements.
- docs/file-format-parity.md: ink is saved as custom geometry, not InkML, so
  Word shows it as a freeform.
- widgets test: theme::apply takes an Appearance (#317).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: send interface text that skipped translation through the catalog

A test-only pseudo-language wraps every catalog lookup in ⟦…⟧ marks; a new
audit test lays the app out in it (every tab, File page, dialog and pane,
the right-click menu, and a hover and a click on every ribbon control,
including the Table and Equation tabs) and fails on any visible text
outside the marks. The document is drawn as an image, so only interface
text is checked; document text, font and style names, shortcuts, icon
lettering, contributor handles and command error messages are allowed.

What it found, now translated in every language that has the strings:
- Right-click menu items (Cut, Paste, Font…, Paragraph…, Link…, New Comment).
- Paragraph dialog: the alignment choices showed code values (`left`);
  the label now sits before the dropdown.
- Table Design › Table Style Options checkboxes.
- Table Layout › Alignment tooltips showed code values (`topLeft`).
- Design › Fonts: the theme's name is translated, its font names are not.
- `pt` units, the Insert › Table grid label, the default watermark text,
  the font dialog's preview sample, and the untitled document's name.
- Contributors and Models dialogs: headings, sort and name choices, summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n audit: start every ribbon click from a fresh app; translate the table style preview

The sweep pressed Escape after each click, and in an equation Escape leaves it,
so the Equation tab vanished after its first control and the rest of its menus
(Convert, Justification, …) were never opened. Each control now gets a fresh app
set up the same way. The narrower allowance for short text (only icon lettering
and initials) also catches short words.

It found the sample table in the Table Style dialog (Mon, Tue, North, Total, …),
now translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* #313: catalog rows for the newly translated interface text

Rows in all eight catalogs for the strings #313 now sends through the
catalog (cell alignment tooltips, credits columns and summary, theme names,
context-menu items, table picker, pt suffix, Document1, the Font dialog
sample) and for the Table Style preview's sample table, which the
untranslated-text audit flagged after main's table style editor landed.
The audit runs headless (no host services: no file pickers or speech) in
about 20 s, so it stays enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n audit: click the ribbon's bottom row too

The sweep only clicked controls centred between y = 52 and 125, which took in
the tab row and left out each group's bottom row (Equation › Convert and
Justification among them). The band is now taken from the layout: below the
tab row and above the document area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n audit: lay the app out wide enough that no ribbon tab overflows

At 1440 pt the Equation tab ran past the window, so its last controls
(Justification among them) sat behind the ribbon's scroll arrow and were never
clicked. The audit window is now 2560 pt wide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n: add Russian (ru) interface translation

- ru.tsv: 1,068 entries covering every interface string, written clean-room
  from the English labels and reviewed by a native speaker. `ru`, `ru-RU`,
  `ru_RU.UTF-8` and other regions resolve to it; File > Options picks it by hand.
- Built-in style names (Normal, Heading 2, TOC 3, Strong…) show in the
  interface language in the Styles gallery, the Styles pane and the Style
  Inspector. Display only: documents, commands and saved files keep the
  English names, and styles the author made keep theirs.
- Rendered previews use the interface language: the Styles gallery samples
  (`АаБбВвГг`) and the Design tab's style-set tiles.
- Catalogs gain a `preview` context for sample text that must fit a small
  tile (`Title` is «Заголовок» as a style but «Пример» in the tiles); it falls
  back to the plain entry, and unknown contexts are still rejected.
- Tests: locale detection and saved preference, full coverage, Cyrillic glyphs
  in the bundled interface fonts, style-name display, catalog contexts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(ru): translate everything the interface audit finds

Adds 356 Russian entries: the equation gallery (categories, templates and
built-in equations), equation options, themes, watermark presets, paper
sizes, table styles, greetings, outline levels, the strings #313 routes
through the catalog, and the new AutoSave messages. The two rows #305 made
obsolete (📂 Browse…, ➕ New comment) are gone.

The interface audit now also fails when a string it sees translated has no
Russian entry, so Russian stays complete as the interface grows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n(ru): native-speaker review corrections

Add Note, Move Past Citation, equation display/inline, accents, stacks,
operators, Column Selection, Reading Highlight, ASAP and Tools, as reviewed;
the Accent gallery button wraps onto two lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n(ru): translate what the improved audit and today's main add

The audit now opens every ribbon menu (bottom rows and the Equation tab's last
controls included), which surfaced Equation › Convert and Justification, and
the Table Style dialog's sample table. Also covers password protection, Manage
Styles, the clipboard pane, Shape Effects and Asian typography from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n: translate the Table Layout 'Draw Table' ribbon label in every catalog

#308's two-line ribbon label had no catalog rows; #300's Russian audit
(ru covers everything the interface looks up) found it. Rows in all ten
catalogs keep et == es and ru covering zh-hans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Borders and Shading line widths through the catalog ({n} pt)

#313's untranslated-text audit flagged main's new dialog (#327).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* land-294: Estonian rows for main's newer dialogs

Define New Multilevel List and Track Changes Options (#342), rotation (#332),
Tabs and Borders and Shading (#327), Columns, Symbol and Field (#335): et
keys equal es again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n audit: run on demand (#[ignore]) until its rare failure under load is understood

It failed once in ~46 runs on a heavily loaded machine and passed 35 straight reruns,
so CI shouldn't depend on it yet. Run it with --ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(ru): restore the review corrections a merge reverted; narrow and hide labels

A merge of older copies of this branch's commits put back the pre-review
wording of 20 entries (Add Note, Tools, the equation accents and operators,
Column Selection, Reading Highlight, ASAP, …) and dropped six that are still in
use (Equation › Convert, Centered as Group, the hyphenation menu's ✓ None).
They're back as reviewed. Also: 1/4 Em Space → «Узкий пробел», and the Draw
tab's Hide Ink button reads «Скрыть».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

* i18n(ru): restore the reviewed wording a merge rewrote

The same merge reworded 24 reviewed entries (password protection, Shape
Effects, Asian typography, the clipboard pane, the table style sample);
they're back as a native speaker reviewed them. Clipboard Pane keeps
«Область буфера обмена», like the other panes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po

---------

Co-authored-by: Henno Täht <henno.taht@gmail.com>
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov 2026-10-11 17:53:41 +02:00 committed by GitHub
parent dded39accf
commit 41bbaf51bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2017 additions and 32 deletions

View File

@ -64,4 +64,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |

View File

@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti or Türkçe (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe or Русский (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the

View File

@ -110,7 +110,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`) and Serbian (`sr`, `sr-latn`, #250), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`) and Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).

View File

@ -263,4 +263,26 @@ fn untranslated_interface_text() {
report.push_str(&format!("{:?} [{}]\n", pseudo::unmarked(text), states.join(", ")));
}
assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len());
russian_has_every_string_the_interface_looked_up(pseudo::looked_up());
}
/// Russian covers everything the audit saw translated, plus the equation gallery's labels (shown
/// in menus the audit doesn't open one by one). Numbered style names go through a template.
fn russian_has_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet<String>) {
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string()));
keys.extend(SYMBOL_SETS.iter().map(|(name, _)| name.to_string()));
for g in STRUCTURES {
keys.insert(g.label.to_string());
for (section, templates) in g.sections {
keys.insert(section.to_string());
keys.extend(templates.iter().map(|t| t.label.to_string()));
}
}
let ru = super::Lang::from_code("ru").unwrap_or(super::Lang::EN);
let numbered = |s: &str| ["Heading ", "TOC "].iter().any(|p| s.strip_prefix(p).is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())));
// Names that read the same in Russian.
let same = |s: &str| matches!(s, "LaTeX" | "LaTeX (*.tex)" | "Unicode" | "I. II. III." | "i. ii. iii.");
let missing: Vec<String> = keys.into_iter().filter(|s| english_words(s) && !super::has(ru, s) && !numbered(s) && !same(s)).collect();
assert!(missing.is_empty(), "{} strings have no Russian entry:\n{missing:#?}", missing.len());
}

View File

@ -1,5 +1,6 @@
//! Parser and lookup table for translation catalogs (`*.tsv`, format documented in `zh-hans.tsv`).
//! The format is PdfCraft's; WordCraft uses its plain (context-free) entries.
//! The format is PdfCraft's. Most entries are plain (empty context); a few use one of
//! [`CONTEXTS`] where the same English needs different wording in one place.
//!
//! Validation is strict (unknown escapes, unknown `@` contexts, placeholder, ellipsis and plural
//! form mismatches, duplicates), but a bad line is only skipped and reported: the rest of the
@ -16,11 +17,18 @@ pub struct Entry {
pub translation: String,
}
/// The contexts a catalog entry may name, and where each applies.
/// - `preview`: sample text inside a rendered preview (the Design tab's style-set tiles), which
/// has to fit a small tile; it falls back to the plain entry.
pub const CONTEXTS: &[&str] = &["preview"];
/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).
#[derive(Debug, Default)]
pub struct Catalog {
/// English source → translation (the hot path, looked up every frame).
plain: HashMap<String, String>,
/// (context, English source) → translation, for the few entries that need one.
contextual: HashMap<(String, String), String>,
}
fn unescape(text: &str) -> Result<String, String> {
@ -58,8 +66,8 @@ pub fn placeholders(text: &str) -> Vec<&str> {
/// Check one entry against its English source.
fn validate(entry: &Entry) -> Result<(), String> {
let Entry { context, source, translation } = entry;
if !context.is_empty() {
return Err(format!("contexts aren't used in WordCraft catalogs (found {context:?}); leave the first column empty"));
if !context.is_empty() && !CONTEXTS.contains(&context.as_str()) {
return Err(format!("unknown context {context:?}; use one of {CONTEXTS:?} or leave the first column empty"));
}
if placeholders(source) != placeholders(translation) {
return Err("placeholders differ from the English source".into());
@ -109,11 +117,23 @@ impl Catalog {
/// Build a catalog from its valid entries; the errors of the skipped lines come back too.
pub fn parse(text: &str) -> (Catalog, Vec<String>) {
let (entries, errors) = parse_entries(text);
let plain = entries.into_iter().map(|e| (e.source, e.translation)).collect();
(Catalog { plain }, errors)
let mut catalog = Catalog::default();
for e in entries {
if e.context.is_empty() {
catalog.plain.insert(e.source, e.translation);
} else {
catalog.contextual.insert((e.context, e.source), e.translation);
}
}
(catalog, errors)
}
pub fn plain(&self, s: &str) -> Option<&str> {
self.plain.get(s).map(String::as_str)
}
/// The entry for `s` in `context`, else the plain one.
pub fn in_context(&self, context: &str, s: &str) -> Option<&str> {
self.contextual.get(&(context.to_string(), s.to_string())).map(String::as_str).or_else(|| self.plain(s))
}
}

View File

@ -1328,6 +1328,9 @@
South Sur
West Oeste
Total Total
# Table Layout › Draw (#308)
Draw\nTable Dibujar\ntabla
Rotation Giro
Rotation: Giro:
Rotate Right 90° Girar 90° a la derecha

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1173,6 +1173,8 @@
West Lääs
Total Kokku
# Table Layout › Draw (#308)
Draw\nTable Joonista\ntabel
# Define New Multilevel List and Track Changes Options (#342)
(no style) (stiil puudub)
Add tab stop at: Lisa tabelduskoht kohal:
@ -1212,7 +1214,6 @@
Text indent at: Teksti taane:
Track Changes Options Muudatuste jälitamise suvandid
Track formatting Jälita vormingut
# Rotation and flips (#332)
Flip Horizontal Peegelda horisontaalselt
Flip Vertical Peegelda vertikaalselt
@ -1220,7 +1221,6 @@
Rotate Right 90° Pööra paremale 90°
Rotation Pööramine
Rotation: Pööre:
# Tabs, Borders and Shading (#327); Columns, Symbol and Field (#335)
Tab stop position: Tabelduskoha asukoht:
Default tab stops: Vaikimisi tabelduskohad:

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1312,6 +1312,9 @@
South 南
West 西
Total 合計
# Table Layout › Draw (#308)
Draw\nTable 表の\n描画
Rotation 回転角度
Rotation: 回転:
Rotate Right 90° 右へ 90 度回転

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -20,6 +20,7 @@
//!
//! # Looking strings up
//! - [`tl!`](crate::tl) / [`t`]: a string in the current language. [`tr`]: in a given one.
//! - [`tc`] / [`trc`]: worded for a context (`preview`), for text that must fit a tight spot.
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
@ -47,7 +48,7 @@ pub struct LangInfo {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 11] = [
pub static LANGUAGES: [LangInfo; 12] = [
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
@ -67,6 +68,8 @@ pub static LANGUAGES: [LangInfo; 11] = [
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() },
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, catalog: OnceLock::new() },
// Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, catalog: OnceLock::new() },
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
];
@ -228,6 +231,21 @@ pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// `s` in the current language as worded for `context` (one of [`catalog::CONTEXTS`]), falling
/// back to the plain translation and then to English.
pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
trc(current(), context, s)
}
/// [`tc`] in a given language.
pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
#[cfg(test)]
if pseudo::on() {
return pseudo::mark(s);
}
lang.catalog().in_context(context, s).unwrap_or(s)
}
/// Lowercase translated interface labels and UI search queries in the current language.
/// Turkish has two distinct I pairs: `İ`/`i` and `I`/`ı`. Unicode's default casing turns `İ`
/// into `i` plus a combining dot, which would prevent `italik` from finding `İtalik`.
@ -256,6 +274,31 @@ pub fn tr(lang: Lang, s: &str) -> &str {
lang.catalog().plain(s).unwrap_or(s)
}
/// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in
/// the current language, styles the author made exactly as named. Display only: the document,
/// commands and saved files keep the English name.
pub fn style_name(style: &wordcraft_doc::Style) -> String {
if style.builtin { builtin_style_name(&style.name) } else { style.name.clone() }
}
/// A built-in style's English name in the current language; numbered families go through one
/// template each (`Heading {n}`), and names without a translation stay English.
pub fn builtin_style_name(name: &str) -> String {
let direct = t(name);
if direct != name {
return direct.to_string();
}
for (prefix, template) in [("Heading ", "Heading {n}"), ("TOC ", "TOC {n}"), ("Index ", "Index {n}")] {
if let Some(n) = name.strip_prefix(prefix)
&& !n.is_empty()
&& n.chars().all(|c| c.is_ascii_digit())
{
return fmt(t(template), &[("n", n)]);
}
}
name.to_string()
}
/// A ribbon location (`Home › Font`) in the current language, segment by segment.
pub fn location(loc: &str) -> String {
loc.split(" › ").map(t).collect::<Vec<_>>().join(" › ")

View File

@ -2,12 +2,19 @@
//! wrapped in `⟦…⟧`, so text drawn without the marks never went through translation.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
thread_local! {
static ON: Cell<bool> = const { Cell::new(false) };
// Marked strings live for the test process; lookups hand out `&'static str`.
static CACHE: RefCell<HashMap<String, &'static str>> = RefCell::new(HashMap::new());
// Every English string looked up while on, to check against a real catalog.
static LOOKED_UP: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
}
/// Every English string looked up while the pseudo-language was on.
pub fn looked_up() -> BTreeSet<String> {
LOOKED_UP.with_borrow(Clone::clone)
}
pub fn on() -> bool {
@ -19,6 +26,7 @@ pub fn set(on: bool) {
}
pub fn mark(s: &str) -> &'static str {
LOOKED_UP.with_borrow_mut(|l| l.insert(s.to_string()));
CACHE.with_borrow_mut(|c| *c.entry(s.to_string()).or_insert_with(|| Box::leak(format!("⟦{}⟧", s.replace('\n', "⟧\n⟦")).into_boxed_str())))
}

View File

@ -1324,6 +1324,9 @@
South Sul
West Oeste
Total Total
# Table Layout › Draw (#308)
Draw\nTable Desenhar\ntabela
Rotation Rotação
Rotation: Rotação:
Rotate Right 90° Girar 90° para a direita

Can't render this file because it contains an unexpected character in line 6 and column 25.

File diff suppressed because it is too large Load Diff

View File

@ -1245,6 +1245,9 @@
South Jug
West Zapad
Total Ukupno
# Table Layout › Draw (#308)
Draw\nTable Nacrtaj\ntabelu
# Columns, Symbol and Field dialogs (#321).
(All) (Sve)
(none) (nema)

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1249,6 +1249,9 @@
South Југ
West Запад
Total Укупно
# Table Layout › Draw (#308)
Draw\nTable Нацртај\nтабелу
# Columns, Symbol and Field dialogs (#321).
(All) (Све)
(none) (нема)

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -362,6 +362,125 @@ fn only_cjk_languages_ask_for_an_installed_cjk_font() {
assert_eq!(cjk, ["zh-hans", "zh-hant", "ja"]);
}
#[test]
fn russian_locales_and_saved_preference_work_without_changing_the_document() {
let ru = lang("ru");
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU-ru", "ru_UA.UTF-8", "ru-Cyrl-RU"] {
assert_eq!(lang_from_tag(tag), Some(ru), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "ru-RU", "en-US"]), Some(ru));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
let result = app.run("ui.language", serde_json::json!({"value": "RU"})).unwrap();
assert_eq!(result["effective"], "ru");
assert_eq!(app.ui.language, "ru");
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(Lang::from_pref(&restored.language), ru);
assert_eq!(ru.name(), "Русский");
}
#[test]
fn russian_covers_the_entire_existing_interface_catalog() {
use std::collections::HashSet;
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
// Everything the other catalogs translate, plus the preview samples below.
assert!(keys(lang("ru").0.source).is_superset(&keys(lang("zh-hans").0.source)));
assert!(parse_entries(lang("ru").0.source).1.is_empty());
let ru = lang("ru");
// Style gallery and Design tab previews show Cyrillic sample text.
assert_eq!(tr(ru, "AaBbCcDd"), "АаБбВвГг");
assert_eq!(tr(ru, "Title"), "Заголовок");
assert_eq!(tr(ru, "Heading 1"), "Заголовок 1");
assert!(tr(ru, "Body text in a short paragraph to show spacing.").starts_with("Основной текст"));
assert_eq!(tr(ru, "Home"), "Главная");
assert_eq!(tr(ru, "Font"), "Шрифт");
assert_eq!(tr(ru, "Review"), "Рецензирование");
assert_eq!(tr(ru, "Save"), "Сохранить");
assert_eq!(tr(ru, "unknown future label"), "unknown future label");
// The search hint's example must find the command it names.
assert_eq!(tr(ru, "Insert Table"), "Вставить таблицу");
assert!(tr(ru, "Type a command, e.g. \"insert table\"").contains("вставить таблицу"));
// Count-neutral wording is grammatical for every count: `Слов: 21`, `Слов: 1 из 21`.
for count in [0, 1, 2, 5, 21, 100, 1000] {
let words = count.to_string();
assert_eq!(fmt(tr(ru, "{words} words"), &[("words", &words)]), format!("Слов: {count}"));
assert_eq!(fmt(tr(ru, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Слов: 1 из {count}"));
}
}
#[test]
fn bundled_interface_fonts_cover_russian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("ru").0.source);
assert!(errors.is_empty());
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
// Cyrillic letters plus the Russian quotes and dash; symbols such as ¶, ⌘ or 📂 come from
// the English sources and render as they already do.
.filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '«' | '»' | '—'))
.chain("АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя".chars())
.collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
for ch in &chars {
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
}
}
}
#[test]
fn built_in_style_names_are_translated_for_display_and_user_styles_are_not() {
let sheet = wordcraft_doc::StyleSheet::builtin();
let name = |id: &str| style_name(sheet.get(id).unwrap());
set_current(lang("ru"));
assert_eq!(name("Normal"), "Обычный");
assert_eq!(name("Title"), "Заголовок");
assert_eq!(name("Heading1"), "Заголовок 1");
// Numbered families beyond the catalog's explicit entries go through one template.
assert_eq!(name("Heading7"), "Заголовок 7");
assert_eq!(name("TOC9"), "Оглавление 9");
// A style the author made keeps its name, even when it matches a built-in one.
let mine = wordcraft_doc::Style { name: "Normal".into(), builtin: false, ..Default::default() };
assert_eq!(style_name(&mine), "Normal");
// A built-in name without a translation stays English.
assert_eq!(builtin_style_name("Some Future Style"), "Some Future Style");
assert_eq!(builtin_style_name("Heading x"), "Heading x");
// Every built-in paragraph and character style shows in Russian. Table styles (`Grid Table 4 –
// Blue`) appear only as tooltips under Table Design and stay English for now.
let untranslated: Vec<&str> = sheet
.styles
.iter()
.filter(|s| s.kind != wordcraft_doc::StyleKind::Table && style_name(s) == s.name && s.name.chars().any(char::is_alphabetic))
.map(|s| s.name.as_str())
.collect();
assert!(untranslated.is_empty(), "built-in styles without a Russian name: {untranslated:?}");
set_current(Lang::EN);
assert_eq!(name("Heading7"), "Heading 7");
// The document itself never changes.
assert_eq!(sheet.get("Normal").unwrap().name, "Normal");
}
#[test]
fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected() {
let (c, errors) = Catalog::parse("\tTitle\tЗаголовок\npreview\tTitle\tТитул\n\tFont\tШрифт\nnope\tFont\tX\n");
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("unknown context"));
assert_eq!(c.plain("Title"), Some("Заголовок"));
assert_eq!(c.in_context("preview", "Title"), Some("Титул"));
// Without a context entry, a context lookup uses the plain one.
assert_eq!(c.in_context("preview", "Font"), Some("Шрифт"));
assert_eq!(c.in_context("preview", "Missing"), None);
let ru = lang("ru");
assert_eq!(tr(ru, "Title"), "Заголовок");
assert_eq!(trc(ru, "preview", "Title"), "Пример");
assert_eq!(trc(Lang::EN, "preview", "Title"), "Title");
assert_eq!(trc(lang("ja"), "preview", "Unknown"), "Unknown");
}
#[test]
fn estonian_locales_and_saved_preference_keep_document_content() {
let et = lang("et");

View File

@ -1347,3 +1347,4 @@
Trademark Sign Ticari Marka İşareti
Unlink Fields Alan Bağlantılarını Kaldır
Unlock Fields Alanların Kilidini Aç
Draw\nTable Tablo\nÇiz

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1324,6 +1324,9 @@
South Південь
West Захід
Total Разом
# Table Layout › Draw (#308)
Draw\nTable Намалювати\nтаблицю
Rotation Поворот
Rotation: Поворот:
Rotate Right 90° Повернути праворуч на 90°

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1,6 +1,7 @@
# Simplified Chinese (zh-hans) interface catalog. Columns, separated by a TAB:
# context <TAB> English source <TAB> translation
# context empty (WordCraft uses no contexts yet)
# context usually empty. `preview` words sample text inside rendered previews (the Design
# tab's style-set tiles), which must fit a small tile; it falls back to the plain entry.
# source the English text exactly as in the code (tl!(...), ribbon labels, command labels)
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a
# trailing "…" in one needs one in the other.
@ -1323,6 +1324,9 @@
South 南区
West 西区
Total 总计
# Table Layout › Draw (#308)
Draw\nTable 绘制\n表格
Rotation 旋转角度
Rotation: 旋转:
Rotate Right 90° 向右旋转 90°

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1313,6 +1313,9 @@
South 南區
West 西區
Total 總計
# Table Layout › Draw (#308)
Draw\nTable 繪製\n表格
Rotation 旋轉角度
Rotation: 旋轉:
Rotate Right 90° 向右旋轉 90°

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -205,10 +205,14 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
}
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", st.get("styleName").and_then(Value::as_str).unwrap_or(""))]))
.small(),
);
let current_name = app
.session
.doc
.styles
.get(&cur)
.map(crate::i18n::style_name)
.unwrap_or_else(|| st.get("styleName").and_then(Value::as_str).unwrap_or("").to_string());
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", &current_name)])).small());
ui.horizontal(|ui| {
if ui.button(tl!("New Style…")).clicked() {
app.dialog = crate::dialogs::Dialog::open("newStyle", app);
@ -234,7 +238,7 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
.styles
.iter()
.filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table)
.map(|s| (s.id.clone(), s.name.clone(), s.kind == wordcraft_doc::StyleKind::Character))
.map(|s| (s.id.clone(), crate::i18n::style_name(s), s.kind == wordcraft_doc::StyleKind::Character))
.collect();
list.sort_by_key(|s| s.1.to_lowercase());
egui::ScrollArea::vertical().show(ui, |ui| {
@ -425,12 +429,18 @@ fn inspector(app: &mut WordApp, ui: &mut Ui) {
let chr = &r["character"];
let pstyle = para["styleName"].as_str().unwrap_or("Normal").to_string();
let cstyle = chr["styleName"].as_str().map(str::to_string);
// Built-in style names show in the interface language; the document keeps the English ones.
let shown = |id: &Value, name: &str| {
id.as_str().and_then(|id| app.session.doc.styles.get(id)).map(crate::i18n::style_name).unwrap_or_else(|| name.to_string())
};
let pstyle_shown = shown(&para["style"], &pstyle);
let cstyle_shown = cstyle.as_deref().map(|n| shown(&chr["style"], n));
let pdirect = direct_list(&para["direct"]);
let cdirect = direct_list(&chr["direct"]);
let mut clear: Option<&str> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
section(ui, &t, "Paragraph", |ui| {
if level_row(ui, &t, "Style", &pstyle, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") {
if level_row(ui, &t, "Style", &pstyle_shown, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") {
clear = Some("paragraphStyle");
}
let (txt, empty) = if pdirect.is_empty() { (none.clone(), true) } else { (pdirect.join(", "), false) };
@ -440,7 +450,7 @@ fn inspector(app: &mut WordApp, ui: &mut Ui) {
});
ui.add_space(8.0);
section(ui, &t, "Characters", |ui| {
let (txt, empty) = match &cstyle {
let (txt, empty) = match &cstyle_shown {
Some(n) => (n.clone(), false),
None => (none.clone(), true),
};

View File

@ -271,7 +271,7 @@ pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: e
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let t = Tokens::get(ui.ctx());
let current = state.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
let styles: Vec<(String, String)> = app.session.doc.styles.gallery().iter().map(|s| (s.id.clone(), s.name.clone())).collect();
let styles: Vec<(String, String)> = app.session.doc.styles.gallery().iter().map(|s| (s.id.clone(), crate::i18n::style_name(s))).collect();
let rev = app.session.rev();
let skey = app.previews.styles_key(&app.session.doc, rev);
let ppp = ui.ctx().pixels_per_point();
@ -288,10 +288,12 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let key = format!("style:{id}:{skey}:{ppp}");
let lang = crate::i18n::current().code();
let key = format!("style:{id}:{skey}:{ppp}:{lang}");
let doc = &app.session.doc;
let tex = app.previews.get_or(ui.ctx(), &key, || {
let mut p = Paragraph::with_text("AaBbCcDd", CharProps::default()).styled(id);
// Sample letters in the interface's script (`АаБбВвГг` in Russian).
let mut p = Paragraph::with_text(tl!("AaBbCcDd"), CharProps::default()).styled(id);
p.props.space_before = Some(0.0);
p.props.align = Some(wordcraft_doc::Align::Left);
// Keep hanging indents (bullets) but start the first line at the tile's left edge.
@ -346,14 +348,14 @@ pub fn style_set_gallery(app: &mut WordApp, ui: &mut Ui) {
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let key = format!("set:{name}:{ppp}");
let key = format!("set:{name}:{ppp}:{}", crate::i18n::current().code());
let tex = app.previews.get_or(ui.ctx(), &key, || {
let mut s = wordcraft_engine::Session::new(Document::new());
let _ = s.run("design.styleSet", &json!({"name": name}));
let blocks = vec![
Block::Para(Paragraph::with_text("Title", CharProps::default()).styled("Title")),
Block::Para(Paragraph::with_text("Heading 1", CharProps::default()).styled("Heading1")),
Block::Para(Paragraph::with_text("Body text in a short paragraph to show spacing.", CharProps::default())),
Block::Para(Paragraph::with_text(crate::i18n::tc("preview", "Title"), CharProps::default()).styled("Title")),
Block::Para(Paragraph::with_text(tl!("Heading 1"), CharProps::default()).styled("Heading1")),
Block::Para(Paragraph::with_text(tl!("Body text in a short paragraph to show spacing."), CharProps::default())),
];
snippet_blocks(&s.doc, blocks, 120.0, 110.0, ppp * 0.42, 6.0, None)
});

View File

@ -1472,7 +1472,7 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
.map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s)))
.collect();
styles.sort_by_key(|(builtin, _, _)| *builtin);
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {

View File

@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
| Method | Params | Result |
|---|---|---|
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`)…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`)…) |
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |

View File

@ -18,8 +18,8 @@ other ~18 languages Word ships, plus native-speaker review (human).
## What WordCraft ships (measured)
- **11 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et` and Turkish `tr`. Catalogs have 1,305–1,307 entries
- **12 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr` and Russian `ru`. Catalogs have 1,305–1,450 entries
each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
label and ribbon location is translated in every language.
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
@ -52,8 +52,8 @@ shared items below.
| Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 |
| Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 |
Other shipped languages: **6** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`) and Turkish (`tr`), all partial.
Other shipped languages: **7** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`) and Russian (`ru`), all partial.
Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or
through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog.
Estonian spelling, grammar, dates and document templates are not added by the interface catalog.
@ -76,6 +76,7 @@ proofing is not included; terminology and checks: [`localization-tr.md`](localiz
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) |
| 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted |
| 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) |
| 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks |