Compare commits

..
Author SHA1 Message Date
dependabot[bot] 358614e1ff ci(deps): bump Swatinem/rust-cache
Bumps the actions group with 1 update in the / directory: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 18:53:02 +00:00
94 changed files with 70982 additions and 1506 deletions
-1
View File
@@ -1 +0,0 @@
craft-i18n.rev text eol=lf
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+1 -2
View File
@@ -11,8 +11,7 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
sonarcloud:
+15 -1
View File
@@ -67,4 +67,18 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
| `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/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `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/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `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 |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
Generated
-41
View File
@@ -869,17 +869,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4"
dependencies = [
"intl_pluralrules",
"log",
"sys-locale",
"unic-langid",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -2091,15 +2080,6 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -4034,7 +4014,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4182,24 +4161,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5439,7 +5400,6 @@ name = "wordcraft-ui-egui"
version = "0.10.0"
dependencies = [
"cfb",
"craft-i18n",
"craft-ui",
"egui",
"egui_kittest",
@@ -5794,7 +5754,6 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+1 -3
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
+1 -1
View File
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(char::is_ascii_alphabetic).is_some() {}
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
+24 -26
View File
@@ -229,7 +229,7 @@ impl Path {
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
write!(f, "{}", s.join("/"))
}
}
@@ -801,36 +801,34 @@ impl Document {
d
}
fn fix_cell_blocks(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fn fix_story_blocks(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
Self::fix_cell_blocks(&mut c.blocks);
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
fn fix(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
fix_cell(&mut c.blocks);
}
}
}
}
}
}
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
Self::fix_story_blocks(&mut self.body);
fn fix_cell(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fix(&mut self.body);
for p in self.parts.values_mut() {
if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new()));
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
i += 1;
}
if i == start {
+3 -3
View File
@@ -339,7 +339,7 @@ impl P {
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√'..='∜' => {
'√' | '∛' | '∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
@@ -410,7 +410,7 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
end += 1;
}
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
@@ -628,7 +628,7 @@ impl P {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(char::is_alphanumeric) {
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(u8::is_ascii_digit) {
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
in_exp = true;
continue;
}
+42 -58
View File
@@ -29,43 +29,6 @@ enum Piece<'a> {
}
impl Writer<'_> {
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
}
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
*toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
}
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
/// Write a block list. `top` = body level (sections allowed).
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
if depth > MAX_DEPTH {
@@ -80,7 +43,14 @@ impl Writer<'_> {
for c in plan.opens.get(i).into_iter().flatten() {
super::sdt::open(w, c);
}
self.update_toc_before_block(i, toc, toc_field_open, w);
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
match &**b {
Block::Para(p) => {
self.inline_controls = plan.inline_in(i);
@@ -88,7 +58,16 @@ impl Writer<'_> {
}
Block::Table(t) => self.table(w, t, rels, depth),
}
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
super::sdt::close(w);
}
@@ -97,7 +76,16 @@ impl Writer<'_> {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
self.write_trailing_para(w, depth);
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
}
@@ -170,23 +158,6 @@ impl Writer<'_> {
self.para_inner(w, p, rels, top, depth, false);
}
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(*idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, mark);
if let Some(ch) = mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
let para_id = self.take_para_id(p);
match &para_id {
@@ -214,7 +185,20 @@ impl Writer<'_> {
});
ppr_inner(w, &pp, framed, num_change.as_ref());
if has_mark {
self.write_mark_rpr(w, &p.mark, &mark_revs);
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, &p.mark);
if let Some(ch) = p.mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
if let Some(s) = section {
self.sectpr(w, s, rels);
+1 -1
View File
@@ -1090,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
out.push((l, false));
}
}
let list = pages.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
}
out
+21 -29
View File
@@ -579,8 +579,27 @@ fn info(s: &mut Session, v: &Value) -> CmdResult {
Ok(describe(s, &r))
}
fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
let mut content = None;
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
match &mut c.kind {
ControlKind::Text { multi_line } => {
if let Some(m) = p::bool(v, "multiLine") {
@@ -614,33 +633,6 @@ fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
}
_ => {}
}
content
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
if let Some(kc) = apply_kind_properties(&mut c, v) {
content = Some(kc);
}
if let Some(text) = content {
let base = content_base(s, &r);
let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base };
+57 -87
View File
@@ -7,7 +7,6 @@ use wordcraft_doc::resolve::ResolvedChar;
use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef};
use super::sel_result;
use super::tools::{is_turkic, lower_char, upper_char};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
@@ -454,16 +453,7 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let to = if path == b.path { b.off } else { p.len() };
let chars = live_chars(p, from, to);
let src: String = chars.iter().map(|(_, c)| c).collect();
// Turkish, Azerbaijani and Crimean Tatar text follows the same case mapping as
// AutoCorrect (#1923); each run's own language decides (#3479).
let turkic: Vec<bool> = chars
.iter()
.map(|(off, _)| {
let lang = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(*off)).lang;
is_turkic(lang.as_deref().or(s.editing_lang.as_deref()))
})
.collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0, &turkic))
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0))
};
if groups.is_empty() {
continue;
@@ -597,81 +587,61 @@ fn is_title(t: &str) -> bool {
#[cfg(test)]
fn convert(src: &str, mode: &str, para_start: bool) -> String {
let turkic = vec![false; src.chars().count()];
convert_chars(src, mode, para_start, &turkic).concat()
}
fn convert_title_char(c: char, i: usize, up: &mut bool, upper: impl Fn(char) -> String, lower: impl Fn(usize, char) -> String) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
if c.is_whitespace() {
*up = true;
}
n
}
fn convert_sentence_char(
c: char,
i: usize,
up: &mut bool,
chars: &[char],
upper: impl Fn(char) -> String,
lower: impl Fn(usize, char) -> String,
) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(char::is_ascii_digit)
&& chars.get(i + 1).is_some_and(char::is_ascii_digit);
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
*up = true;
}
n
convert_chars(src, mode, para_start).concat()
}
/// The case-converted form of each character of `src`, in order (one entry per char, so a
/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
/// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Vec<String> {
/// change of length such as ß → SS or İ → i̇ can be applied run by run).
fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
let chars: Vec<char> = src.chars().collect();
// Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`).
let lower = |i: usize, c: char| -> String {
let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic());
let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic());
if c == 'Σ' && prev && !next {
"ς".to_string()
} else if turkic.get(i).copied().unwrap_or(false) {
lower_char(c, true)
} else {
c.to_lowercase().collect()
}
if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() }
};
// Title case capitalises the first word of any selection; sentence case only at a paragraph start.
let mut up = mode == "title" || para_start;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let turkic = turkic.get(i).copied().unwrap_or(false);
let upper = |c: char| upper_char(c, turkic);
let new = match mode {
"upper" => upper(c),
"upper" => c.to_uppercase().collect(),
"lower" => lower(i, c),
"toggle" => {
if c.is_uppercase() {
lower(i, c)
} else {
upper(c)
c.to_uppercase().collect()
}
}
"title" => convert_title_char(c, i, &mut up, upper, lower),
_ => convert_sentence_char(c, i, &mut up, &chars, upper, lower),
"title" => {
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
if c.is_whitespace() {
up = true;
}
n
}
_ => {
// Sentence case.
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
up = true;
}
n
}
};
out.push(new);
}
@@ -766,6 +736,28 @@ pub fn state(s: &Session) -> Value {
})
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
/// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)]
@@ -872,25 +864,3 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
s.sel = crate::Selection { anchor: at, focus: end };
sel_result(s)
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
+67 -67
View File
@@ -979,73 +979,6 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1375,3 +1308,70 @@ mod tests {
assert_eq!(date_picture("DATE"), None);
}
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
+1 -1
View File
@@ -837,7 +837,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '=' | '<' | '>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or("");
let v = value(s, row, &field);
+37 -49
View File
@@ -228,50 +228,6 @@ enum Tok {
Cmp(&'static str),
}
fn parse_num_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<Tok, CmdError> {
let mut lit = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
lit.push(d);
chars.next();
} else {
break;
}
}
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
Ok(Tok::Num(x))
}
fn parse_name_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
let mut name = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_alphanumeric() {
name.push(d.to_ascii_uppercase());
chars.next();
} else {
break;
}
}
Tok::Name(name)
}
fn parse_cmp_token(ch: char, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
chars.next();
let next = chars.peek().copied();
let (op, longer) = match (ch, next) {
('<', Some('=')) => ("<=", true),
('<', Some('>')) => ("<>", true),
('>', Some('=')) => (">=", true),
('<', _) => ("<", false),
('>', _) => (">", false),
_ => ("=", false),
};
if longer {
chars.next();
}
Tok::Cmp(op)
}
fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
let mut out = Vec::new();
let mut chars = s.chars().peekable();
@@ -279,14 +235,46 @@ fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
if ch.is_whitespace() {
chars.next();
} else if ch.is_ascii_digit() || ch == '.' {
out.push(parse_num_token(&mut chars)?);
let mut lit = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
lit.push(d);
chars.next();
} else {
break;
}
}
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
out.push(Tok::Num(x));
} else if ch.is_ascii_alphabetic() {
out.push(parse_name_token(&mut chars));
let mut name = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_alphanumeric() {
name.push(d.to_ascii_uppercase());
chars.next();
} else {
break;
}
}
out.push(Tok::Name(name));
} else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') {
out.push(Tok::Op(ch));
chars.next();
} else if matches!(ch, '<'..='>') {
out.push(parse_cmp_token(ch, &mut chars));
} else if matches!(ch, '<' | '>' | '=') {
chars.next();
let next = chars.peek().copied();
let (op, longer) = match (ch, next) {
('<', Some('=')) => ("<=", true),
('<', Some('>')) => ("<>", true),
('>', Some('=')) => (">=", true),
('<', _) => ("<", false),
('>', _) => (">", false),
_ => ("=", false),
};
if longer {
chars.next();
}
out.push(Tok::Cmp(op));
} else {
return Err(syntax(format!("`{ch}` isn't part of a formula")));
}
@@ -480,7 +468,7 @@ impl Parser {
/// `B4` → (row 3, column 1): the row counts from 1 and the column from A.
fn cell_ref(name: &str) -> Option<(usize, usize)> {
let letters: String = name.chars().take_while(char::is_ascii_alphabetic).collect();
let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let digits = name.get(letters.len()..)?;
if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
+6 -5
View File
@@ -195,7 +195,8 @@ impl AutoCorrectPrefs {
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
.find(|(a, _)| {
*a == word || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
*a == word
|| (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
})
.map(|(a, b)| {
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
@@ -248,7 +249,7 @@ impl AutoCorrectPrefs {
let mut v: Vec<String> = NOT_SENTENCE_END
.iter()
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a)))
.map(ToString::to_string)
.map(|a| a.to_string())
.chain(self.exceptions.iter().map(|a| a.to_lowercase()))
.collect();
v.sort();
@@ -1027,7 +1028,7 @@ fn prev_token(before: &str) -> String {
}
/// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923).
pub(crate) fn is_turkic(lang: Option<&str>) -> bool {
fn is_turkic(lang: Option<&str>) -> bool {
if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr {
return true; // tr, tur, any region, same normalisation as proofing
}
@@ -1037,7 +1038,7 @@ pub(crate) fn is_turkic(lang: Option<&str>) -> bool {
}
/// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default.
pub(crate) fn upper_char(c: char, turkic: bool) -> String {
fn upper_char(c: char, turkic: bool) -> String {
if turkic && c == 'i' {
return "\u{130}".to_string();
}
@@ -1046,7 +1047,7 @@ pub(crate) fn upper_char(c: char, turkic: bool) -> String {
/// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`,
/// never `i` plus a combining dot.
pub(crate) fn lower_char(c: char, turkic: bool) -> String {
fn lower_char(c: char, turkic: bool) -> String {
match c {
'I' if turkic => "\u{131}".to_string(),
'\u{130}' => "i".to_string(),
+1 -1
View File
@@ -1519,7 +1519,7 @@ impl Session {
let mut result = match result {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
}
};
-40
View File
@@ -2075,46 +2075,6 @@ fn autocorrect_capitalisation_follows_turkic_case_mapping() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language");
}
/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using
/// each run's own language; other languages keep the Unicode default.
#[test]
fn change_case_follows_turkic_case_mapping() {
for tag in ["tr-TR", "az-AZ"] {
for (mode, input, want) in [
("upper", "istanbul", "İSTANBUL"),
("lower", "IŞIK", "ışık"),
("title", "istanbul güzel", "İstanbul Güzel"),
("sentence", "istanbul. izmir", "İstanbul. İzmir"),
("toggle", "iI", "İı"),
] {
let mut s = s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
run(&mut s, "text.insert", json!({"text": input, "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": mode}));
assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}");
}
}
// Other languages keep the Unicode default.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "title"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US");
// Mixed runs: each run's own language decides.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true}));
run(&mut s, "review.language", json!({"lang": "tr-TR"}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true}));
run(&mut s, "review.language", json!({"lang": "en-US"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs");
}
// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves
// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with
// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one.
+2 -2
View File
@@ -99,7 +99,7 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
return FaceRef(f);
@@ -933,7 +933,7 @@ pub fn base_style(style: &str) -> &str {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
}
/// Weight implied by a style name.
+6 -5
View File
@@ -366,8 +366,9 @@ impl Lexer<'_> {
self.pos += after_tag;
self.skip_past(">");
return;
} else {
self.pos += k + 2;
}
self.pos += k + 2;
}
}
@@ -407,7 +408,7 @@ impl Lexer<'_> {
self.skip_past(">");
return Some(Tok::End(name));
}
if !b.get(1).is_some_and(u8::is_ascii_alphabetic) {
if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) {
self.pos += 1;
return Some(Tok::Text("<".into()));
}
@@ -418,7 +419,7 @@ impl Lexer<'_> {
let mut attrs = Vec::new();
let mut self_close = false;
loop {
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
i += 1;
}
match b.get(i) {
@@ -447,13 +448,13 @@ impl Lexer<'_> {
i += 1;
continue;
}
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
i += 1;
}
let mut val = String::new();
if b.get(i) == Some(&b'=') {
i += 1;
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
i += 1;
}
match b.get(i) {
+3 -3
View File
@@ -778,13 +778,13 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\');
o.push_str(&name);
i += 1 + name.len();
if chars.get(i).is_some_and(char::is_ascii_alphabetic) {
if chars.get(i).is_some_and(|n| n.is_ascii_alphabetic()) {
o.push(' ');
}
}
continue;
}
'#'..='&' => {
'%' | '#' | '&' | '$' => {
o.push('\\');
o.push(c);
}
@@ -796,7 +796,7 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\');
o.push_str(name);
// A control word needs a space only before a letter.
if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) {
if chars.get(i + 1).is_some_and(|n| n.is_ascii_alphabetic()) {
o.push(' ');
}
}
+5 -5
View File
@@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option<Marker> {
let (m, len) = if matches!(first, '-' | '+' | '*') {
(Marker { ordered: false, ch: first, len: 1, start: 1 }, 1)
} else {
let digits = t.chars().take_while(char::is_ascii_digit).count();
let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
if !(1..=9).contains(&digits) {
return None;
}
@@ -607,7 +607,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
i += 1;
}
// Blank lines at the end of an item belong between items.
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) {
item.pop();
}
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start };
@@ -833,7 +833,7 @@ fn unescape(s: &str) -> String {
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation)
&& let Some(n) = chars.peek().copied().filter(|n| n.is_ascii_punctuation())
{
out.push(n);
chars.next();
@@ -852,7 +852,7 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
let at = from + k;
let before = lower.get(..at)?.chars().next_back();
let after = lower.get(at + name.len()..)?.trim_start();
if before.is_some_and(char::is_whitespace) && after.starts_with('=') {
if before.is_some_and(|c| c.is_whitespace()) && after.starts_with('=') {
let v = after.get(1..)?.trim_start();
let q = v.chars().next()?;
return Some(if q == '"' || q == '\'' {
@@ -1257,7 +1257,7 @@ fn escape_line_start(s: String) -> String {
|| (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-'))
|| thematic_break(t)
|| {
let d = t.chars().take_while(char::is_ascii_digit).count();
let d = t.chars().take_while(|c| c.is_ascii_digit()).count();
d > 0
&& t.get(d..).is_some_and(|r| {
r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")"
+2 -2
View File
@@ -88,10 +88,10 @@ fn run(r: &MRun, o: &mut String, eq_arr: bool) {
let cs: Vec<char> = r.text.chars().collect();
let mut i = 0;
while let Some(&c) = cs.get(i) {
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) {
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
let mut num = String::new();
while let Some(&d) =
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(char::is_ascii_digit))
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
{
num.push(d);
i += 1;
+2 -2
View File
@@ -2,9 +2,9 @@
//! reader (#863).
use std::collections::HashSet;
use wordcraft_doc::{Document, Part, PartKind};
use wordcraft_doc::{Part, PartKind};
use super::{Body, Ev, FBlock, Pages, SectionProps, Styles, events, get, model};
use super::*;
/// Most XML events kept for all headers and footers together; a slot past it is left out.
const MAX_EVENTS: usize = 1 << 20;
+2 -2
View File
@@ -2638,7 +2638,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let Some(&n) = bytes.get(i + 1) else { break };
if n.is_ascii_alphabetic() {
let mut j = i + 1;
while bytes.get(j).is_some_and(u8::is_ascii_alphabetic) && j - i <= 32 {
while bytes.get(j).is_some_and(|b| b.is_ascii_alphabetic()) && j - i <= 32 {
j += 1;
}
let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or("");
@@ -2648,7 +2648,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
k += 1;
}
let d0 = k;
while bytes.get(k).is_some_and(u8::is_ascii_digit) && k - d0 < 10 {
while bytes.get(k).is_some_and(|b| b.is_ascii_digit()) && k - d0 < 10 {
k += 1;
}
let param = if k > d0 {
+1 -1
View File
@@ -788,7 +788,7 @@ fn iso_datetime(v: &str) -> Option<String> {
let y: i64 = it.next()?.parse().ok()?;
let m: u32 = it.next()?.parse().ok()?;
let d_part = it.next()?;
let d_digits: String = d_part.chars().take_while(char::is_ascii_digit).collect();
let d_digits: String = d_part.chars().take_while(|c| c.is_ascii_digit()).collect();
let d: u32 = d_digits.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
+3 -34
View File
@@ -42,11 +42,10 @@ pub fn dictionary() -> Arc<AffixDict> {
fn load_installed() -> AffixDict {
let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
crate::hunspell::clear_load_error(NAMES[0]);
log::info!("Czech spelling: no Hunspell cs_CZ dictionary installed");
return AffixDict::default();
};
crate::hunspell::note_load(NAMES[0], &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| {
log::warn!("Czech spelling: {e}");
AffixDict::default()
})
@@ -98,30 +97,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Czech dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -245,22 +232,4 @@ mod tests {
let opts = crate::Options { ignore_uppercase: false, ..Default::default() };
assert!(!crate::is_correct_in_with("ZQZAKÁZANÉ", crate::ProofLang::Cs, &opts));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Czech checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_czech_check() {
test_dict::install();
let lang = crate::ProofLang::Cs;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŠKULA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŠKULA", lang, &check_caps));
assert!(crate::is_correct_in_with("ŠKOLA", lang, &check_caps));
assert!(crate::is_correct_in_with("škula2", lang, &defaults));
assert!(!crate::is_correct_in_with("škula2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŠKULA"), "direct checks keep the defaults");
}
}
+2 -32
View File
@@ -85,30 +85,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Croatian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -159,22 +147,4 @@ mod tests {
fn missing_dictionary_is_silent() {
assert!(is_correct_with(&AffixDict::default(), "nepostojećariječ"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Croatian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_croatian_check() {
test_dict::install();
let lang = crate::ProofLang::Hr;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŠKOLLA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŠKOLLA", lang, &check_caps));
assert!(crate::is_correct_in_with("ŠKOLA", lang, &check_caps));
assert!(crate::is_correct_in_with("školla2", lang, &defaults));
assert!(!crate::is_correct_in_with("školla2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŠKOLLA"), "direct checks keep the defaults");
}
}
+3 -35
View File
@@ -91,30 +91,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Indonesian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -125,7 +113,7 @@ fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
|| w.split('-').all(|p| {
p.chars().count() <= 1
|| CLITICS.iter().any(|c| p.eq_ignore_ascii_case(c))
|| (opts.ignore_uppercase && p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= 6)
|| (p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= 6)
|| known(d, p)
});
}
@@ -236,24 +224,4 @@ mod tests {
let opts = crate::Options { ignore_uppercase: false, ..Default::default() };
assert!(!crate::is_correct_in_with("ZQTERLARANG", crate::ProofLang::Id, &opts));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Indonesian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_indonesian_check() {
test_dict::install();
let lang = crate::ProofLang::Id;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("RUMHA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("RUMHA", lang, &check_caps));
assert!(crate::is_correct_in_with("RUMAH", lang, &check_caps));
assert!(crate::is_correct_in_with("RUMHA-nya", lang, &defaults));
assert!(!crate::is_correct_in_with("RUMHA-nya", lang, &check_caps), "a part in capitals is checked too");
assert!(crate::is_correct_in_with("rumha2", lang, &defaults));
assert!(!crate::is_correct_in_with("rumha2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("RUMHA"), "direct checks keep the defaults");
}
}
+1 -1
View File
@@ -276,7 +276,7 @@ fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
}
let mut after = text.get(*b..).unwrap_or("").chars();
let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue };
let ends = after.next().is_none_or(char::is_whitespace);
let ends = after.next().is_none_or(|c| c.is_whitespace());
let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘');
if ends && !quoted {
v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()]));
+2 -32
View File
@@ -111,30 +111,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Italian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -241,22 +229,4 @@ mod tests {
crate::add_word("Zxqitalianaparola");
assert!(is_correct("zxqitalianaparola"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Italian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_italian_check() {
test_dict::install();
let lang = crate::ProofLang::It;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("LIBRRO", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("LIBRRO", lang, &check_caps));
assert!(crate::is_correct_in_with("LIBRO", lang, &check_caps));
assert!(crate::is_correct_in_with("librro2", lang, &defaults));
assert!(!crate::is_correct_in_with("librro2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("LIBRRO"), "direct checks keep the defaults");
}
}
+24 -52
View File
@@ -320,7 +320,7 @@ const COMMON: &str = "a an the and or but if of to in on at by for with from as
const EXTRA_EN: &str = include_str!("../../../assets/spelling/en-extra.txt");
fn extra_words() -> &'static HashSet<&'static str> {
static E: OnceLock<HashSet<&str>> = OnceLock::new();
static E: OnceLock<HashSet<&'static str>> = OnceLock::new();
E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
}
@@ -395,13 +395,16 @@ pub fn has_dictionary(lang: Option<&str>) -> bool {
ProofLang::from_tag(lang).is_proofed()
}
/// The Hunspell dictionary name (`pl_PL`, `it_IT`) a proofing language needs but doesn't have
/// The Hunspell dictionary name (`pl_PL`, `pt_BR`) a proofing language needs but doesn't have
/// installed, for a status-bar hint (#1167). `None` when the language's dictionary is available,
/// or when it needs none (English has a built-in word list; unproofed languages aren't offered
/// one). Reads the dictionary once (it is cached afterwards).
pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> {
let lang = ProofLang::from_tag(lang);
dictionary_needed(lang, dictionary_loaded(lang)?)
match ProofLang::from_tag(lang) {
ProofLang::Pl => dictionary_needed(ProofLang::Pl, pl::has_dictionary()),
ProofLang::Pt => dictionary_needed(ProofLang::Pt, pt::has_dictionary()),
_ => None,
}
}
/// The Hunspell name, `.dic` file and reason of a dictionary that is installed for this language
@@ -409,38 +412,24 @@ pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> {
/// loaded, or when no file exists (then [`missing_dictionary`] names it). Reads the dictionary
/// once (it is cached afterwards).
pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::path::PathBuf, String)> {
let lang = ProofLang::from_tag(lang);
let name = dictionary_needed(lang, dictionary_loaded(lang)?)?;
let (proof_lang, loaded) = match ProofLang::from_tag(lang) {
ProofLang::Pl => (ProofLang::Pl, pl::has_dictionary()),
ProofLang::Pt => (ProofLang::Pt, pt::has_dictionary()),
_ => return None,
};
let name = dictionary_needed(proof_lang, false)?;
if loaded {
return None;
}
hunspell::load_error(name).map(|(file, reason)| (name, file, reason))
}
/// Did the Hunspell dictionary of `lang` load? `None` for languages that use none (English).
fn dictionary_loaded(lang: ProofLang) -> Option<bool> {
Some(match lang {
ProofLang::Pl => pl::has_dictionary(),
ProofLang::Pt => pt::has_dictionary(),
ProofLang::It => it::has_dictionary(),
ProofLang::Hr => hr::has_dictionary(),
ProofLang::Tr => tr::has_dictionary(),
ProofLang::Ru => ru::has_dictionary(),
ProofLang::Cs => cs::has_dictionary(),
ProofLang::Id => id::has_dictionary(),
_ => return None,
})
}
/// The Hunspell name `lang` needs when `installed` is false (the first name its module looks for).
/// The Hunspell name `lang` needs when `installed` is false; only Polish and Portuguese use one.
fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> {
match lang {
_ if installed => None,
ProofLang::Pl => Some("pl_PL"),
ProofLang::Pt => Some("pt_BR"),
ProofLang::It => Some("it_IT"),
ProofLang::Hr => Some("hr_HR"),
ProofLang::Tr => Some("tr_TR"),
ProofLang::Ru => Some("ru_RU"),
ProofLang::Cs => Some("cs_CZ"),
ProofLang::Id => Some("id_ID"),
_ => None,
}
}
@@ -729,14 +718,14 @@ pub fn is_correct_in(word: &str, lang: ProofLang) -> bool {
pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
match lang {
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => is_correct_english(word, lang, opts),
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct_with_options(word, opts),
ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct_with_options(word, opts),
ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct_with_options(word, opts),
ProofLang::It => accepted_by_options(word, opts) || it::is_correct_with_options(word, opts),
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word),
ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct(word),
ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word),
ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word),
ProofLang::Tr => accepted_by_options(word, opts) || tr::is_correct(word),
ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word),
ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct_with_options(word, opts),
ProofLang::Id => accepted_by_options(word, opts) || id::is_correct_with_options(word, opts),
ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct(word),
ProofLang::Id => accepted_by_options(word, opts) || id::is_correct(word),
ProofLang::Bg | ProofLang::De | ProofLang::Other => true,
}
}
@@ -889,7 +878,7 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec<Issue> {
if gap == " "
&& (w0 == "an" || w0 == "An")
&& w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c))
&& !w1.chars().all(char::is_uppercase)
&& !w1.chars().all(|c| c.is_uppercase())
{
v.push(Issue {
start: *a0,
@@ -968,23 +957,6 @@ mod tests {
assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR"));
assert_eq!(dictionary_needed(ProofLang::Pl, true), None);
assert_eq!(dictionary_needed(ProofLang::Pt, true), None);
// Every Hunspell language is named by the first name its module looks for (#3150).
for (l, name) in [
(ProofLang::It, "it_IT"),
(ProofLang::Hr, "hr_HR"),
(ProofLang::Tr, "tr_TR"),
(ProofLang::Ru, "ru_RU"),
(ProofLang::Cs, "cs_CZ"),
(ProofLang::Id, "id_ID"),
] {
assert_eq!(dictionary_needed(l, false), Some(name), "{l:?}");
assert_eq!(dictionary_needed(l, true), None, "{l:?}");
}
// Every proofed language other than English has a Hunspell dictionary to report.
for l in ProofLang::PROOFED {
let english = matches!(l, ProofLang::En | ProofLang::EnGb | ProofLang::EnCa);
assert_eq!(dictionary_needed(l, false).is_some(), !english, "{l:?}");
}
for l in [ProofLang::En, ProofLang::Bg, ProofLang::De, ProofLang::Other] {
assert_eq!(dictionary_needed(l, false), None);
}
+2 -32
View File
@@ -96,30 +96,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Polish dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -221,22 +209,4 @@ mod tests {
crate::add_word("Zxqpolskisłowo");
assert!(is_correct("zxqpolskisłowo"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Polish checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_polish_check() {
test_dict::install();
let lang = crate::ProofLang::Pl;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŻEKA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŻEKA", lang, &check_caps));
assert!(crate::is_correct_in_with("RZEKA", lang, &check_caps));
assert!(crate::is_correct_in_with("żeka2", lang, &defaults));
assert!(!crate::is_correct_in_with("żeka2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŻEKA"), "direct checks keep the defaults");
}
}
+2 -32
View File
@@ -91,30 +91,18 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Portuguese dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
return true;
}
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -229,22 +217,4 @@ mod tests {
crate::add_word("Unigranrio-RJ");
assert!(crate::check_spelling_in(text, crate::ProofLang::Pt).is_empty());
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Portuguese checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_portuguese_check() {
test_dict::install();
let lang = crate::ProofLang::Pt;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("PRAYA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("PRAYA", lang, &check_caps));
assert!(crate::is_correct_in_with("PRAIA", lang, &check_caps));
assert!(crate::is_correct_in_with("praya2", lang, &defaults));
assert!(!crate::is_correct_in_with("praya2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("PRAYA"), "direct checks keep the defaults");
}
}
+2 -3
View File
@@ -63,11 +63,10 @@ pub fn dictionary() -> Arc<AffixDict> {
fn load_installed() -> AffixDict {
let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
crate::hunspell::clear_load_error(NAMES[0]);
log::info!("Russian spelling: no Hunspell ru_RU dictionary installed");
return AffixDict::default();
};
crate::hunspell::note_load(NAMES[0], &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| {
log::warn!("Russian spelling: {e}");
AffixDict::default()
})
@@ -109,7 +108,7 @@ pub fn has_dictionary() -> bool {
/// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)?
pub(crate) fn is_cyrillic(c: char) -> bool {
matches!(c, '\u{0400}'..='\u{052F}')
matches!(c, '\u{0400}'..='\u{04FF}' | '\u{0500}'..='\u{052F}')
}
/// Particles joined to a word with a hyphen (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`,
+1 -8
View File
@@ -1,13 +1,6 @@
//! Decoded-media cache: hits need the same allocation, and the byte budget bounds what is kept.
use std::collections::HashMap;
use std::sync::Arc;
use wordcraft_doc::Document;
use wordcraft_doc::props::Rgb;
use wordcraft_layout::display::{Draw, Stroke};
use super::{CACHE_BYTES, MediaCache, RenderOptions, render_items, wave_path};
use super::*;
#[test]
fn media_cache_only_hits_the_same_allocation() {
+2 -5
View File
@@ -1,12 +1,9 @@
//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour.
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
use wordcraft_layout::display::{DisplayOptions, Draw, page_display};
use super::*;
use wordcraft_layout::display::page_display;
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
use super::{RenderOptions, Rendered, render_page};
/// Colour emoji families the system fallback may pick.
const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"];
-4
View File
@@ -10,10 +10,6 @@ workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
+1 -81
View File
@@ -1,6 +1,5 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md), and embed the craft-i18n interface catalogs. Nothing is read at
//! run time.
//! standards/contributors.md). Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
@@ -8,12 +7,6 @@
//! JSON says the entry was verified (`people_entry`).
//!
//! A missing or malformed file gives empty tables (or no names) and a warning.
//!
//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in
//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, WordCraft
//! catalog) entry per locale that has a `wordcraft.tsv`. Unset, or not a checkout (a warning),
//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (releases) that
//! is an error instead. Recipe: craft-i18n's `docs/integration.md`.
use std::fmt::Write as _;
@@ -31,79 +24,6 @@ fn main() {
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display());
}
let out = out.with_file_name("craft_i18n.rs");
if let Err(e) = std::fs::write(&out, craft_i18n()) {
println!("cargo::error=writing {}: {e}", out.display());
}
}
/// The catalog this app reads in each locale: `locales/<locale>/wordcraft.tsv`.
const APP_CATALOG: &str = "wordcraft.tsv";
/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads.
fn craft_i18n() -> String {
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED");
let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1");
let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) {
// Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read.
Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) {
Ok(entries) => entries,
Err(e) => {
let level = if required { "error" } else { "warning" };
println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only");
Vec::new()
}
},
None => {
if required {
println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations");
}
Vec::new()
}
};
let mut o = String::from("/// (locale, shared catalog, WordCraft's catalog), one entry per locale; `\"\"` for a missing file.\n");
o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n");
for (code, shared, app) in entries {
// `{:?}` escapes the path (Windows backslashes) as a Rust string literal.
let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})"));
let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),");
}
o.push_str("];\n");
o
}
/// Every locale under `dir` with this app's catalog, by code: (code, its `common.tsv` if any, its
/// app catalog). An error when `dir` isn't a craft-i18n `locales/` directory.
#[allow(clippy::type_complexity)]
fn locales(dir: &std::path::Path) -> Result<Vec<(String, Option<std::path::PathBuf>, std::path::PathBuf)>, String> {
// Build scripts run in the crate's directory and `include_str!` resolves against `OUT_DIR`, so
// a relative path would mean two different places.
if !dir.is_absolute() {
return Err(format!("{} is not an absolute path", dir.display()));
}
let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?;
println!("cargo::rerun-if-changed={}", dir.display());
let mut out = Vec::new();
for entry in list.flatten() {
let path = entry.path();
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
let app = path.join(APP_CATALOG);
if !app.is_file() {
continue;
}
println!("cargo::rerun-if-changed={}", app.display());
let shared = Some(path.join("common.tsv")).filter(|p| p.is_file());
if let Some(p) = &shared {
println!("cargo::rerun-if-changed={}", p.display());
}
out.push((code, shared, app));
}
if out.is_empty() {
return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display()));
}
out.sort();
Ok(out)
}
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
+11 -18
View File
@@ -7,23 +7,6 @@ use wordcraft_doc::{PartKind, StoryRef};
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
use crate::{WordApp, icons};
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
match (unreadable, missing) {
(Some((file, reason)), _) => Some(crate::i18n::fmt(
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
&[("language", lang), ("file", &file.to_string_lossy()), ("reason", reason)],
)),
(None, Some(name)) => Some(crate::i18n::fmt(
tl!(
"No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."
),
&[("language", lang), ("name", name)],
)),
(None, None) => None,
}
}
/// The Quick Access Toolbar's button for command `id`.
pub(crate) fn qat_button_id(id: &str) -> egui::Id {
egui::Id::new(("qat_button", id))
@@ -607,7 +590,17 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
} else {
lang.clone()
};
let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
let tip = match (&unreadable, missing) {
(Some((file, reason)), _) => crate::i18n::fmt(
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
&[("language", &lang), ("file", &file.to_string_lossy()), ("reason", reason)],
),
(None, Some(name)) => crate::i18n::fmt(
tl!("No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."),
&[("language", &lang), ("name", name)],
),
(None, None) => tl!("Language").to_string(),
};
let resp = st(ui, &shown).on_hover_text(tip);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
+24 -29
View File
@@ -1250,7 +1250,7 @@ impl Dialog {
filter: RecipientFilter::from_session(m),
}
} else {
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(ToString::to_string).collect();
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect();
let rows = vec![vec![String::new(); fields.len()]];
Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() }
}
@@ -4259,34 +4259,11 @@ mod tests {
#[test]
fn command_search_respects_turkish_i_pairs_and_keeps_canonical_matches() {
use egui_kittest::kittest::Queryable;
// Turkish casing follows the language code; the labels are fixtures with both I pairs.
const LABELS: &str =
"\tItalic\tİtalik\n\tHome\tGiriş\n\tFont\tYazı Tipi\n\tTable of Contents\tİçindekiler Tablosu\n\tReferences\tBaşvurular\n";
let reg = app_with("").session.registry.clone();
let italic = reg.get("format.italic").unwrap();
let table = reg.get("insert.table").unwrap();
let toc = reg.all().iter().find(|c| c.label == "Table of Contents" && c.location == "References › Table of Contents").unwrap();
crate::i18n::set_current(crate::i18n::fixture_lang("tr", LABELS));
for query in ["italik", "İTALİK", "YAZI TİPİ", "FORMAT.ITALIC", "ITALIC"] {
assert!(command_matches(italic, query), "tr: {query}");
}
for query in ["içindekiler", "TABLE OF CONTENTS"] {
assert!(command_matches(toc, query), "tr: {query}");
}
for query in ["INSERT", "INSERT.TABLE"] {
assert!(command_matches(table, query), "tr: {query}");
}
assert_eq!(crate::i18n::lowercase("Iİıi"), "ıiıi");
// The same labels under another language's code use Unicode's default casing.
crate::i18n::set_current(crate::i18n::fixture_lang("x-fixture", LABELS));
assert!(!command_matches(italic, "italik"), "İ lowercases to i plus a combining dot");
assert_eq!(crate::i18n::lowercase("Iİıi"), "ii\u{307}ıi");
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
move |ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.ui.language));
if fonts {
show(app, &ctx);
} else {
@@ -4297,18 +4274,36 @@ mod tests {
app_with("Unchanged document"),
);
let before = serde_json::to_value(&h.state().session.doc).unwrap();
for (query, expected) in
[("ITALIC", "Italic — Home › Font"), ("FORMAT.ITALIC", "Italic — Home › Font"), ("INSERT.TABLE", "Table — Insert › Tables")]
{
for (language, query, expected) in [
("tr", "italik", "İtalik — Giriş › Yazı Tipi"),
("tr", "İTALİK", "İtalik — Giriş › Yazı Tipi"),
("tr", "içindekiler", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"),
("tr", "YAZI TİPİ", "İtalik — Giriş › Yazı Tipi"),
// Dozens of commands match a lone INSERT and the box shows 14, in registry order: the
// witness is an early one; Table is checked below, past the box's limit.
("tr", "INSERT", "Bölünemez Boşluk — Ekle › Semboller"),
("tr", "INSERT.TABLE", "Tablo — Ekle › Tablolar"),
("tr", "FORMAT.ITALIC", "İtalik — Giriş › Yazı Tipi"),
("tr", "TABLE OF CONTENTS", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"),
("en", "ITALIC", "Italic — Home › Font"),
("en", "FORMAT.ITALIC", "Italic — Home › Font"),
("en", "INSERT.TABLE", "Table — Insert › Tables"),
] {
h.state_mut().ui.language = language.into();
h.state_mut().dialog = Some(Dialog::Commands { query: query.into(), swallow: false });
h.run_steps(4);
assert!(
h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains(expected)).is_some(),
"{query}\n{:?}",
"{language}: {query}\n{:?}",
h.query_all_by_role(egui::accesskit::Role::Button).collect::<Vec<_>>()
);
assert_eq!(crate::i18n::lowercase("Iİıi"), if language == "tr" { "ıiıi" } else { "ii\u{307}ıi" });
}
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), before, "search does not change the document");
crate::i18n::set_current(crate::i18n::Lang::from_pref("tr"));
let reg = h.state().session.registry.clone();
assert!(command_matches(reg.get("insert.table").unwrap(), "INSERT"), "tr: INSERT finds Table");
crate::i18n::set_current(crate::i18n::Lang::EN);
}
/// #2986: a box that keeps the keyboard asks for focus only while it lacks it. `request_focus`
+6 -8
View File
@@ -1905,9 +1905,8 @@ mod tests {
assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::<Vec<_>>(), [3, 3, 3, 3]);
}
/// The envelope sizes go through the interface language's catalog and use the region's
/// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in
/// English).
/// The envelope sizes are in the interface language with the region's decimal separator
/// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English).
#[test]
fn envelope_sizes_follow_the_interface_language_and_region() {
let size = |name: &str| ENVELOPE_SIZES.iter().find(|s| s.0 == name).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap();
@@ -1916,11 +1915,10 @@ mod tests {
let mut a = app();
a.run("file.options", json!({"region": "it-IT"})).unwrap();
crate::options::sync(&a);
assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 in");
crate::i18n::pseudo::set(true);
assert_eq!(size("Envelope #10"), "⟦#10 4,13 × 9,5 in⟧");
assert_eq!(size("Envelope DL"), "⟦DL 110 × 220 mm⟧");
crate::i18n::pseudo::set(false);
crate::i18n::set_current(crate::i18n::Lang::from_code("it").unwrap());
assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 pollici");
assert_eq!(size("Envelope DL"), "DL 110 × 220 mm");
crate::i18n::set_current(crate::i18n::Lang::EN);
a.run("file.options", json!({"region": "en-US"})).unwrap();
crate::options::sync(&a);
}
+2 -2
View File
@@ -242,7 +242,7 @@ pub struct CaptionForm {
impl CaptionForm {
pub fn read(app: &WordApp) -> CaptionForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
for l in &app.ui.caption_labels {
if !labels.contains(l) {
labels.push(l.clone());
@@ -937,7 +937,7 @@ pub struct FiguresForm {
impl FiguresForm {
pub fn read(app: &WordApp) -> FiguresForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
for l in app.ui.caption_labels.iter().cloned().chain(wordcraft_engine::cmd::citations::caption_labels(&app.session)) {
if !labels.contains(&l) {
labels.push(l);
+37 -42
View File
@@ -183,11 +183,10 @@ impl Exempt {
}
}
/// Fails on interface text drawn without going through the catalog, listed per UI state, and
/// prints the missing-translation report (`report_catalog_gaps`). `cargo xtask i18n-report` runs it
/// with `--nocapture`; `cargo xtask test` and `ci` run it that way instead of in the main test run.
/// It can also run with the other tests: input is synthetic and timed by `TIME`, Read Aloud never
/// starts a speech thread, and process-wide names other tests change are gathered every frame.
/// Lists untranslated interface text per UI state. `cargo test -p wordcraft-ui-egui
/// untranslated_interface_text -- --nocapture` prints the report. It runs with the other tests:
/// input is synthetic and timed by `TIME`, Read Aloud never starts a speech thread, and
/// process-wide names other tests change are gathered every frame.
#[test]
fn untranslated_interface_text() {
pseudo::set(true);
@@ -339,42 +338,16 @@ fn untranslated_interface_text() {
states.dedup();
report.push_str(&format!("{:?} [{}]\n", pseudo::unmarked(text), states.join(", ")));
}
report_catalog_gaps(pseudo::looked_up());
assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len());
report_catalog_gaps(pseudo::looked_up());
}
/// The missing-translation report (craft-i18n's S3; `cargo xtask i18n-report`, part of `cargo xtask
/// ci`): the embedded catalogs' problems (the crate's `check_all`), and per language the interface
/// strings with no translation (the missing bucket, which translators fill in craft-i18n) and those
/// whose translation is the English itself (kept English, not counted as translated). A missing
/// translation falls back to English, so the report never fails; without embedded catalogs
/// (`CRAFT_I18N_DIR` unset) it is skipped. The strings are the ones the audit saw looked up, the
/// ribbon tabs, every command's label and ribbon location, the File › Home greetings, every reason
/// a command gives for being off (#2678) and the equation gallery's labels (shown in menus the
/// audit doesn't open one by one). Numbered style names go through a template.
/// Prints, per language, the strings the audit saw looked up (plus the equation gallery's labels,
/// shown in menus the audit doesn't open one by one) that its catalog has no entry for. A missing
/// translation falls back to English, so it never fails the test; `--nocapture` shows the list.
/// Numbered style names go through a template.
fn report_catalog_gaps(mut keys: std::collections::BTreeSet<String>) {
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
if super::CRAFT_I18N.is_empty() {
eprintln!("missing-translation report skipped: built without craft-i18n (set CRAFT_I18N_DIR to a craft-i18n checkout)");
return;
}
let problems = super::I18N.check_all();
if !problems.is_empty() {
eprintln!("{} catalog rows are skipped (they show in English):\n{problems:#?}", problems.len());
}
let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]);
keys.extend(tabs.map(|t| t.to_string()));
keys.extend(["Good morning", "Good afternoon", "Good evening"].map(String::from));
keys.extend(disabled_reasons());
// Reasons the UI itself gives (`widgets.rs::tooltip`), which the engine's checks never reach.
keys.insert("Open a document first".to_string());
for doc in [wordcraft_doc::Document::new(), wordcraft_engine::sample::sample_document()] {
let session = wordcraft_engine::Session::new(doc);
for spec in session.registry.all() {
keys.extend(std::iter::once(spec.label).chain(spec.location.split(" › ")).filter(|s| !s.is_empty()).map(String::from));
keys.extend((spec.enabled)(&session).map(String::from));
}
}
keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string()));
keys.extend(SYMBOL_SETS.iter().map(|(name, _)| name.to_string()));
for g in STRUCTURES {
@@ -418,13 +391,10 @@ fn report_catalog_gaps(mut keys: std::collections::BTreeSet<String>) {
// translate: its placeholder names aren't words the interface shows.
let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::<String>());
for lang in super::Lang::all().filter(|l| *l != super::Lang::EN) {
let reported = keys.iter().filter(|s| words(s) && !numbered(s) && !same(s) && !script_error(s));
let (kept, missing): (Vec<&String>, Vec<&String>) = reported.filter(|s| super::tr(lang, s) == s.as_str()).partition(|s| super::has(lang, s));
let missing: Vec<&String> =
keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect();
if !missing.is_empty() {
eprintln!("{} strings have no {} translation:\n{missing:#?}", missing.len(), lang.code());
}
if !kept.is_empty() {
eprintln!("{} strings are kept in English in {}:\n{kept:#?}", kept.len(), lang.code());
eprintln!("{} strings have no {} entry:\n{missing:#?}", missing.len(), lang.code());
}
}
}
@@ -541,3 +511,28 @@ fn disabled_reasons() -> std::collections::BTreeSet<String> {
}
reasons
}
/// Ribbon tooltips show why a control is off in the interface's language: every reason has an
/// entry in every catalog (#2678).
#[test]
fn every_disabled_reason_is_translated() {
let reasons = disabled_reasons();
// The source scan finds every reason the registry gives in a blank and the sample document.
for doc in [wordcraft_doc::Document::new(), wordcraft_engine::sample::sample_document()] {
let session = wordcraft_engine::Session::new(doc);
for spec in session.registry.all() {
if let Some(r) = (spec.enabled)(&session) {
assert!(reasons.contains(r), "{}: the source scan missed its reason {r:?}", spec.id);
}
}
}
assert!(reasons.len() > 20, "the source scan found only {reasons:#?}");
// Reasons the UI itself gives (`widgets.rs::tooltip`), which the engine's checks never reach.
let mut reasons = reasons;
reasons.insert("Open a document first".to_string());
let mut missing = Vec::new();
for l in super::Lang::all().filter(|l| *l != super::Lang::EN) {
missing.extend(reasons.iter().filter(|r| !super::has(l, r)).map(|r| format!("{}: {r:?}", l.code())));
}
assert!(missing.is_empty(), "{} disabled reasons are untranslated:\n{missing:#?}", missing.len());
}
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
//! Parser and lookup table for translation catalogs (`*.tsv`, format documented in `zh-hans.tsv`).
//! 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
//! catalog still loads and the skipped string shows in English. The tests insist the bundled
//! catalogs have no errors at all.
use std::collections::{HashMap, HashSet};
/// A catalog entry as read from the file.
#[derive(Debug, PartialEq, Eq)]
pub struct Entry {
pub context: String,
pub source: String,
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.
/// - `template`: the name of a built-in document template (Home and New), which may need other
/// wording than the same English word elsewhere (the "Letter" template and the "Letter" paper
/// size); it falls back to the plain entry.
pub const CONTEXTS: &[&str] = &["preview", "template"];
/// 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> {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),
None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),
}
}
Ok(out)
}
/// `{name}` placeholders of a template, sorted (translators may reorder them).
pub fn placeholders(text: &str) -> Vec<&str> {
let mut names = Vec::new();
let mut rest = text;
while let Some((_, after_open)) = rest.split_once('{') {
let Some((name, after_close)) = after_open.split_once('}') else { break };
names.push(name);
rest = after_close;
}
names.sort_unstable();
names
}
/// Check one entry against its English source.
fn validate(entry: &Entry) -> Result<(), String> {
let Entry { context, source, translation } = entry;
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());
}
if source.ends_with('…') != translation.ends_with('…') {
return Err("trailing ellipsis differs from the English source".into());
}
Ok(())
}
fn parse_line(line: &str) -> Result<Entry, String> {
let mut columns = line.split('\t');
let (Some(context), Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next(), columns.next()) else {
return Err("expected `context<TAB>source<TAB>translation`".into());
};
let entry = Entry { context: unescape(context)?, source: unescape(source)?, translation: unescape(translation)? };
if entry.source.is_empty() || entry.translation.is_empty() {
return Err("source and translation must be nonempty".into());
}
validate(&entry)?;
Ok(entry)
}
/// Read the entries of a catalog file. Malformed lines and duplicates (the first entry wins) are
/// returned as errors and skipped, so a bad translation never breaks the UI.
pub fn parse_entries(text: &str) -> (Vec<Entry>, Vec<String>) {
let mut entries = Vec::new();
let mut errors = Vec::new();
let mut seen = HashSet::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let line_no = index.saturating_add(1);
match parse_line(line) {
Ok(entry) if !seen.insert((entry.context.clone(), entry.source.clone())) => {
errors.push(format!("line {line_no}: duplicate key {:?} / {:?}", entry.context, entry.source));
}
Ok(entry) => entries.push(entry),
Err(error) => errors.push(format!("line {line_no}: {error}")),
}
}
(entries, errors)
}
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 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))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+203 -159
View File
@@ -1,216 +1,256 @@
//! Interface languages (#8). Strings in the code stay English and are the lookup keys; the
//! craft-i18n catalogs map them to display text when the UI is drawn. Command ids, document text,
//! file names, the control channel, the CLI and MCP never see translated text, so scripts and
//! agents are unaffected. A string without a translation shows in English, so coverage can grow
//! one entry at a time, and a missing translation never fails a build, a test or a release.
//!
//! The catalogs live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n)
//! (`locales/<locale>/common.tsv` and `wordcraft.tsv`), not in this repository. `build.rs` embeds
//! them from `CRAFT_I18N_DIR` (CI and releases check out the SHA in `craft-i18n.rev`); without it
//! the build is English only. Lookup, fallback (`pt-br` → `pt` → English, app rows over shared
//! ones), plurals, language names and locale matching are the `craft-i18n` crate's (craft-libs).
//! Interface languages (#8). Strings in the code stay English and are the lookup keys; a catalog
//! per language (`*.tsv`, format in `zh-hans.tsv`) maps them to display text when the UI is drawn.
//! Command ids, document text, file names, the control channel, the CLI and MCP never see
//! translated text, so scripts and agents are unaffected. A string without a translation shows in
//! English, so coverage can grow one entry at a time.
//!
//! The `language` setting is `auto` by default: the UI follows the operating system's language
//! (Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere) when there is a
//! catalog for it, and English otherwise. File ▸ Options ▸ Interface language picks one by hand.
//!
//! # Adding or changing a translation
//! In craft-i18n: add rows to `locales/<locale>/wordcraft.tsv` (its `docs/integration.md`); they
//! show here once `craft-i18n.rev` moves to a commit that has them. A feature PR here adds the
//! English only.
//! This is the system PdfCraft and PhotoCraft use (`pdfcraft/crates/ui-egui/src/i18n`), so the
//! catalog format and habits carry over between the Crafting Apps.
//!
//! # Adding a language
//! 1. craft-i18n gets `locales/<code>/wordcraft.tsv` and the language's `@id language.name` row.
//! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the
//! English text, clean-room: never from Microsoft Word's or another product's localisation).
//! 2. Add one row to [`LANGUAGES`]. Set its `editing_lang` only when `wordcraft-proof` can proof
//! the language (Polish: `pl-PL`): new documents are then written in it.
//!
//! The Options dropdown and the system-language match pick it up from there.
//! The Options dropdown, the system-language match and the catalog tests pick it up from there.
//!
//! # 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.
//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open").
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
mod catalog;
use std::cell::Cell;
use std::sync::{LazyLock, OnceLock};
use std::sync::OnceLock;
use craft_i18n::I18n;
pub use craft_i18n::fmt;
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
/// The embedded catalogs, offering exactly the languages in [`LANGUAGES`]. Built on first use;
/// a locale is parsed on its first lookup.
static I18N: LazyLock<I18n> = LazyLock::new(|| {
let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect();
let (i18n, problems) = I18n::new(CRAFT_I18N, &offered);
problems.iter().for_each(|p| log::warn!("interface catalogs: {p}"));
i18n
});
use catalog::Catalog;
/// The `language` setting that follows the system.
pub const AUTO: &str = "auto";
/// One interface language WordCraft offers: what the app needs beyond the catalogs. Its name and
/// translations come from craft-i18n.
/// One supported interface language.
pub struct LangInfo {
/// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value and the
/// craft-i18n locale.
/// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value.
pub code: &'static str,
/// The language's name in itself, shown in the Options dropdown.
pub name: &'static str,
/// Catalog file contents (empty for the built-in English).
pub source: &'static str,
/// Which CJK interface face comes first (Chinese text wants the Chinese face).
pub prefer_hans: bool,
/// The language (BCP 47) new blank documents are written in with this interface, for
/// languages WordCraft can proof; `None` keeps WordCraft's default, US English.
pub editing_lang: Option<&'static str>,
catalog: OnceLock<Catalog>,
}
/// The languages WordCraft offers, when their catalogs are embedded. English first: it is the
/// fallback and the source language.
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 27] = [
LangInfo { code: "en", prefer_hans: false, editing_lang: None },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching).
LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None },
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, 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,
editing_lang: None,
catalog: OnceLock::new(),
},
// Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here.
LangInfo { code: "zh-hant", prefer_hans: true, editing_lang: None },
LangInfo { code: "ja", prefer_hans: false, editing_lang: None },
LangInfo { code: "ko", prefer_hans: false, editing_lang: None },
LangInfo { code: "uk", prefer_hans: false, editing_lang: None },
LangInfo {
code: "zh-hant",
name: "繁體中文",
source: include_str!("zh-hant.tsv"),
prefer_hans: true,
editing_lang: None,
catalog: OnceLock::new(),
},
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo {
code: "ko",
name: "한국어",
source: include_str!("ko.tsv"),
prefer_hans: false, // 일본어처럼 CJK지만 한자 우선순위는 필요 없으므로 false
editing_lang: None, // 현재 한국어 맞춤법(proofing) 지원이 없으므로 None
catalog: OnceLock::new(),
},
LangInfo {
code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new()
},
// Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here.
LangInfo { code: "es", prefer_hans: false, editing_lang: None },
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
// catalog yet (the European vocabulary differs), so they stay in English ([`lang_from_tag`]).
LangInfo { code: "pt-br", prefer_hans: false, editing_lang: Some("pt-BR") },
// catalog yet (the European vocabulary differs), so they stay in English.
LangInfo {
code: "pt-br",
name: "Português (Brasil)",
source: include_str!("pt-br.tsv"),
prefer_hans: false,
editing_lang: Some("pt-BR"),
catalog: OnceLock::new(),
},
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
// `sr-Cyrl-*` resolve here.
LangInfo { code: "sr", prefer_hans: false, editing_lang: None },
// Serbian, Latin script; `sr-Latn-*` resolves here; a script is never
// crossed, so Latin Serbian never shows Cyrillic.
LangInfo { code: "sr-latn", prefer_hans: false, editing_lang: None },
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
// 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,
editing_lang: None,
catalog: OnceLock::new(),
},
// Polish; `pl`, `pl-PL` and `pl_PL.UTF-8` resolve here. WordCraft proofs Polish (spelling,
// grammar, hyphenation), so new documents are written in Polish with this interface.
LangInfo { code: "pl", prefer_hans: false, editing_lang: Some("pl-PL") },
LangInfo { code: "cs", prefer_hans: false, editing_lang: Some("cs-CZ") },
LangInfo { code: "et", prefer_hans: false, editing_lang: None },
LangInfo {
code: "pl",
name: "Polski",
source: include_str!("pl.tsv"),
prefer_hans: false,
editing_lang: Some("pl-PL"),
catalog: OnceLock::new(),
},
LangInfo {
code: "cs",
name: "Čeština",
source: include_str!("cs.tsv"),
prefer_hans: false,
editing_lang: Some("cs-CZ"),
catalog: OnceLock::new(),
},
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` …
// resolve here.
LangInfo { code: "de", prefer_hans: false, editing_lang: None },
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here.
LangInfo { code: "ru", prefer_hans: false, editing_lang: None },
LangInfo { code: "tr", prefer_hans: false, editing_lang: Some("tr-TR") },
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo {
code: "tr",
name: "Türkçe",
source: include_str!("tr.tsv"),
prefer_hans: false,
editing_lang: Some("tr-TR"),
catalog: OnceLock::new(),
},
// Italian; `it-IT`, `it-CH` and `it-*` resolve here. New documents are written in
// Italian: `wordcraft-proof` checks it (`ProofLang::It`).
LangInfo { code: "it", prefer_hans: false, editing_lang: Some("it-IT") },
LangInfo {
code: "it",
name: "Italiano",
source: include_str!("it.tsv"),
prefer_hans: false,
editing_lang: Some("it-IT"),
catalog: OnceLock::new(),
},
// Hungarian; `hu`, `hu-HU` and `hu_HU.UTF-8` resolve here.
LangInfo { code: "hu", prefer_hans: false, editing_lang: None },
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here.
LangInfo { code: "fr", prefer_hans: false, editing_lang: None },
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Bulgarian; `bg`, `bg-BG` and `bg_BG.UTF-8` resolve here.
LangInfo { code: "bg", prefer_hans: false, editing_lang: None },
LangInfo {
code: "bg", name: "Български", source: include_str!("bg.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new()
},
// Vietnamese; `vi`, `vi-VN` and `vi_VN.UTF-8` resolve here.
LangInfo { code: "vi", prefer_hans: false, editing_lang: None },
LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Swedish; sv, sv-SE, sv-FI and sv_SE.UTF-8 resolve here.
LangInfo { code: "sv", prefer_hans: false, editing_lang: None },
LangInfo { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Croatian; `hr`, `hr-HR` and `hr_HR.UTF-8` resolve here.
LangInfo { code: "hr", prefer_hans: false, editing_lang: None },
LangInfo { code: "hr", name: "Hrvatski", source: include_str!("hr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Dutch; `nl`, `nl-NL`, `nl-BE` and `nl_NL.UTF-8` resolve here.
LangInfo { code: "nl", prefer_hans: false, editing_lang: None },
LangInfo { code: "nl", name: "Nederlands", source: include_str!("nl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Danish; `da`, `da-DK` and `da_DK.UTF-8` resolve here.
LangInfo { code: "da", prefer_hans: false, editing_lang: None },
LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here.
LangInfo { code: "ro", prefer_hans: false, editing_lang: None },
LangInfo { code: "ro", name: "Română", source: include_str!("ro.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here.
LangInfo { code: "sl", prefer_hans: false, editing_lang: None },
LangInfo { code: "sl", name: "Slovenščina", source: include_str!("sl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
];
/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded.
#[derive(Clone, Copy)]
pub struct Lang {
lang: craft_i18n::Lang,
/// The catalogs it is looked up in: the embedded ones ([`I18N`]), or a test fixture's.
catalogs: Option<&'static I18n>,
impl LangInfo {
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| {
let (catalog, errors) = Catalog::parse(self.source);
for error in errors {
log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
}
catalog
})
}
}
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
#[derive(Clone, Copy)]
pub struct Lang(&'static LangInfo);
impl std::fmt::Debug for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Lang({})", self.code())
write!(f, "Lang({})", self.0.code)
}
}
impl PartialEq for Lang {
fn eq(&self, other: &Self) -> bool {
self.code() == other.code()
self.0.code == other.0.code
}
}
impl Eq for Lang {}
impl Lang {
pub const EN: Lang = Lang { lang: craft_i18n::Lang::EN, catalogs: None };
fn i18n(self) -> &'static I18n {
self.catalogs.unwrap_or_else(|| LazyLock::force(&I18N))
}
fn info(self) -> Option<&'static LangInfo> {
let code = self.code();
LANGUAGES.iter().find(|l| l.code == code)
}
pub const EN: Lang = Lang(&LANGUAGES[0]);
pub fn code(self) -> &'static str {
self.i18n().code(self.lang)
self.0.code
}
/// The language's name in itself (its `@id language.name` row), shown in the Options dropdown.
pub fn name(self) -> &'static str {
self.i18n().name(self.lang)
self.0.name
}
pub fn prefers_hans(self) -> bool {
self.info().is_some_and(|l| l.prefer_hans)
self.0.prefer_hans
}
pub fn prefers_korean(self) -> bool {
self.code() == "ko"
self.0.code == "ko"
}
/// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]).
pub fn editing_lang(self) -> Option<&'static str> {
self.info().and_then(|l| l.editing_lang)
self.0.editing_lang
}
/// Is the interface written in CJK script (its own name is), so it needs a CJK face?
pub fn uses_cjk(self) -> bool {
self.name().chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
}
/// The embedded catalogs (shared, then WordCraft's) this language reads; none for English.
fn catalog_texts(self) -> impl Iterator<Item = &'static str> {
let code = self.code();
CRAFT_I18N.iter().filter(move |(c, ..)| c.eq_ignore_ascii_case(code)).flat_map(|(_, shared, app)| [*shared, *app])
self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
}
/// The CJK interface text the fonts must cover with the Chinese (`hans`) or other CJK fallback
/// order: the catalogs of the CJK languages using that order, and every language's own name (the
/// Options list). The interface fonts check the embedded faces against it (#486).
pub fn cjk_font_text(hans: bool) -> (Vec<&'static str>, String) {
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).flat_map(Lang::catalog_texts).collect();
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).map(|l| l.0.source).collect();
(catalogs, Lang::all().map(Lang::name).collect::<Vec<_>>().join(" "))
}
/// The CJK interface catalogs using the Korean or Chinese writing-system preference.
pub fn cjk_font_text_for(hans: bool, korean: bool) -> (Vec<&'static str>, String) {
let catalogs =
Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).flat_map(Lang::catalog_texts).collect();
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).map(|l| l.0.source).collect();
(catalogs, Lang::all().map(Lang::name).collect::<Vec<_>>().join(" "))
}
/// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded.
/// A language by its exact code (any case).
pub fn from_code(code: &str) -> Option<Lang> {
let info = LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code))?;
I18N.from_code(info.code).map(|lang| Lang { lang, catalogs: None })
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
}
/// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a
@@ -219,10 +259,9 @@ impl Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every language the interface can be shown in: [`LANGUAGES`] in order, those with embedded
/// catalogs (English only in a build without craft-i18n).
/// Every registered language.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().filter_map(|l| Lang::from_code(l.code))
LANGUAGES.iter().map(Lang)
}
/// Every registered language in the order a picker lists them: English first (the source
@@ -234,6 +273,10 @@ impl Lang {
rest.sort_by_cached_key(|l| name_sort_key(l.name()));
std::iter::once(Lang::EN).chain(rest).collect()
}
fn catalog(self) -> &'static Catalog {
self.0.catalog()
}
}
/// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case).
@@ -245,22 +288,31 @@ pub fn normalize_pref(pref: &str) -> Option<&'static str> {
Lang::from_code(pref).map(Lang::code)
}
/// Portuguese other than Brazil's (`pt`, `pt-PT`, `pt_AO.UTF-8`): WordCraft has no catalog for
/// it (the European vocabulary differs), so it stays English rather than falling to `pt-br` as the
/// crate's matching would.
fn non_brazilian_portuguese(tag: &str) -> bool {
let tag = tag.trim().to_ascii_lowercase();
let mut parts = tag.split(['.', '@']).next().unwrap_or("").split(['-', '_']);
parts.next() == Some("pt") && !parts.any(|p| p == "br")
/// Candidate codes for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`.
fn candidates(tag: &str) -> Vec<String> {
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
let Some(&primary) = parts.first() else { return Vec::new() };
let mut out = Vec::new();
for n in (1..=parts.len()).rev() {
out.push(parts.get(..n).unwrap_or_default().join("-"));
}
if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) {
// Chinese by region when no script is given.
let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" };
out.insert(out.len().saturating_sub(1), script.to_string());
}
out
}
/// The registered language for a locale tag such as `ja_JP.UTF-8` or `zh-Hant-TW`; `None` when
/// there is no catalog for it. `C`/`POSIX` mean English.
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
if non_brazilian_portuguese(tag) {
return None;
let cands = candidates(tag);
if matches!(cands.first().map(String::as_str), Some("c" | "posix")) {
return Some(Lang::EN);
}
I18N.match_tag(tag).and_then(|lang| Lang::from_code(I18N.code(lang)))
cands.iter().find_map(|c| Lang::from_code(c))
}
/// The first of the system's preferred languages WordCraft has, in the user's order: someone
@@ -316,9 +368,9 @@ pub fn current() -> Lang {
CURRENT.get()
}
/// Does `lang` have a translation for this string? (English never does: it is the source.)
/// Does `lang` have a catalog entry for this string? (English never does: it is the source.)
pub fn has(lang: Lang, s: &str) -> bool {
lang.i18n().get(lang.lang, "", s).is_some()
lang.catalog().plain(s).is_some()
}
/// `s` in the current language ([`tr`] with [`current`]).
@@ -326,8 +378,8 @@ pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// `s` in the current language as worded for `context` (`preview`, `template`), falling back to
/// the plain translation and then to English.
/// `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)
}
@@ -336,21 +388,9 @@ pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
#[cfg(test)]
if pseudo::on() {
return I18N.tc(craft_i18n::Lang::PSEUDO, context, s);
return pseudo::mark(s);
}
lang.i18n().tc(lang.lang, context, s)
}
/// `s`'s translation for `context` in the current language, with no fallback to the plain row
/// or English: for a word whose plain translation means something else here (the "Open"
/// paragraph-spacing preset is not the file verb).
pub fn get<'a>(context: &str, s: &'a str) -> Option<&'a str> {
#[cfg(test)]
if pseudo::on() {
return I18N.get(craft_i18n::Lang::PSEUDO, context, s);
}
let lang = current();
lang.i18n().get(lang.lang, context, s)
lang.catalog().in_context(context, s).unwrap_or(s)
}
/// Lowercase translated interface labels and UI search queries in the current language.
@@ -376,9 +416,9 @@ pub fn lowercase(s: &str) -> String {
pub fn tr(lang: Lang, s: &str) -> &str {
#[cfg(test)]
if pseudo::on() {
return I18N.t(craft_i18n::Lang::PSEUDO, s);
return pseudo::mark(s);
}
lang.i18n().t(lang.lang, s)
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
@@ -420,26 +460,30 @@ pub fn prefixed(prefix: &str, rest: &str) -> String {
fmt(t(&format!("{prefix} {{action}}")), &[("action", t(rest))])
}
/// A test language whose WordCraft catalog is `source` (catalog format), so tests of lookup code
/// don't depend on the embedded catalogs' wording. `code` selects code-specific rules such as
/// Turkish casing.
#[cfg(test)]
pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang {
let embedded: &'static craft_i18n::Embedded = Box::leak(Box::new([(code, "", source)]));
let (i18n, _) = I18n::new(embedded, &[code]);
let i18n: &'static I18n = Box::leak(Box::new(i18n));
let lang = i18n.from_code(code).unwrap_or(craft_i18n::Lang::EN);
Lang { lang, catalogs: Some(i18n) }
}
/// For a test that needs the embedded catalogs: `false`, with a note, when none were embedded
/// (`CRAFT_I18N_DIR` unset), so the test can skip; the interface is then English only.
#[cfg(test)]
pub(crate) fn catalogs_embedded() -> bool {
if CRAFT_I18N.is_empty() {
eprintln!("skipped: built without craft-i18n (CRAFT_I18N_DIR); the interface is English only");
/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted
/// values are never read as templates again, so a file name containing `{n}` stays intact.
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some((before, after_open)) = rest.split_once('{') {
out.push_str(before);
let Some((name, after_close)) = after_open.split_once('}') else {
out.push('{');
out.push_str(after_open);
return out;
};
match args.iter().find(|(key, _)| *key == name) {
Some((_, value)) => out.push_str(value),
None => {
out.push('{');
out.push_str(name);
out.push('}');
}
}
rest = after_close;
}
!CRAFT_I18N.is_empty()
out.push_str(rest);
out
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -8
View File
@@ -1,18 +1,20 @@
//! A test-only pseudo-language: while it is on (per thread), every string that goes through a
//! lookup comes back as the craft-i18n crate's pseudo locale marks it, wrapped in `⟦…⟧`, so text
//! drawn without the marks never went through translation.
//! A test-only pseudo-language: every string that goes through the catalog lookup comes back
//! wrapped in `⟦…⟧`, so text drawn without the marks never went through translation.
use std::cell::Cell;
use std::collections::BTreeSet;
use std::cell::{Cell, RefCell};
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 in the pseudo-language so far, in this process (the crate
/// keeps one list for every thread).
/// Every English string looked up while the pseudo-language was on.
pub fn looked_up() -> BTreeSet<String> {
super::I18N.looked_up()
LOOKED_UP.with_borrow(Clone::clone)
}
pub fn on() -> bool {
@@ -23,6 +25,11 @@ pub fn set(on: bool) {
ON.set(on);
}
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())))
}
/// `text` without its marked segments. Lines are marked one by one, since multi-line labels are
/// often drawn a line at a time.
pub fn unmarked(text: &str) -> String {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -23
View File
@@ -460,17 +460,9 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
// #414 (detect the document's language) goes here.
ui.add_space(8.0);
section(ui, "Proofing language");
let lang = crate::ribbon::caret_language(app);
let text = if crate::ribbon::caret_no_proof(app) {
tl!("The text at the insertion point is marked Do not check spelling or grammar.").to_string()
} else if !crate::ribbon::caret_language_proofed(app) {
crate::i18n::fmt(tl!("{language}: WordCraft doesn't check spelling or grammar in this language yet."), &[("language", &lang)])
} else {
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
crate::chrome::dictionary_problem(&lang, &unreadable, crate::ribbon::caret_missing_dictionary(app))
.unwrap_or_else(|| crate::i18n::fmt(tl!("Spelling and grammar are checked in {language}."), &[("language", &lang)]))
};
ui.label(RichText::new(text).small().weak());
// The document's default language (Review › Language › Set As Default changes it).
let tag = app.session.doc.styles.default_chr.lang.clone();
note(ui, &crate::ribbon::language_name(tag.as_deref()));
}
fn accessibility(app: &mut WordApp, ui: &mut Ui) {
@@ -886,9 +878,6 @@ mod tests {
/// label. AccessKit must still expose the full name and the row must keep its fixed size.
#[test]
fn options_nav_labels_stay_inside_their_rows() {
if !crate::i18n::catalogs_embedded() {
return;
}
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
@@ -919,6 +908,10 @@ mod tests {
.expect("navigation label is painted");
let bounds = text.visual_bounding_rect();
assert!(row.contains_rect(bounds.intersect(clip)), "{language} {percent}% {selected}: {label} paints outside {row:?}");
if language == "ru" && id == "accessibility" {
assert_eq!(label, "Доступность");
assert!(clip.contains_rect(bounds), "{language} {percent}% {selected}: {label} must fit its row");
}
if language == "en" && ["general", "display", "save"].contains(&id) {
assert!(clip.contains_rect(bounds), "short labels must remain fully visible");
}
@@ -932,9 +925,6 @@ mod tests {
/// backgrounds intact, and the selected pane's settings visible at every interface size.
#[test]
fn options_nav_rows_keep_hover_and_selection() {
if !crate::i18n::catalogs_embedded() {
return;
}
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
@@ -1224,9 +1214,6 @@ mod tests {
h.get_by_label("Animate the caret (it glides and fades)").click();
h.run_steps(4);
assert!(h.state().ui.caret_animation, "and on again");
if !crate::i18n::catalogs_embedded() {
return;
}
h.state_mut().run("ui.language", json!({"value": "es"})).unwrap();
h.run_steps(4);
assert!(h.query_by_label("Animación").is_some() && h.query_by_label("Animation").is_none());
@@ -1269,9 +1256,6 @@ mod tests {
/// takes the unit typed either way.
#[test]
fn length_units_follow_the_interface_language() {
if !crate::i18n::catalogs_embedded() {
return;
}
let mut a = app();
a.run("ui.language", json!({"value": "ru"})).unwrap();
a.run("file.options", json!({"region": "ru-RU"})).unwrap();
+3 -31
View File
@@ -908,27 +908,11 @@ pub(crate) fn caret_language(app: &WordApp) -> String {
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
}
/// The formatting at the caret, resolved through the styles and the document default.
fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar {
let s = &app.session;
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())
}
/// The language tag at the caret, resolved through the styles and the document default.
fn caret_lang_tag(app: &WordApp) -> Option<String> {
caret_char(app).lang
}
/// Is the text at the caret itself marked Do not check spelling or grammar? (`format.state`
/// looks at the whole word around a caret, so a partly marked word would read as checked.)
pub(crate) fn caret_no_proof(app: &WordApp) -> bool {
caret_char(app).no_proof
}
/// Does WordCraft check spelling and grammar in the caret's language at all (Bulgarian: no)?
pub(crate) fn caret_language_proofed(app: &WordApp) -> bool {
wordcraft_engine::proof::has_dictionary(caret_lang_tag(app).as_deref())
let s = &app.session;
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()).lang
}
/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167).
@@ -1663,12 +1647,6 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
("Relaxed", "relaxed"),
("Double", "double"),
] {
// "Open" here is roomy spacing, not the file verb: only a `spacing` row
// translates it, else it stays English.
if l == "Open" {
mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v}));
continue;
}
mi(ui, app, l, "design.paragraphSpacing", json!({"value": v}));
}
});
@@ -3456,9 +3434,6 @@ mod tests {
/// in Polish the table tabs were out of reach. They stay on the strip and a click opens them.
#[test]
fn table_tabs_stay_clickable_when_the_tab_strip_overflows() {
if !crate::i18n::catalogs_embedded() {
return;
}
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default());
app.run("insert.table", json!({"rows": 3, "cols": 3})).unwrap();
@@ -3486,9 +3461,6 @@ mod tests {
/// Editing / Share, the tabs stay inside the window, and the buttons keep their room.
#[test]
fn tab_strip_never_overlaps_the_right_buttons() {
if !crate::i18n::catalogs_embedded() {
return;
}
use egui_kittest::kittest::Queryable;
for lang in ["en", "pl", "de"] {
for width in [300.0, 420.0, 520.0, 640.0, 765.0, 1280.0] {
+8 -8
View File
@@ -1,6 +1,6 @@
# Hungarian interface
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/hu/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Hungarian entries instead of failing; previously minor: Hungarian interface catalog and regression checks) · **Target:** WordCraft 0.5.0
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the interface audit lists missing Hungarian entries instead of failing; previously minor (Hungarian interface catalog and regression checks)) · **Target:** WordCraft 0.5.0
Choose **File › Options › Interface language › Magyar**, or follow a Hungarian system display
language (`hu-HU`, `hu_HU.UTF-8`). The catalog code is `hu`; `ui.language` with `{"value":"hu"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=hu-HU` does the same for a headle
launch while the preference is `auto`. The choice is saved with the other interface settings.
Document text, command ids, CLI/MCP/control responses and file paths do not change.
craft-i18n's `locales/hu/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/hu.tsv`) has 2,419 entries: every key of the Russian catalog (built-in style
`crates/ui-egui/src/i18n/hu.tsv` has 2,419 entries: every key of the Russian catalog (built-in style
names, equation gallery labels, paper sizes, watermarks and greetings included) plus the citation
style names. It is written from the meaning of the English labels; no proprietary translation resources were consulted. The catalog
format has no plural forms, and Hungarian doesn't need them: a noun after a number stays singular
@@ -59,9 +59,12 @@ acute capitals.
## Verification and remaining scope
`cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Hungarian locale tags and preference
order, saving and restoring `hu`, unchanged document content and the glyphs `ÁáÉéÍíÓóÖöŐőÚúÜüŰű` in
the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`)
include Hungarian automatically.
order, saving and restoring `hu`, unchanged document content, every key of the Russian catalog,
built-in style names, placeholders, count wording, the font sample's capitals, English fallback,
and the glyphs `ÁáÉéÍíÓóÖöŐőÚúÜüŰű` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared catalog checks
(validation, every tab and command translated) include Hungarian automatically, and the on-demand
interface audit (`untranslated_interface_text`) lists Hungarian strings it looked up that the
catalog lacks.
Spelling, grammar and hyphenation stay English-only: this is an interface translation, not
Hungarian proofing. A native-speaker review of the catalog is welcome.
@@ -70,8 +73,5 @@ Hungarian proofing. A native-speaker review of the catalog is welcome.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/hu/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | The interface audit lists Hungarian strings the catalog lacks; a missing entry doesn't fail the audit |
| 2026-10-11 | minor | Hungarian catalog (2,419 entries: built-in style names, equation gallery, citation styles), conventions, terminology, scope and regression checks |
+20 -28
View File
@@ -1,11 +1,9 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and
the clean-room rule live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n)
(`locales/<locale>/wordcraft.tsv` and the shared `common.tsv`).
Interface languages, script support and proofing per language. How translation works (catalog
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
**Dimension: ~29% (estimated; 28% before Italian proofing, 27% before Portuguese proofing, 25% before Polish and per-run proofing languages), 70–115 h to full
for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus
@@ -23,17 +21,8 @@ native-speaker review (human).
- **27 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`,
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl` and Danish `da` (#2722). Catalogs have 2,061–2,674 entries
each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far. A missing translation falls back to English and never fails a test or
build. `cargo xtask i18n-report`, part of `cargo xtask ci`, prints per language the interface
strings its catalog lacks: everything the interface audit saw looked up, the ribbon tabs, command
labels and ribbon locations, the File › Home greetings, disabled-control reasons and the equation
gallery. The catalogs are embedded at build time from craft-i18n (`CRAFT_I18N_DIR`, the commit
in `craft-i18n.rev`; a build without it is English only), and craft-i18n's checker applies the
format checks to every language alike (rows parse, no duplicate keys, placeholders match the
English, plural forms, ellipses); the report also lists rows the crate skips and strings kept in English. Font tests check that the bundled faces have a glyph for each catalog's letters.
Tests of the lookup code (English fallback, contexts, placeholder templates, built-in style names,
the per-thread language, command search) use small inline catalogs or the pseudo-language, so
rewording a translation never fails a test.
each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
label and ribbon location is translated in every language.
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero,
@@ -87,7 +76,8 @@ entries, the same keys as the Russian catalog. Command search folds the Turkish
finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish
proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md).
Bulgarian (`bg-BG` system locales, `{"value":"bg"}`) has 2,419 entries: every key of the reference
catalog, the equation gallery's labels and everything the interface audit finds. Counts use count-neutral wording (`Думи: 21`). Bulgarian text is
catalog, the equation gallery's labels and everything the interface audit finds (the audit checks
Bulgarian as it does Russian). Counts use count-neutral wording (`Думи: 21`). Bulgarian text is
hyphenated by the Bulgarian syllable rules (#826); Bulgarian spelling and grammar are not included.
Slovenian (`sl-SI` system locales, `{"value":"sl"}`) has 2,614 entries: every key of every other
catalog. Counts use count-neutral wording (`Besede: 21`); terminology: [`localization-sl.md`](localization-sl.md).
@@ -98,22 +88,25 @@ the Russian catalog, built-in style names shown translated, plus the citation st
Czech follows `cs-CZ` system locales and `{"value":"cs"}`, with 2,447 entries: every key of the
Russian and Spanish catalogs, including built-in style names and numbered families, the equation
gallery, watermarks, paper sizes and preview tiles. Counts use count-neutral wording
(`Počet slov: 21`). Czech proofing is not included.
gallery, watermarks, paper sizes and preview tiles. The Czech coverage test
enforces both reference catalogs and retains the Chinese catalog coverage; counts keep the existing
count-neutral wording (`Počet slov: 21`). Czech proofing is not included.
When no saved proofing-language default exists, Polish, Portuguese and Czech interfaces write
new documents and opened TXT/Markdown text in their proofing languages (`pl-PL`, `pt-BR`, `cs-CZ`).
**Interface audit (runs in CI).** `cargo xtask i18n-report` runs `untranslated_interface_text`,
which lays the app out in a pseudo-language (every tab, File page, dialog, pane, the right-click
menu, and a hover and a click on every ribbon control) and fails on visible text that skipped the
catalog. It then prints the missing-translation report described above, which never fails. Proper names (citation styles such as APA or
**Interface audit (runs in CI).** `untranslated_interface_text` lays the app out in a pseudo-language
(every tab, File page, dialog, pane, the right-click menu, and a hover and a click on every ribbon
control) and fails on visible text that skipped the catalog. It then prints, per language, the
strings it looked up that the catalog lacks (`--nocapture`); a missing entry falls back to English
and doesn't fail the audit. Proper names (citation styles such as APA or
GOST, LaTeX, Unicode) and `bad parameters:` errors, which name a command's JSON parameters for
scripts and MCP, stay English. Size labels (Envelopes, Labels, Layout › Size) are translated
patterns (`{name} {height} × {width} in`) with the region's decimal separator; paper names
other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128).
The report's disabled-control reasons (the reason a ribbon hint gives for a greyed-out control,
#1086, #2678) are read from every command's `enabled` check in the engine's command sources.
`every_disabled_reason_is_translated` reads every command's `enabled` check from the engine's
command sources (the reason a ribbon hint gives for a greyed-out control, #1086) and fails if any
catalog lacks one (#2678).
Set Proofing Language › Set As Default keeps the chosen language in user preferences across restarts (#2882), taking precedence over the interface language for blank documents and plain-text/Markdown files. Existing DOCX and built-in English templates keep their own language. Ordinary selection-language changes do not change this preference; older settings keep following the interface language. A rejected command, including a final protected-region check, retains the previous saved default. This does not install dictionaries or add proofing support.
@@ -150,6 +143,8 @@ Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catal
the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info
properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs,
groups and command names use the names Italian Word users know (feature names, as `AGENTS.md`
allows); all other text is written from the meaning of the English. The interface audit also
checks the Italian catalog (2026-10-14). Italian proofing is in the proofing table above.
allows); all other text is written from the meaning of the English. Italian proofing: see the table above.
A native speaker reviewed the catalog on 2026-10-14 (wording fixes, consistent terms).
@@ -219,9 +214,6 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | Interface audit: strings a catalog lacks are printed per language, for every language, instead of failing for Russian, Hungarian, Turkish and Bulgarian |
| 2026-10-21 | trivial | Japanese row: ruby (Phonetic Guide) is supported over its base text and HTML export/import keeps it (#1681); the row had listed ruby as missing |
| 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged |
+6 -15
View File
@@ -1,6 +1,6 @@
# Slovenian interface
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/sl/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** trivial (catalog completed to every key of every other catalog, 2,614 entries) · **Target:** WordCraft 0.7.0
Choose **File › Options › Interface language › Slovenščina**, or follow a Slovenian system display
language (`sl-SI`, `sl_SI.UTF-8`). The catalog code is `sl`; `ui.language` with `{"value":"sl"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=sl-SI` does the same for a headle
launch while the preference is `auto`. Document text, command ids, CLI/MCP/control responses and
file paths do not change.
craft-i18n's `locales/sl/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/sl.tsv`) has 2,614 entries: every key of every other catalog (built-in
`crates/ui-egui/src/i18n/sl.tsv` has 2,614 entries: every key of every other catalog (built-in
style names, equation gallery labels, paper sizes, watermarks, greetings, citation style names and
native print settings included). It is written from the meaning of the English labels; no proprietary translation
resources were consulted.
@@ -57,16 +57,7 @@ resources were consulted.
## Verification and remaining scope
Tests in `crates/ui-egui/src/i18n/tests.rs` check locale selection, the saved preference and the
bundled fonts' coverage of č, š, ž. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`)
include Slovenian automatically. Slovenian spelling, grammar and hyphenation are not included. Native speaker review
is welcome.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/sl/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-15 | trivial | Catalog completed to every key of every other catalog (2,614 entries) |
Tests in `crates/ui-egui/src/i18n/tests.rs` check locale selection, the saved preference, that the
catalog covers every key of every other catalog and has no keys outside them, key terms, the search
hint example and the bundled fonts' coverage of č, š, ž. Slovenian spelling, grammar and hyphenation
are not included. Native speaker review is welcome.
+9 -9
View File
@@ -1,6 +1,6 @@
# Swedish interface
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/sv/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: the interface audit lists missing Swedish entries instead of failing; previously minor: Swedish interface catalog and regression checks) · **Target:** WordCraft 0.6.1
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-23 · **Change:** trivial (the interface audit lists missing Swedish entries instead of failing; previously minor (Swedish interface catalog and regression checks)) · **Target:** WordCraft 0.6.1
Choose **File › Options › Interface language › Svenska**, or follow a Swedish system display
language (`sv-SE`, `sv_SE.UTF-8`, and `sv-FI` for Swedish in Finland). The catalog code is `sv`;
@@ -9,7 +9,7 @@ same for a headless screenshot or a launch while the preference is `auto`. The c
the other interface settings. Document text, command ids, CLI/MCP/control responses and file paths
do not change.
craft-i18n's `locales/sv/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/sv.tsv`) has 2,504 entries: every key of the Spanish reference catalog and
`crates/ui-egui/src/i18n/sv.tsv` has 2,504 entries: every key of the Spanish reference catalog and
of the Bulgarian one (built-in style names, equation gallery labels, paper sizes, watermarks and
citation styles included), plus 44 newer strings no other catalog has yet: 23 that the interface
audit finds and 21 that the code sends through translation outside the audit's reach (native print
@@ -79,10 +79,13 @@ quotes); they are translated as written and still act on English text.
## Verification and remaining scope
`cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Swedish locale tags (`sv`, `sv-SE`,
`sv_SE.UTF-8`, `SV-se`, `sv-FI`) and preference order, saving and restoring `sv`, unchanged document
content and the glyphs `ÅÄÖåäöÉé` plus every other accented letter the catalog uses in the bundled
Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`)
include Swedish automatically.
`sv_SE.UTF-8`, `SV-se`, `sv-FI`) and preference order, saving and restoring `sv`, unchanged
document content, every key of the reference catalog, no catalog errors, the search hint's example
(`infoga tabell`), page and count wording, English fallback, and the glyphs `ÅÄÖåäöÉé` plus every
other accented letter the catalog uses in the bundled Inter (Regular, Medium, SemiBold) and
JetBrains Mono. The shared catalog checks (validation, every tab and command translated) include
Swedish automatically, and the interface audit (`untranslated_interface_text`) lists Swedish
strings it looked up that the catalog lacks.
Spelling, grammar and hyphenation stay English-only: this is an interface translation, not
Swedish proofing.
@@ -91,8 +94,5 @@ Swedish proofing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/sv/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-23 | trivial | The interface audit lists Swedish strings the catalog lacks; a missing entry doesn't fail the audit |
| 2026-10-13 | minor | Swedish catalog (2,504 entries), conventions, terminology, known compromises and regression checks |
+9 -10
View File
@@ -1,6 +1,6 @@
# Turkish interface
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (the catalog moved to storytold/craft-i18n, `locales/tr/wordcraft.tsv`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously minor: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0
Choose **File › Options › Interface language › Türkçe**, or follow a Turkish system display language
(`tr-TR`, `tr-CY`, `tr_TR.UTF-8`). The catalog code is `tr`; `ui.language` with `{"value":"tr"}`
@@ -8,7 +8,7 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=tr-TR` does the same for a headle
launch while the preference is `auto`. The choice is saved with the other interface settings.
Document text, command ids, CLI/MCP/control responses and file paths do not change.
craft-i18n's `locales/tr/wordcraft.tsv` (storytold/craft-i18n; it was `crates/ui-egui/src/i18n/tr.tsv`) has all 2,325 entries of the Russian catalog (the same
`crates/ui-egui/src/i18n/tr.tsv` has all 2,325 entries of the Russian catalog (the same
keys). It covers all ribbon commands, dialogs, built-in style names, equation and math gallery
structures, watermark presets, paper sizes, and rendered preview tiles. It is written from the
meaning of the English labels; no proprietary translation resources were consulted. The catalog
@@ -46,11 +46,13 @@ still work. Other interface languages and document search are unchanged.
## Verification and remaining scope
`cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Turkish locale tags and preference order,
saving and restoring `tr`, unknown preferences, unchanged document content and the glyphs
`ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. A command-search
test checks dotted and dotless I under Turkish casing with fixture labels, and that English labels
and ASCII command ids still match. The shared checks (catalog validation and the missing-translation report, `cargo xtask i18n-report`)
include Turkish automatically.
saving and restoring `tr`, unknown preferences, unchanged document content, full key parity with
the Russian catalog (and superset of Spanish and Chinese), placeholders, count wording, the font
sample's capitals, English fallback, and the glyphs `ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular,
Medium, SemiBold) and JetBrains Mono. A dialog test renders real command-search results in Turkish
and English, including dotted and dotless I and ASCII command ids. The shared catalog checks
(validation, every tab and command translated, and interface audit strings) include Turkish
automatically.
Turkish proofing (`crates/proof/src/tr`) provides spelling against installed Hunspell `tr_TR`
dictionaries, spelling suggestions tuned for Turkish diacritics and detached particles, rule-based grammar
@@ -60,9 +62,6 @@ checks (reduplications, conjunction commas, capitalization), and syllable hyphen
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | The catalog moved to storytold/craft-i18n (`locales/tr/wordcraft.tsv`) |
| 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
| 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed |
| 2026-10-13 | minor | Turkish proofing added: spelling, suggestions, grammar rules and TDK syllable hyphenation |
| 2026-10-11 | minor | Turkish catalog completed (2,325 entries, #774), covering all math structures, built-in styles, presets and preview tiles |
| 2026-10-11 | minor | Turkish catalog (1,307 entries), terminology, scope and regression checks |
+3 -5
View File
@@ -1,9 +1,9 @@
# Installing spelling dictionaries
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-22 · **Change:** trivial (every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-18 · **Change:** trivial (ISO 8859-1 dictionaries are read, #2824; previously major: first version)
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
Turkish and Indonesian spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian and
Turkish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
WordCraft reads at runtime. These dictionaries are not bundled: they are large, and several are
GPL or LGPL licensed. Without one, the language isn't checked. The status bar then shows the
language with **(no dictionary)**, for example *Português (Brasil) (no dictionary)*.
@@ -29,7 +29,6 @@ WordCraft looks for the first of these file names (each name means a `<name>.aff
| Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` |
| Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` |
| Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` |
| Indonesian | `id_ID`, `id-ID`, `id`, `in_ID` | LibreOffice `dict-id` |
Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)*
is checked with it too.
@@ -128,7 +127,6 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Italian, Croatian, Turkish, Russian, Czech and Indonesian without a dictionary now show **(no dictionary)** (or **(dictionary unreadable)**) too, as this page already said; before, only Polish and Portuguese did. Indonesian is listed with its file names (#3150) |
| 2026-10-18 | trivial | ISO 8859-1 (`SET ISO8859-1`) dictionaries are read, so the original VERO `pt_BR` works without converting it to UTF-8 (#2824) |
| 2026-10-18 | minor | An installed dictionary that can't be read shows **(dictionary unreadable)** in the status bar, with the file and reason in its tooltip and in File › Options › Proofing (#2830) |
| 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works |
+2 -3
View File
@@ -187,9 +187,8 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
`İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ`
gives the single letter `i` in every language, never `i` + combining dot. Other languages keep
the Unicode default (`istanbul` → `Istanbul` in English)
(`autocorrect_capitalisation_follows_turkic_case_mapping`); Format > Change Case follows the
same Turkic mapping from each run's language, while other languages keep the Unicode default
(`change_case_follows_turkic_case_mapping`, #3479).
(`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet
Turkish-aware.
- Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412,
`tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and
table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and
+1 -4
View File
@@ -42,7 +42,6 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
return 0
}
# Copy licence and readme files that exist into a package directory.
@@ -52,7 +51,6 @@ copy_docs() {
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
return 0
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
@@ -67,6 +65,5 @@ copy_font_licences() {
# Portable SHA-256 of a file (prints just the hash).
sha256() {
local file="$1"
if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
+1 -5
View File
@@ -21,11 +21,7 @@ MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
render() {
local src="$1" size="$2" dst="$3"
resvg -w "$size" -h "$size" "$src" "$dst" </dev/null
return 0
}
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render "$SVG" 1024 "$DIR/wordcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256.
@@ -43,9 +43,6 @@ modules:
env:
CARGO_HOME: /run/build/wordcraft/cargo
CARGO_NET_OFFLINE: 'true'
# The interface translations (the craft-i18n source below), embedded by build.rs.
CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n
CRAFT_I18N_REQUIRED: '1'
build-commands:
- cargo --offline build --release --locked -p wordcraft -p wordcraft-cli
- install -Dm755 target/release/wordcraft /app/bin/wordcraft
@@ -67,9 +64,4 @@ modules:
- corpus
- build-flatpak
- .flatpak-builder
# The interface translations at the commit in craft-i18n.rev (a test keeps the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b
dest: craft-i18n
- cargo-sources.json
+8 -45
View File
@@ -75,7 +75,7 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { local item="$1"; case " $FORMATS " in *" $item "*) return 0 ;; *) return 1 ;; esac; }
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -99,36 +99,6 @@ fi
# ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then
APPIMAGETOOL_VERSION=1.9.1
# appimagetool would otherwise embed type2-runtime's `continuous` build, unchecked.
RUNTIME_VERSION=20251108
case "$ARCH" in
x86_64)
APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
;;
aarch64)
APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444
;;
*) APPIMAGETOOL_SHA256='' RUNTIME_SHA256='' ;;
esac
if [[ -z "$RUNTIME_SHA256" ]]; then
echo "no pinned AppImage runtime for $ARCH" >&2
exit 2
fi
# Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run.
fetch_pinned() {
local url="$1" expected_sha="$2" target="$3"
if [[ ! -x "$target" ]]; then
curl --proto '=https' -fsSL -o "$target.download" "$url"
printf '%s %s\n' "$expected_sha" "$target.download" | sha256sum -c -
chmod 755 "$target.download"
mv "$target.download" "$target"
fi
printf '%s %s\n' "$expected_sha" "$target" | sha256sum -c -
return 0
}
APPDIR="$WORK/WordCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
@@ -139,23 +109,17 @@ if has appimage; then
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [[ -z "$TOOL" ]]; then
if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2
exit 2
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [[ ! -x "$TOOL" ]]; then
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
fetch_pinned "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" \
"$APPIMAGETOOL_SHA256" "$TOOL"
fi
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$RUNTIME_VERSION-$ARCH"
fetch_pinned "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" \
"$RUNTIME_SHA256" "$RUNTIME"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative, e.g. target/agent-<name>).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
APPDIR="$(cd "$APPDIR" && pwd)"
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")"
# A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
rm -f "$OUT.zsync"
# Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and
@@ -164,11 +128,10 @@ if has appimage; then
# pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY.
REPO="${GITHUB_REPOSITORY:-storytold/wordcraft}"
UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|wordcraft-*-linux-$ARCH.AppImage.zsync"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the pinned static
# runtime, so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \
--runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT")
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
+2 -4
View File
@@ -143,20 +143,18 @@ rm -f "$DMG"
# Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with
# "Resource busy". Usage: hdiutil_retry <verb> <args...>; five tries with growing pauses.
hdiutil_retry() {
local verb="$1"
local attempt
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
if [[ "$attempt" = 5 ]]; then
echo "error: hdiutil $verb failed 5 times" >&2
echo "error: hdiutil $1 failed 5 times" >&2
return 1
fi
warn "hdiutil $verb failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
warn "hdiutil $1 failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
return 1
}
# `hdiutil create -srcfolder` copies the staged files as they are. The previous
# `makehybrid -hfs` + `convert` gave every file and folder in the image a com.apple.FinderInfo
+3 -10
View File
@@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) {
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'WordCraft', '/du', 'https://github.com/storytold/wordcraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$([IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Path $tmp | Out-Null
$tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$PID"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
if ($haveCert) {
@@ -73,15 +73,8 @@ try {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
$trustedSigningVersion = '1.0.95'
$trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
$pkg = Join-Path $tmp 'trusted-signing.zip'
$uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg"
Invoke-WebRequest -Uri $uri -OutFile $pkg
$actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $trustedSigningSha256) {
throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch"
}
Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
+11 -29
View File
@@ -21,11 +21,8 @@ commands:
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
test workspace tests (cargo nextest when installed, else cargo test), the interface
audit with its missing-translation report, then doctests
i18n-report the interface audit: fails on text that skips the catalog, and prints the
strings each language has no translation for (never a failure)
ci fmt --check, clippy -D warnings, test, i18n-report, doctest, assets, layers, wasm
test workspace tests (cargo nextest when installed, else cargo test), then doctests
ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm
(stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
@@ -45,8 +42,7 @@ fn main() -> ExitCode {
Some("assets") => assets::run(&root()),
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("test") => cmd_test().and_then(|()| cmd_i18n_report()).and_then(|()| cmd_doctest()),
Some("i18n-report") => cmd_i18n_report(),
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -169,31 +165,18 @@ fn nextest_available() -> bool {
cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
}
/// The interface audit, which `cmd_i18n_report` runs on its own so its report is printed.
const I18N_AUDIT: &str = "i18n::audit::untranslated_interface_text";
const I18N_AUDIT_FILTER: &str = "package(wordcraft-ui-egui) & test(=i18n::audit::untranslated_interface_text)";
fn cmd_test() -> Result<(), String> {
test_run(&["-E", &format!("not ({I18N_AUDIT_FILTER})")], &["--skip", I18N_AUDIT])
}
/// Missing translations never fail the build: the report lists them in the log.
fn cmd_i18n_report() -> Result<(), String> {
test_run(&["-E", I18N_AUDIT_FILTER, "--no-capture"], &["--exact", I18N_AUDIT, "--nocapture"])
}
/// A test run over `TEST_TARGETS`, with nextest arguments or, without nextest, libtest arguments.
/// Every run builds the same targets, so a second run reuses the first one's build.
fn test_run(nextest: &[&str], libtest: &[&str]) -> Result<(), String> {
let mut c = cargo();
if nextest_available() {
c.args(["nextest", "run"]).args(TEST_TARGETS).args(nextest);
run(c, &format!("cargo nextest run {} {}", TEST_TARGETS.join(" "), nextest.join(" ")))
let tool = if nextest_available() {
c.args(["nextest", "run"]);
"nextest run"
} else {
eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
c.arg("test").args(TEST_TARGETS).arg("--").args(libtest);
run(c, &format!("cargo test {} -- {}", TEST_TARGETS.join(" "), libtest.join(" ")))
}
c.arg("test");
"test"
};
c.args(TEST_TARGETS);
run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
}
/// nextest does not run doctests.
@@ -223,7 +206,6 @@ fn cmd_ci() -> Result<(), String> {
}),
),
("test", Box::new(cmd_test)),
("i18n-report", Box::new(cmd_i18n_report)),
("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),