From a04bb9da2a00e08fd52c394aec26703800f4415a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 19:37:05 +0000 Subject: [PATCH 1/2] craft-text: locale codes with a script or year subtag; Slovak data through feature unification --- ATTRIBUTION.md | 2 +- NOTICE | 6 ++++-- crates/text/Cargo.toml | 4 +++- crates/text/README.md | 7 +++++-- crates/text/src/hyphen/mod.rs | 30 ++++++++++++++++++++++-------- crates/text/src/hyphen/tests.rs | 6 +++++- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a6493818..7b32c6f1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,7 +12,7 @@ author, source and license. Library code and original assets are under the repos | `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | | `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain | | `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 | -| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, feature `slovak-gpl` only) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT | +| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, embedded whenever `hypher/slovak` is on: feature `slovak-gpl`, or another dependency through feature unification) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT | | Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 | Original assets are covered by the repository licence. diff --git a/NOTICE b/NOTICE index 699ee37c..9d0e267a 100644 --- a/NOTICE +++ b/NOTICE @@ -29,9 +29,11 @@ Hyphenation patterns for other languages, embedded through the hypher crate only Unlicense, a notice permitting any use, or a choice of licenses that includes one of these. An application that enables a language ships that file's copyright and license notice. The exception is Slovak (hyph-sk.tex, Copyright (C) 1992 Jana Chlebíková), under the GNU General - Public License, version 2 or later (SPDX: GPL-2.0-or-later). It is compiled in only with the + Public License, version 2 or later (SPDX: GPL-2.0-or-later). craft-text asks for it through the feature "slovak-gpl", which "all-languages" does not include; an application that enables it - ships GPL-licensed data. The Spanish patterns (feature "spanish") are hyph-es.tex 5.0. + ships GPL-licensed data. Cargo unifies features, so another dependency that enables hypher's + "slovak" feature (hypher's default features do) embeds the same data; a build that must exclude + it checks its dependency graph. The Spanish patterns (feature "spanish") are hyph-es.tex 5.0. Copyright (C) 1993, 1997 Javier Bezos, 2001-2019 Javier Bezos, CervanTeX. MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index b9d54a48..ae32a115 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -138,7 +138,9 @@ russian = ["hyphen", "hypher/russian"] sanskrit = ["hyphen", "hypher/sanskrit"] serbian = ["hyphen", "hypher/serbian"] # Slovak patterns are under the GPL, version 2 or later (hyph-sk.tex), unlike every other language -# here, so `all-languages` leaves them out. A build that enables this ships GPL-licensed data. +# here, so `all-languages` leaves them out. A build that enables this ships GPL-licensed data. Any +# other dependency that enables `hypher/slovak` (hypher's defaults do) embeds them too, through +# feature unification. slovak-gpl = ["hyphen", "hypher/slovak"] slovenian = ["hyphen", "hypher/slovenian"] spanish = ["hyphen", "hypher/spanish"] diff --git a/crates/text/README.md b/crates/text/README.md index 2694c9b5..585945b7 100644 --- a/crates/text/README.md +++ b/crates/text/README.md @@ -42,8 +42,11 @@ Each has a cargo feature named as in hypher (`spanish`, `german`, `norwegian`, enables 46 of them. Patterns for languages whose feature is off are not compiled in. Slovak's patterns are under the GPL, version 2 or later; the patterns of every other language are -under a permissive license. Their feature is `slovak-gpl`, and `all-languages` leaves it out: a build that -enables it ships GPL-licensed data. +under a permissive license. craft-text asks for Slovak through the feature `slovak-gpl`, which +`all-languages` leaves out; a build that enables it ships GPL-licensed data. Cargo unifies features, +so another dependency that enables hypher's `slovak` feature (hypher's default features do) embeds +the same data. A build that must exclude it checks its dependency graph +(`cargo tree -e features -i hypher`). ```rust use craft_text::hyphen::{Lang, Limits, hyphen_points_in}; diff --git a/crates/text/src/hyphen/mod.rs b/crates/text/src/hyphen/mod.rs index f098bdbb..deac28a2 100644 --- a/crates/text/src/hyphen/mod.rs +++ b/crates/text/src/hyphen/mod.rs @@ -161,16 +161,30 @@ impl Lang { } /// The language of a locale code split into lowercase `words`: a language (2 or 3 letters), then -/// optionally a region (2 letters or 3 digits) and a reform year (4 digits). +/// optionally a script (4 letters), a region (2 letters or 3 digits) and a reform year (4 digits), +/// in that order. fn locale_language<'a>(words: &[&'a str]) -> Option<&'a str> { - let letters = |w: &str, n: std::ops::RangeInclusive| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()); - let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit()); - match *words { - [lang] if letters(lang, 2..=3) => Some(lang), - [lang, region] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => Some(lang), - [lang, region, year] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) && digits(year, 4) => Some(lang), - _ => None, + fn letters(w: &str, n: std::ops::RangeInclusive) -> bool { + n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()) } + fn digits(w: &str, n: usize) -> bool { + w.len() == n && w.bytes().all(|b| b.is_ascii_digit()) + } + let (&lang, mut rest) = words.split_first()?; + if !letters(lang, 2..=3) { + return None; + } + let script = |w: &str| letters(w, 4..=4); + let region = |w: &str| letters(w, 2..=2) || digits(w, 3); + let year = |w: &str| digits(w, 4); + for subtag in [script as fn(&str) -> bool, region, year] { + if let Some((&w, tail)) = rest.split_first() + && subtag(w) + { + rest = tail; + } + } + rest.is_empty().then_some(lang) } /// Where a word's break points came from. diff --git a/crates/text/src/hyphen/tests.rs b/crates/text/src/hyphen/tests.rs index be36e4de..8c1f1af8 100644 --- a/crates/text/src/hyphen/tests.rs +++ b/crates/text/src/hyphen/tests.rs @@ -70,7 +70,7 @@ fn limits_are_respected() { #[test] fn language_names_and_codes() { - for name in ["English", "English: USA", "english", "$ID/English: UK", "en", "en_US", "en-GB", "EN"] { + for name in ["English", "English: USA", "english", "$ID/English: UK", "en", "en_US", "en-GB", "EN", "en-Latn-US"] { assert_eq!(Lang::for_language(name), Some(Lang::English), "{name}"); } // Each language resolves by name and by code exactly when its feature is on. @@ -88,6 +88,10 @@ fn language_names_and_codes() { format!("{code}_{upper}"), format!("{code}-419"), format!("{code}_{upper}_2006"), + format!("{code}-Latn"), + format!("{code}-Latn-{upper}"), + format!("{code}-1996"), + format!("{code}-Latn-1996"), ] { assert_eq!(Lang::for_language(&spelling), want, "{spelling}"); } From 4617a971ae21efd58881819249e2d2e93f354199 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 19:40:45 +0000 Subject: [PATCH 2/2] craft-ui, craft-fonts-build: harness direction, caret clamp, served-face conversion, byte-equal font copies --- .github/workflows/ui.yml | 4 ++-- crates/fonts-build/README.md | 2 +- crates/fonts-build/src/embed.rs | 15 ++++++++------- crates/fonts-build/src/tests.rs | 12 ++++++++++++ crates/ui/src/fonts/mod.rs | 7 +++++++ crates/ui/src/ime/composer.rs | 6 ++++-- crates/ui/src/ime/tests.rs | 10 ++++++++++ crates/ui/src/testing/mod.rs | 7 ++++++- crates/ui/tests/snapshots/rename-ar.png | Bin 12023 -> 12007 bytes 9 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index e9299f37..848bd183 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -3,9 +3,9 @@ name: Shared UI on: push: branches: [main] - paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "crates/fonts-build/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] pull_request: - paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] + paths: ["crates/ui/**", "crates/i18n/**", "crates/fonts-build/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"] workflow_dispatch: permissions: diff --git a/crates/fonts-build/README.md b/crates/fonts-build/README.md index 3c54319b..941426b3 100644 --- a/crates/fonts-build/README.md +++ b/crates/fonts-build/README.md @@ -138,7 +138,7 @@ it has: about 3 KB for a CJK interface face cut to an app's catalogs, under 1 KB An interface face cut by `subset_to` is served from its cut in `OUT_DIR`, with the start of the cut's SHA-256 in its name (`web/fonts/ui/NotoSansTCUI-Regular.1234abcd5678ef90.ttf`) and the cut's own `integrity`, so changed catalogs give a new URL rather than a cached copy that fails its check. -A relative `dir` is under the package directory. Files already there with the right size are not +A relative `dir` is under the package directory. Files already there with the same bytes are not copied again, a deleted one is copied again on the next build, and files of faces no longer selected, and earlier cuts, stay. diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs index 2f9372fd..c6937ba5 100644 --- a/crates/fonts-build/src/embed.rs +++ b/crates/fonts-build/src/embed.rs @@ -155,7 +155,7 @@ impl Embed { /// `web/fonts` as `fonts/`. An interface face cut by `Embed::subset_to` is served from its cut, /// with the start of the cut's SHA-256 in the file name (`ui/NotoSansTCUI-Regular..ttf`) /// and the cut's own integrity value. A relative `dir` is under the package directory, where - /// Cargo runs build scripts. Files already there with the right size are left as they are; + /// Cargo runs build scripts. Files already there with the same bytes are left as they are; /// files of faces no longer selected, and earlier cuts, are not removed. pub fn serve(mut self, dir: impl Into, url_prefix: &str) -> Self { self.serve = Some(Serve { dir: dir.into(), url_prefix: url_prefix.to_owned() }); @@ -555,21 +555,22 @@ fn served_path(file: &str) -> &str { file.strip_prefix("fonts/").unwrap_or(file) } -/// Copy `from` unless `to` already has its size. The copy keeps `from`'s modification time and is -/// watched, so the build copies it again once it is deleted, without running every build. +/// Copy `from` unless `to` already holds the same bytes. The copy keeps `from`'s modification time +/// and is watched, so the build copies it again once it is deleted, without running every build. fn copy_file(from: &Path, to: &Path, cargo: &mut Vec) -> Result<(), Error> { cargo.push(format!("cargo::rerun-if-changed={}", to.display())); let write_error = |source| Error::Write { path: to.to_path_buf(), source }; - let size = |path: &Path| std::fs::metadata(path).ok().map(|m| m.len()); - if size(to).is_some() && size(to) == size(from) { + let read_error = |source| Error::Read { path: from.to_path_buf(), source }; + let bytes = std::fs::read(from).map_err(read_error)?; + if std::fs::read(to).is_ok_and(|old| old == bytes) { return Ok(()); } if let Some(parent) = to.parent() { std::fs::create_dir_all(parent).map_err(write_error)?; } - std::fs::copy(from, to).map_err(write_error)?; + std::fs::write(to, &bytes).map_err(write_error)?; // Cargo reruns a build script whose watched files are newer than its last run. - let modified = std::fs::metadata(from).and_then(|m| m.modified()).map_err(|source| Error::Read { path: from.to_path_buf(), source })?; + let modified = std::fs::metadata(from).and_then(|m| m.modified()).map_err(read_error)?; std::fs::File::options().write(true).open(to).and_then(|f| f.set_modified(modified)).map_err(write_error) } diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs index 6fb2a7c0..f614dc6e 100644 --- a/crates/fonts-build/src/tests.rs +++ b/crates/fonts-build/src/tests.rs @@ -232,6 +232,18 @@ fn serve_copies_the_selected_faces_and_writes_urls() { assert!(!empty.exists()); } +#[test] +fn serve_replaces_a_stale_copy_of_the_same_size() { + let scratch = Scratch::new("serve-stale"); + let served = scratch.0.join("served"); + let source = std::fs::read(fixture().join("fonts/sans-jp/SansJP-Regular.ttf")).unwrap(); + std::fs::create_dir_all(served.join("sans-jp")).unwrap(); + std::fs::write(served.join("sans-jp/SansJP-Regular.ttf"), vec![0; source.len()]).unwrap(); + let r = run(Embed::new().scripts(&["Jpan"]).filter(|f| f.style == "Regular").serve(&served, "fonts/"), Some(&fixture()), Some("1")); + r.result.unwrap(); + assert_eq!(std::fs::read(served.join("sans-jp/SansJP-Regular.ttf")).unwrap(), source); +} + #[test] fn integrity_is_base64_of_the_hex_hash() { use crate::embed::integrity; diff --git a/crates/ui/src/fonts/mod.rs b/crates/ui/src/fonts/mod.rs index 26af81e3..436f773e 100644 --- a/crates/ui/src/fonts/mod.rs +++ b/crates/ui/src/fonts/mod.rs @@ -77,6 +77,13 @@ impl From for CraftFace { } } +impl From for ServedFace { + fn from(face: craft_fonts_build::ServedFace) -> Self { + let craft_fonts_build::ServedFace { family, style, scripts, index, url, size, integrity, coverage } = face; + Self { family, style, scripts, index, url, size, integrity, coverage } + } +} + impl CraftFace { /// The egui font name, e.g. `"Noto Sans Arabic Regular"`. pub fn name(&self) -> String { diff --git a/crates/ui/src/ime/composer.rs b/crates/ui/src/ime/composer.rs index 3a435008..c20dced1 100644 --- a/crates/ui/src/ime/composer.rs +++ b/crates/ui/src/ime/composer.rs @@ -581,11 +581,13 @@ fn is_shortcut_chord(m: Modifiers) -> bool { /// A caret IMEs can place a window at: at least a point wide and tall (some ignore an empty /// rectangle), and inside the window, so a caret scrolled off the canvas doesn't put the /// candidates off screen. -fn caret_area(caret: Rect, screen: Rect) -> Rect { +pub(super) fn caret_area(caret: Rect, screen: Rect) -> Rect { if !caret.is_finite() || !screen.is_positive() { return caret; } let size = caret.size().max(egui::vec2(1.0, 1.0)).min(screen.size()); - let min = caret.min.clamp(screen.min, screen.max - size); + // `screen.max - size` can round below `screen.min` (a screen from 0.1 to 0.5), and `clamp` + // panics on bounds out of order. + let min = caret.min.clamp(screen.min, (screen.max - size).max(screen.min)); Rect::from_min_size(min, size) } diff --git a/crates/ui/src/ime/tests.rs b/crates/ui/src/ime/tests.rs index 25479955..17ecde3f 100644 --- a/crates/ui/src/ime/tests.rs +++ b/crates/ui/src/ime/tests.rs @@ -538,3 +538,13 @@ fn a_caret_off_the_canvas_keeps_the_candidates_on_screen() { let r = out.platform_output.ime.map(|i| i.cursor_rect); assert_eq!(r, Some(Rect::from_min_size(pos2(0.0, 580.0), vec2(2.0, 20.0)))); } + +#[test] +fn a_caret_past_a_tiny_or_unbounded_screen_stays_inside_it() { + let caret = Rect::from_min_size(pos2(10.0, 10.0), vec2(2.0, 20.0)); + let tiny = Rect::from_min_max(pos2(0.1, 0.1), pos2(0.5, 0.5)); + assert_eq!(composer::caret_area(caret, tiny), Rect::from_min_size(tiny.min, tiny.size())); + assert_eq!(composer::caret_area(caret, Rect::EVERYTHING), caret); + let nan = Rect::from_min_max(pos2(0.0, 0.0), pos2(f32::NAN, 600.0)); + assert_eq!(composer::caret_area(caret, nan), caret); +} diff --git a/crates/ui/src/testing/mod.rs b/crates/ui/src/testing/mod.rs index e8866a87..9cab5bbd 100644 --- a/crates/ui/src/testing/mod.rs +++ b/crates/ui/src/testing/mod.rs @@ -127,7 +127,10 @@ impl Screens { } /// Called on the context before the first frame, after the fonts are installed: the app's - /// theme, and its interface direction if it stores one on the context. + /// theme, and anything else it stores on the context. The harness applies the locale's + /// direction ([`direction::apply`](crate::direction::apply)) before this runs and again after + /// it, so egui's own chrome follows the locale even when the callback installs a whole new + /// style. The callback can't override the direction. pub fn setup(mut self, setup: impl Fn(&Context, &Locale) + 'static) -> Self { self.setup = Some(Rc::new(setup)); self @@ -153,8 +156,10 @@ impl Screens { .with_size(self.size) .with_context_setup(move |ctx| { fonts.install(ctx); + crate::direction::apply(ctx, locale.direction); if let Some(setup) = setup { setup(ctx, &locale); + crate::direction::apply(ctx, locale.direction); } }) .build_ui_state( diff --git a/crates/ui/tests/snapshots/rename-ar.png b/crates/ui/tests/snapshots/rename-ar.png index b3b3055b6ff67df54c05edca35a9c1d85a5eae6b..32529767474351ec42441e7338b89e9c50650093 100644 GIT binary patch delta 5341 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