| author | |
| committer | |
| log | 309ad5f20a2a5bcb3ed236cb23f75d2d18064f3d |
| tree | 194422846523197098dd02d94b36294b0d53622d |
| parent | ce8ac92ba35e5cac71d188d311c0c2c241541692 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Spelling in the browser is Hunspell's American English dictionary through
spellbook, fetched beside the module, with Add to Dictionary's words kept
in the browser's files; the page has one thread, so paragraphs are
checked as they are drawn.
AccessKit has no web adapter, so once a screen reader asks for it, through
a visually hidden button, the trees it would get are mirrored as hidden
elements with ARIA roles, the focus as the text area's active descendant,
and acting on one sends AccessKit's action back.
Text the bundled faces lack takes Noto's, fetched by script the first time
a page holds it: Arabic, Hebrew, Devanagari, Thai, symbols, and a CJK face
cut to the characters GB 2312, JIS X 0208 and KS X 1001 list.
Assisted-by: claude-opus-5.510 files changed, 455 insertions(+), 21 deletions(-)
Cargo.lock+11| ... | @@ -3528,6 +3528,7 @@ dependencies = [ | ... | @@ -3528,6 +3528,7 @@ dependencies = [ |
| 3528 | "serde", | 3528 | "serde", |
| 3529 | "serde_json", | 3529 | "serde_json", |
| 3530 | "smithay-clipboard", | 3530 | "smithay-clipboard", |
| 3531 | "spellbook", | ||
| 3531 | "ui", | 3532 | "ui", |
| 3532 | "unicode_names2", | 3533 | "unicode_names2", |
| 3533 | "ureq", | 3534 | "ureq", |
| ... | @@ -3555,6 +3556,16 @@ dependencies = [ | ... | @@ -3555,6 +3556,16 @@ dependencies = [ |
| 3555 | "windows-sys 0.61.2", | 3556 | "windows-sys 0.61.2", |
| 3556 | ] | 3557 | ] |
| 3557 | 3558 | ||
| 3559 | [[package]] | ||
| 3560 | name = "spellbook" | ||
| 3561 | version = "0.4.2" | ||
| 3562 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3563 | checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a" | ||
| 3564 | dependencies = [ | ||
| 3565 | "foldhash", | ||
| 3566 | "hashbrown 0.17.1", | ||
| 3567 | ] | ||
| 3568 | |||
| 3558 | [[package]] | 3569 | [[package]] |
| 3559 | name = "spirv" | 3570 | name = "spirv" |
| 3560 | version = "0.4.0+sdk-1.4.341.0" | 3571 | version = "0.4.0+sdk-1.4.341.0" |
arc/platforms.md+10-3| ... | @@ -168,9 +168,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. | ... | @@ -168,9 +168,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 168 | it is uncertain, as one whose answer was lost, and it goes again on top of another app's | 168 | it is uncertain, as one whose answer was lost, and it goes again on top of another app's |
| 169 | write. The sync popup says the folder isn't locked. | 169 | write. The sync popup says the folder isn't locked. |
| 170 | - Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the | 170 | - Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the |
| 171 | browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert | 171 | browser keeps its own window and tab chords. Dialogs are the browser's; Insert, and Open |
| 172 | ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility | 172 | without a folder to give, ask for files to copy in; printing downloads the PDF. Servers and |
| 173 | are still to come. | 173 | recording are still to come. |
| 174 | - Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the | ||
| 175 | module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces | ||
| 176 | lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to | ||
| 177 | the national standards' characters by `tools/web/subset_cjk.py`). | ||
| 178 | - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden | ||
| 179 | button, then on every visit) the trees AccessKit would get are mirrored as hidden | ||
| 180 | elements with ARIA roles; acting on one sends AccessKit's action back. | ||
| 174 | 181 | ||
| 175 | ## iOS: native around the canvas | 182 | ## iOS: native around the canvas |
| 176 | 183 |
crates/canvas/src/spelling.rs+8| ... | @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, mpsc}; | ... | @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, mpsc}; |
| 13 | /// Checked paragraphs kept before the cache starts over. | 13 | /// Checked paragraphs kept before the cache starts over. |
| 14 | const KEPT: usize = 8192; | 14 | const KEPT: usize = 8192; |
| 15 | /// Paragraphs waiting to be checked that one call to the dictionary takes. | 15 | /// Paragraphs waiting to be checked that one call to the dictionary takes. |
| 16 | #[cfg(not(target_arch = "wasm32"))] | ||
| 16 | const BATCH: usize = 64; | 17 | const BATCH: usize = 64; |
| 17 | 18 | ||
| 18 | /// A platform spell checker, called from the spelling thread and the host's. Languages are | 19 | /// A platform spell checker, called from the spelling thread and the host's. Languages are |
| ... | @@ -327,6 +328,10 @@ impl Spelling { | ... | @@ -327,6 +328,10 @@ impl Spelling { |
| 327 | }); | 328 | }); |
| 328 | let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>(); | 329 | let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>(); |
| 329 | let worker = Arc::clone(&shared); | 330 | let worker = Arc::clone(&shared); |
| 331 | // The browser gives a page one thread: paragraphs are checked as they are drawn. | ||
| 332 | #[cfg(target_arch = "wasm32")] | ||
| 333 | drop((receiver, worker, redraw)); | ||
| 334 | #[cfg(not(target_arch = "wasm32"))] | ||
| 330 | std::thread::Builder::new() | 335 | std::thread::Builder::new() |
| 331 | .name("spelling".into()) | 336 | .name("spelling".into()) |
| 332 | .spawn(move || { | 337 | .spawn(move || { |
| ... | @@ -353,6 +358,9 @@ impl Spelling { | ... | @@ -353,6 +358,9 @@ impl Spelling { |
| 353 | /// The marks on `paragraph` once it is checked, less accepted words; until then none, | 358 | /// The marks on `paragraph` once it is checked, less accepted words; until then none, |
| 354 | /// and it is checked. | 359 | /// and it is checked. |
| 355 | pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> { | 360 | pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> { |
| 361 | if cfg!(target_arch = "wasm32") { | ||
| 362 | return self.marks_now(paragraph); | ||
| 363 | } | ||
| 356 | let key = key(paragraph); | 364 | let key = key(paragraph); |
| 357 | let mut state = self.shared.state.lock().unwrap(); | 365 | let mut state = self.shared.state.lock().unwrap(); |
| 358 | if let Some(marks) = state.marks(key, paragraph) { | 366 | if let Some(marks) = state.marks(key, paragraph) { |
crates/snowbound/Cargo.toml+2| ... | @@ -46,6 +46,8 @@ ring = "0.17" | ... | @@ -46,6 +46,8 @@ ring = "0.17" |
| 46 | [target.'cfg(target_arch = "wasm32")'.dependencies] | 46 | [target.'cfg(target_arch = "wasm32")'.dependencies] |
| 47 | wgpu = { workspace = true, features = ["webgl"] } | 47 | wgpu = { workspace = true, features = ["webgl"] } |
| 48 | getrandom = { version = "0.4", features = ["wasm_js"] } | 48 | getrandom = { version = "0.4", features = ["wasm_js"] } |
| 49 | # Checks spelling with Hunspell dictionaries, as the browser offers pages no checker. | ||
| 50 | spellbook = "0.4" | ||
| 49 | wasm-bindgen = "0.2" | 51 | wasm-bindgen = "0.2" |
| 50 | wasm-bindgen-futures = "0.4" | 52 | wasm-bindgen-futures = "0.4" |
| 51 | js-sys = "0.3" | 53 | js-sys = "0.3" |
crates/snowbound/src/spell_web.rs+60-4| ... | @@ -1,8 +1,64 @@ | ... | @@ -1,8 +1,64 @@ |
| 1 | //! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until | 1 | //! Spelling in the browser, which gives pages no checker to ask: Hunspell's American English |
| 2 | //! Hunspell dictionaries load in wasm. | 2 | //! dictionary through `spellbook`, which `index.html` fetches beside the module, with the |
| 3 | //! words Add to Dictionary learns kept in the browser's files. | ||
| 3 | 4 | ||
| 4 | use canvas::spelling::Dictionary; | 5 | use canvas::spelling::{Dictionary, pick}; |
| 6 | use std::{io::Write, sync::Mutex}; | ||
| 7 | |||
| 8 | /// The dictionary's files, where `web::start` puts them, and the words learned. | ||
| 9 | pub const AFFIX: &str = "/Dictionaries/en_US.aff"; | ||
| 10 | pub const WORDS: &str = "/Dictionaries/en_US.dic"; | ||
| 11 | const LEARNED: &str = "/Settings/dictionary.txt"; | ||
| 12 | |||
| 13 | struct Checker(Mutex<spellbook::Dictionary>); | ||
| 5 | 14 | ||
| 6 | pub fn dictionary() -> Option<Box<dyn Dictionary>> { | 15 | pub fn dictionary() -> Option<Box<dyn Dictionary>> { |
| 7 | None | 16 | let affix = notebook::fs::read_to_string(AFFIX).ok()?; |
| 17 | let words = notebook::fs::read_to_string(WORDS).ok()?; | ||
| 18 | let mut dictionary = spellbook::Dictionary::new(&affix, &words).ok()?; | ||
| 19 | for word in notebook::fs::read_to_string(LEARNED) | ||
| 20 | .unwrap_or_default() | ||
| 21 | .lines() | ||
| 22 | { | ||
| 23 | let _ = dictionary.add(word); | ||
| 24 | } | ||
| 25 | Some(Box::new(Checker(Mutex::new(dictionary)))) | ||
| 26 | } | ||
| 27 | |||
| 28 | /// Whether the dictionary serves `language`, an LCID. | ||
| 29 | fn serves(language: u32) -> bool { | ||
| 30 | pick(language, &["en_US".to_owned()]).is_some() | ||
| 31 | } | ||
| 32 | |||
| 33 | impl Dictionary for Checker { | ||
| 34 | fn misspelled(&self, words: &[(&str, u32)]) -> Vec<bool> { | ||
| 35 | let Ok(dictionary) = self.0.lock() else { | ||
| 36 | return vec![false; words.len()]; | ||
| 37 | }; | ||
| 38 | words | ||
| 39 | .iter() | ||
| 40 | .map(|(word, language)| serves(*language) && !dictionary.check(word)) | ||
| 41 | .collect() | ||
| 42 | } | ||
| 43 | |||
| 44 | fn suggest(&self, word: &str, language: u32) -> Vec<String> { | ||
| 45 | let mut suggestions = Vec::new(); | ||
| 46 | if serves(language) | ||
| 47 | && let Ok(dictionary) = self.0.lock() | ||
| 48 | { | ||
| 49 | dictionary.suggest(word, &mut suggestions); | ||
| 50 | } | ||
| 51 | suggestions | ||
| 52 | } | ||
| 53 | |||
| 54 | fn learn(&self, word: &str) { | ||
| 55 | if let Ok(mut dictionary) = self.0.lock() { | ||
| 56 | let _ = dictionary.add(word); | ||
| 57 | } | ||
| 58 | let _ = notebook::fs::OpenOptions::new() | ||
| 59 | .create(true) | ||
| 60 | .append(true) | ||
| 61 | .open(LEARNED) | ||
| 62 | .and_then(|mut file| writeln!(file, "{word}")); | ||
| 63 | } | ||
| 8 | } | 64 | } |
crates/snowbound/src/web.rs+206-6| ... | @@ -10,7 +10,9 @@ use std::{ | ... | @@ -10,7 +10,9 @@ use std::{ |
| 10 | collections::VecDeque, | 10 | collections::VecDeque, |
| 11 | error::Error, | 11 | error::Error, |
| 12 | marker::PhantomData, | 12 | marker::PhantomData, |
| 13 | ops::RangeInclusive, | ||
| 13 | path::{Path, PathBuf}, | 14 | path::{Path, PathBuf}, |
| 15 | sync::Arc, | ||
| 14 | time::Duration, | 16 | time::Duration, |
| 15 | }; | 17 | }; |
| 16 | use ui::Ui; | 18 | use ui::Ui; |
| ... | @@ -70,6 +72,12 @@ extern "C" { | ... | @@ -70,6 +72,12 @@ extern "C" { |
| 70 | /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a | 72 | /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a |
| 71 | /// fourth `true`, a section committed under a folder of the user's, written only where | 73 | /// fourth `true`, a section committed under a folder of the user's, written only where |
| 72 | /// nothing else wrote the file since it was read (`refreshed` answers). | 74 | /// nothing else wrote the file since it was read (`refreshed` answers). |
| 75 | /// Applies an accessibility tree update to the DOM mirror (`mirrored`). | ||
| 76 | #[wasm_bindgen(js_name = mirrorTree)] | ||
| 77 | fn mirror_tree(update: &js_sys::Object); | ||
| 78 | /// Fetches `fonts/{name}` for `font_arrived`. | ||
| 79 | #[wasm_bindgen(js_name = fetchFont)] | ||
| 80 | fn fetch_font(name: &str); | ||
| 73 | #[wasm_bindgen(js_name = storeFiles)] | 81 | #[wasm_bindgen(js_name = storeFiles)] |
| 74 | fn store_files(changes: js_sys::Array); | 82 | fn store_files(changes: js_sys::Array); |
| 75 | } | 83 | } |
| ... | @@ -84,6 +92,41 @@ const SETTINGS: &str = "/Settings"; | ... | @@ -84,6 +92,41 @@ const SETTINGS: &str = "/Settings"; |
| 84 | const FOLDERS: &str = "/Folders"; | 92 | const FOLDERS: &str = "/Folders"; |
| 85 | /// Files chosen to insert, open or paste, kept where the page links them from. | 93 | /// Files chosen to insert, open or paste, kept where the page links them from. |
| 86 | const CHOSEN: &str = "/Chosen"; | 94 | const CHOSEN: &str = "/Chosen"; |
| 95 | /// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a | ||
| 96 | /// page holds a character in their ranges: Noto's, by script. | ||
| 97 | const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 6] = [ | ||
| 98 | ( | ||
| 99 | "NotoSansArabic.ttf", | ||
| 100 | &[ | ||
| 101 | 0x600..=0x6ff, | ||
| 102 | 0x750..=0x77f, | ||
| 103 | 0x8a0..=0x8ff, | ||
| 104 | 0xfb50..=0xfdff, | ||
| 105 | 0xfe70..=0xfeff, | ||
| 106 | ], | ||
| 107 | ), | ||
| 108 | ("NotoSansHebrew.ttf", &[0x590..=0x5ff, 0xfb1d..=0xfb4f]), | ||
| 109 | ("NotoSansDevanagari.ttf", &[0x900..=0x97f, 0xa8e0..=0xa8ff]), | ||
| 110 | ("NotoSansThai.ttf", &[0xe00..=0xe7f]), | ||
| 111 | ( | ||
| 112 | "NotoSansSymbols2-Regular.otf", | ||
| 113 | &[ | ||
| 114 | 0x2190..=0x21ff, | ||
| 115 | 0x2300..=0x23ff, | ||
| 116 | 0x25a0..=0x27bf, | ||
| 117 | 0x2b00..=0x2bff, | ||
| 118 | ], | ||
| 119 | ), | ||
| 120 | ( | ||
| 121 | "NotoSansCJK.otf", | ||
| 122 | &[ | ||
| 123 | 0x2e80..=0x9fff, | ||
| 124 | 0xac00..=0xd7af, | ||
| 125 | 0xf900..=0xfaff, | ||
| 126 | 0xff00..=0xffef, | ||
| 127 | ], | ||
| 128 | ), | ||
| 129 | ]; | ||
| 87 | /// How long changed files wait to be written out, so a burst of edits writes once. | 130 | /// How long changed files wait to be written out, so a burst of edits writes once. |
| 88 | const STORE_AFTER: Duration = Duration::from_millis(500); | 131 | const STORE_AFTER: Duration = Duration::from_millis(500); |
| 89 | 132 | ||
| ... | @@ -123,6 +166,13 @@ thread_local! { | ... | @@ -123,6 +166,13 @@ thread_local! { |
| 123 | static EVENTS: RefCell<VecDeque<UserEvent>> = const { RefCell::new(VecDeque::new()) }; | 166 | static EVENTS: RefCell<VecDeque<UserEvent>> = const { RefCell::new(VecDeque::new()) }; |
| 124 | /// Work deferred to after the frame, as threads run it elsewhere. | 167 | /// Work deferred to after the frame, as threads run it elsewhere. |
| 125 | static LATER: RefCell<Vec<Box<dyn FnOnce()>>> = const { RefCell::new(Vec::new()) }; | 168 | static LATER: RefCell<Vec<Box<dyn FnOnce()>>> = const { RefCell::new(Vec::new()) }; |
| 169 | /// The fallback faces asked for, by bit, and the page whose text was last looked through. | ||
| 170 | static FETCHED: Cell<u32> = const { Cell::new(0) }; | ||
| 171 | static LOOKED: Cell<Option<onestore::ExGuid>> = const { Cell::new(None) }; | ||
| 172 | /// Text arrived from the keyboard, input methods or the clipboard since the last look. | ||
| 173 | static TYPED: Cell<bool> = const { Cell::new(false) }; | ||
| 174 | /// A screen reader asked for the accessibility mirror. | ||
| 175 | static ACCESSIBLE: Cell<bool> = const { Cell::new(false) }; | ||
| 126 | /// Input waits a frame for what a chord opened. | 176 | /// Input waits a frame for what a chord opened. |
| 127 | static HOLDING: Cell<bool> = const { Cell::new(false) }; | 177 | static HOLDING: Cell<bool> = const { Cell::new(false) }; |
| 128 | /// Modifier bits as the page's input last reported them. | 178 | /// Modifier bits as the page's input last reported them. |
| ... | @@ -268,8 +318,9 @@ impl EventLoopProxy<UserEvent> { | ... | @@ -268,8 +318,9 @@ impl EventLoopProxy<UserEvent> { |
| 268 | } | 318 | } |
| 269 | } | 319 | } |
| 270 | 320 | ||
| 271 | /// Assistive technology's view of the window, which a DOM mirror takes in a later phase; | 321 | /// Assistive technology's view of the window: a DOM of ARIA roles the glue keeps beside the |
| 272 | /// until then no tree is asked for. | 322 | /// canvas once a screen reader asks for it (`accessibility`), as AccessKit has no adapter |
| 323 | /// for the web. | ||
| 273 | pub struct AccessAdapter; | 324 | pub struct AccessAdapter; |
| 274 | 325 | ||
| 275 | impl AccessAdapter { | 326 | impl AccessAdapter { |
| ... | @@ -281,7 +332,98 @@ impl AccessAdapter { | ... | @@ -281,7 +332,98 @@ impl AccessAdapter { |
| 281 | Self | 332 | Self |
| 282 | } | 333 | } |
| 283 | 334 | ||
| 284 | pub fn update_if_active(&mut self, _: impl FnOnce() -> accesskit::TreeUpdate) {} | 335 | pub fn update_if_active(&mut self, update: impl FnOnce() -> accesskit::TreeUpdate) { |
| 336 | if ACCESSIBLE.get() { | ||
| 337 | mirror_tree(&mirrored(&update())); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | /// `update` as the glue mirrors it: the tree, its focus and root where it has one, and each | ||
| 343 | /// node as `[id, role, name, value, children, bounds, grafted tree, disabled, toggled]`. | ||
| 344 | fn mirrored(update: &accesskit::TreeUpdate) -> js_sys::Object { | ||
| 345 | let tree = |id: accesskit::TreeId| JsValue::from_str(&id.0.to_string()); | ||
| 346 | let nodes: js_sys::Array = update | ||
| 347 | .nodes | ||
| 348 | .iter() | ||
| 349 | .map(|(id, node)| { | ||
| 350 | let children: js_sys::Array = node | ||
| 351 | .children() | ||
| 352 | .iter() | ||
| 353 | .map(|child| JsValue::from_f64(child.0 as f64)) | ||
| 354 | .collect(); | ||
| 355 | let bounds = node.bounds().map_or(JsValue::NULL, |rect| { | ||
| 356 | js_sys::Array::of4( | ||
| 357 | &rect.x0.into(), | ||
| 358 | &rect.y0.into(), | ||
| 359 | &rect.x1.into(), | ||
| 360 | &rect.y1.into(), | ||
| 361 | ) | ||
| 362 | .into() | ||
| 363 | }); | ||
| 364 | let text = |text: Option<&str>| text.map_or(JsValue::NULL, JsValue::from_str); | ||
| 365 | [ | ||
| 366 | JsValue::from_f64(id.0 as f64), | ||
| 367 | JsValue::from_str(&format!("{:?}", node.role())), | ||
| 368 | text(node.label()), | ||
| 369 | text(node.value()), | ||
| 370 | children.into(), | ||
| 371 | bounds, | ||
| 372 | node.tree_id().map_or(JsValue::NULL, tree), | ||
| 373 | node.is_disabled().into(), | ||
| 374 | node.toggled().map_or(JsValue::NULL, |toggled| { | ||
| 375 | (toggled == accesskit::Toggled::True).into() | ||
| 376 | }), | ||
| 377 | ] | ||
| 378 | .into_iter() | ||
| 379 | .collect::<js_sys::Array>() | ||
| 380 | }) | ||
| 381 | .collect(); | ||
| 382 | let mirrored = js_sys::Object::new(); | ||
| 383 | let set = |key: &str, value: &JsValue| { | ||
| 384 | let _ = js_sys::Reflect::set(&mirrored, &key.into(), value); | ||
| 385 | }; | ||
| 386 | set("tree", &tree(update.tree_id)); | ||
| 387 | set("focus", &JsValue::from_f64(update.focus.0 as f64)); | ||
| 388 | set( | ||
| 389 | "root", | ||
| 390 | &update | ||
| 391 | .tree | ||
| 392 | .as_ref() | ||
| 393 | .map_or(JsValue::NULL, |info| JsValue::from_f64(info.root.0 as f64)), | ||
| 394 | ); | ||
| 395 | set("nodes", &nodes); | ||
| 396 | mirrored | ||
| 397 | } | ||
| 398 | |||
| 399 | /// A screen reader turned the mirror on, or off: the whole tree is sent afresh. | ||
| 400 | #[wasm_bindgen] | ||
| 401 | pub fn accessibility(on: bool) { | ||
| 402 | ACCESSIBLE.set(on); | ||
| 403 | send(UserEvent::Then(Box::new(|state| { | ||
| 404 | state.deactivate_accessibility(); | ||
| 405 | state.update_accessibility(true) | ||
| 406 | }))); | ||
| 407 | } | ||
| 408 | |||
| 409 | /// Assistive technology acted on node `node` of tree `tree`: 0 clicks it, 1 focuses it. | ||
| 410 | #[wasm_bindgen] | ||
| 411 | pub fn access(tree: String, node: f64, action: u8) { | ||
| 412 | let Ok(tree) = tree.parse() else { | ||
| 413 | return; | ||
| 414 | }; | ||
| 415 | let request = accesskit::ActionRequest { | ||
| 416 | action: match action { | ||
| 417 | 1 => accesskit::Action::Focus, | ||
| 418 | _ => accesskit::Action::Click, | ||
| 419 | }, | ||
| 420 | target_tree: accesskit::TreeId(tree), | ||
| 421 | target_node: accesskit::NodeId(node as u64), | ||
| 422 | data: None, | ||
| 423 | }; | ||
| 424 | send(UserEvent::Then(Box::new(move |state| { | ||
| 425 | state.access_action(request) | ||
| 426 | }))); | ||
| 285 | } | 427 | } |
| 286 | 428 | ||
| 287 | /// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's | 429 | /// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's |
| ... | @@ -670,10 +812,15 @@ pub mod smb { | ... | @@ -670,10 +812,15 @@ pub mod smb { |
| 670 | } | 812 | } |
| 671 | } | 813 | } |
| 672 | 814 | ||
| 673 | /// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is | 815 | /// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts` and, where it |
| 674 | /// the module's own exports, which the glue calls. | 816 | /// could, the spelling dictionary's affix and word files. `module` is the module's own |
| 817 | /// exports, which the glue calls. | ||
| 675 | #[wasm_bindgen] | 818 | #[wasm_bindgen] |
| 676 | pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> { | 819 | pub async fn start( |
| 820 | module: JsValue, | ||
| 821 | fonts: Vec<js_sys::Uint8Array>, | ||
| 822 | dictionary: Vec<js_sys::Uint8Array>, | ||
| 823 | ) -> Result<(), JsValue> { | ||
| 677 | std::panic::set_hook(Box::new(|info| report(info))); | 824 | std::panic::set_hook(Box::new(|info| report(info))); |
| 678 | let window = web_sys::window().ok_or("No window")?; | 825 | let window = web_sys::window().ok_or("No window")?; |
| 679 | let navigator = window.navigator(); | 826 | let navigator = window.navigator(); |
| ... | @@ -701,6 +848,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<() | ... | @@ -701,6 +848,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<() |
| 701 | canvas.set_width(host(|host| host.size.width)); | 848 | canvas.set_width(host(|host| host.size.width)); |
| 702 | canvas.set_height(host(|host| host.size.height)); | 849 | canvas.set_height(host(|host| host.size.height)); |
| 703 | restore(load_files().await?); | 850 | restore(load_files().await?); |
| 851 | if let [affix, words] = dictionary.as_slice() { | ||
| 852 | notebook::fs::restore("/Dictionaries", notebook::fs::Saved::Directory); | ||
| 853 | for (path, file) in [(crate::spell::AFFIX, affix), (crate::spell::WORDS, words)] { | ||
| 854 | notebook::fs::restore(path, notebook::fs::Saved::File(file.to_vec(), 0.0)); | ||
| 855 | } | ||
| 856 | } | ||
| 704 | for folder in load_folders().await?.iter() { | 857 | for folder in load_folders().await?.iter() { |
| 705 | let folder = js_sys::Array::from(&folder); | 858 | let folder = js_sys::Array::from(&folder); |
| 706 | let root = folder.get(0).as_string().unwrap_or_default(); | 859 | let root = folder.get(0).as_string().unwrap_or_default(); |
| ... | @@ -893,6 +1046,7 @@ pub fn pointer(kind: u8, x: f32, y: f32, button: i16, pressure: f32, held: u8) { | ... | @@ -893,6 +1046,7 @@ pub fn pointer(kind: u8, x: f32, y: f32, button: i16, pressure: f32, held: u8) { |
| 893 | #[wasm_bindgen] | 1046 | #[wasm_bindgen] |
| 894 | pub fn key(key: &str, text: Option<String>, held: u8) { | 1047 | pub fn key(key: &str, text: Option<String>, held: u8) { |
| 895 | follow_modifiers(held); | 1048 | follow_modifiers(held); |
| 1049 | TYPED.set(TYPED.get() || text.is_some()); | ||
| 896 | queue(Input::Ui(ui::Event::Key { | 1050 | queue(Input::Ui(ui::Event::Key { |
| 897 | key: logical_key(key), | 1051 | key: logical_key(key), |
| 898 | text, | 1052 | text, |
| ... | @@ -915,6 +1069,7 @@ pub fn compose(text: String) { | ... | @@ -915,6 +1069,7 @@ pub fn compose(text: String) { |
| 915 | 1069 | ||
| 916 | #[wasm_bindgen] | 1070 | #[wasm_bindgen] |
| 917 | pub fn commit(text: String) { | 1071 | pub fn commit(text: String) { |
| 1072 | TYPED.set(true); | ||
| 918 | queue(Input::Ui(ui::Event::Ime(Ime::Commit(text)))); | 1073 | queue(Input::Ui(ui::Event::Ime(Ime::Commit(text)))); |
| 919 | } | 1074 | } |
| 920 | 1075 | ||
| ... | @@ -922,6 +1077,7 @@ pub fn commit(text: String) { | ... | @@ -922,6 +1077,7 @@ pub fn commit(text: String) { |
| 922 | #[wasm_bindgen] | 1077 | #[wasm_bindgen] |
| 923 | pub fn paste(text: Option<String>, html: Option<String>, files: js_sys::Array) { | 1078 | pub fn paste(text: Option<String>, html: Option<String>, files: js_sys::Array) { |
| 924 | let files = keep(files, CHOSEN); | 1079 | let files = keep(files, CHOSEN); |
| 1080 | TYPED.set(true); | ||
| 925 | PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files }); | 1081 | PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files }); |
| 926 | send(UserEvent::Choose(commands::Choice::Command( | 1082 | send(UserEvent::Choose(commands::Choice::Command( |
| 927 | commands::Id::Paste, | 1083 | commands::Id::Paste, |
| ... | @@ -1136,6 +1292,10 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> { | ... | @@ -1136,6 +1292,10 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> { |
| 1136 | if host(|host| std::mem::take(&mut host.redraw)) { | 1292 | if host(|host| std::mem::take(&mut host.redraw)) { |
| 1137 | state.frame()?; | 1293 | state.frame()?; |
| 1138 | } | 1294 | } |
| 1295 | let space = state.session.as_ref().map(|session| session.space); | ||
| 1296 | if TYPED.take() || LOOKED.replace(space) != space { | ||
| 1297 | fetch_fallbacks(&crate::page_text(&state.view.editor)); | ||
| 1298 | } | ||
| 1139 | park_input(state); | 1299 | park_input(state); |
| 1140 | let now = Instant::now(); | 1300 | let now = Instant::now(); |
| 1141 | let (repaint, blink) = state.view.blink(now); | 1301 | let (repaint, blink) = state.view.blink(now); |
| ... | @@ -1159,6 +1319,46 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> { | ... | @@ -1159,6 +1319,46 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> { |
| 1159 | Ok(()) | 1319 | Ok(()) |
| 1160 | } | 1320 | } |
| 1161 | 1321 | ||
| 1322 | /// Fetches the fallback faces `text` calls for that are not yet asked for. | ||
| 1323 | fn fetch_fallbacks(text: &str) { | ||
| 1324 | for character in text.chars().map(u32::from) { | ||
| 1325 | for (index, (name, ranges)) in FALLBACKS.iter().enumerate() { | ||
| 1326 | if FETCHED.get() & (1 << index) == 0 | ||
| 1327 | && ranges.iter().any(|range| range.contains(&character)) | ||
| 1328 | { | ||
| 1329 | FETCHED.set(FETCHED.get() | 1 << index); | ||
| 1330 | fetch_font(name); | ||
| 1331 | } | ||
| 1332 | } | ||
| 1333 | } | ||
| 1334 | } | ||
| 1335 | |||
| 1336 | /// A fallback face arrived: the page, its loaders and the interface draw what the bundled | ||
| 1337 | /// faces lack with it, and the page is laid out again. | ||
| 1338 | #[wasm_bindgen] | ||
| 1339 | pub fn font_arrived(bytes: Vec<u8>) { | ||
| 1340 | let face = parley::fontique::Blob::new(Arc::new(bytes)); | ||
| 1341 | send(UserEvent::Then(Box::new(move |state| { | ||
| 1342 | let collection = &mut state.view.engine.fonts.collection; | ||
| 1343 | let families: Vec<String> = collection | ||
| 1344 | .register_fonts(face.clone(), None) | ||
| 1345 | .into_iter() | ||
| 1346 | .filter_map(|(family, _)| collection.family_name(family).map(String::from)) | ||
| 1347 | .collect(); | ||
| 1348 | draw::fall_back_to(collection, &families); | ||
| 1349 | if let Ok(mut layouts) = state.layouts.lock() { | ||
| 1350 | layouts.fonts.collection.register_fonts(face.clone(), None); | ||
| 1351 | draw::fall_back_to(&mut layouts.fonts.collection, &families); | ||
| 1352 | } | ||
| 1353 | for family in &families { | ||
| 1354 | state.ui.preview_font(face.clone(), family); | ||
| 1355 | } | ||
| 1356 | state.ui.fall_back_to(&families); | ||
| 1357 | state.renderer.clear_glyph_cache(); | ||
| 1358 | state.refresh() | ||
| 1359 | }))); | ||
| 1360 | } | ||
| 1361 | |||
| 1162 | /// Parks the text area while the page takes no text, keeping it writable for the | 1362 | /// Parks the text area while the page takes no text, keeping it writable for the |
| 1163 | /// interface's fields. | 1363 | /// interface's fields. |
| 1164 | fn park_input(state: &State) { | 1364 | fn park_input(state: &State) { |
crates/snowbound/web/glue.js+75| ... | @@ -287,6 +287,13 @@ export function pickNotebook() { | ... | @@ -287,6 +287,13 @@ export function pickNotebook() { |
| 287 | .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); | 287 | .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); |
| 288 | } | 288 | } |
| 289 | 289 | ||
| 290 | export function fetchFont(name) { | ||
| 291 | fetch(`fonts/${name}`) | ||
| 292 | .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status))) | ||
| 293 | .then((data) => wasm.font_arrived(new Uint8Array(data))) | ||
| 294 | .catch((error) => console.warn("Font", name, error)); | ||
| 295 | } | ||
| 296 | |||
| 290 | export function requestFrame() { | 297 | export function requestFrame() { |
| 291 | if (!framePending) { | 298 | if (!framePending) { |
| 292 | framePending = true; | 299 | framePending = true; |
| ... | @@ -371,6 +378,73 @@ export function openLink(url) { | ... | @@ -371,6 +378,73 @@ export function openLink(url) { |
| 371 | open(url, "_blank", "noopener"); | 378 | open(url, "_blank", "noopener"); |
| 372 | } | 379 | } |
| 373 | 380 | ||
| 381 | // Assistive technology's view of the window: AccessKit's trees mirrored as elements with | ||
| 382 | // ARIA roles, visually hidden, which a screen reader reads and acts on. | ||
| 383 | const ROLES = { | ||
| 384 | Button: "button", DefaultButton: "button", CheckBox: "checkbox", RadioButton: "radio", | ||
| 385 | Switch: "switch", TextInput: "textbox", MultilineTextInput: "textbox", SearchInput: "searchbox", | ||
| 386 | Document: "document", Group: "group", GenericContainer: "generic", Label: "none", | ||
| 387 | Paragraph: "paragraph", List: "list", ListItem: "listitem", ListBox: "listbox", | ||
| 388 | ListBoxOption: "option", Menu: "menu", MenuBar: "menubar", MenuItem: "menuitem", | ||
| 389 | MenuItemCheckBox: "menuitemcheckbox", MenuItemRadio: "menuitemradio", Tab: "tab", | ||
| 390 | TabList: "tablist", TabPanel: "tabpanel", Toolbar: "toolbar", Tooltip: "tooltip", | ||
| 391 | Tree: "tree", TreeItem: "treeitem", Dialog: "dialog", AlertDialog: "alertdialog", | ||
| 392 | Image: "img", Link: "link", Heading: "heading", Table: "table", Row: "row", Cell: "cell", | ||
| 393 | ColumnHeader: "columnheader", ComboBox: "combobox", ScrollBar: "scrollbar", Slider: "slider", | ||
| 394 | Window: "application", Pane: "region", Separator: "separator", Status: "status", | ||
| 395 | }; | ||
| 396 | const mirrored = new Map(); | ||
| 397 | export function mirrorTree({ tree, focus, root, nodes }) { | ||
| 398 | if (!mirrored.has(tree)) mirrored.set(tree, { nodes: new Map(), root: null }); | ||
| 399 | const held = mirrored.get(tree); | ||
| 400 | const element = (id) => { | ||
| 401 | if (!held.nodes.has(id)) { | ||
| 402 | const made = document.createElement("div"); | ||
| 403 | made.id = `a11y-${tree}-${id}`; | ||
| 404 | made.onclick = () => wasm.access(tree, id, 0); | ||
| 405 | held.nodes.set(id, made); | ||
| 406 | } | ||
| 407 | return held.nodes.get(id); | ||
| 408 | }; | ||
| 409 | for (const [id, role, name, value, children, , grafted, disabled, toggled] of nodes) { | ||
| 410 | const node = element(id); | ||
| 411 | const aria = ROLES[role]; | ||
| 412 | if (aria && aria !== "none") node.setAttribute("role", aria); | ||
| 413 | else node.removeAttribute("role"); | ||
| 414 | const text = role === "Label" || role === "TextRun" || role === "StaticText"; | ||
| 415 | if (name && !text) node.setAttribute("aria-label", name); | ||
| 416 | else node.removeAttribute("aria-label"); | ||
| 417 | node.toggleAttribute("aria-disabled", disabled); | ||
| 418 | if (toggled === null) node.removeAttribute("aria-checked"); | ||
| 419 | else node.setAttribute("aria-checked", String(toggled)); | ||
| 420 | const kids = children.map(element); | ||
| 421 | if (grafted && mirrored.get(grafted)?.root) kids.push(mirrored.get(grafted).root); | ||
| 422 | if (text && !kids.length) node.textContent = value ?? name ?? ""; | ||
| 423 | else node.replaceChildren(...kids); | ||
| 424 | if (grafted) node.dataset.grafted = grafted; | ||
| 425 | } | ||
| 426 | if (root !== null) { | ||
| 427 | held.root = element(root); | ||
| 428 | if (tree === "00000000-0000-0000-0000-000000000000") | ||
| 429 | document.getElementById("a11y").replaceChildren(held.root); | ||
| 430 | else | ||
| 431 | for (const { nodes: all } of mirrored.values()) | ||
| 432 | for (const node of all.values()) if (node.dataset.grafted === tree) node.append(held.root); | ||
| 433 | } | ||
| 434 | if (held.nodes.has(focus)) input.setAttribute("aria-activedescendant", held.nodes.get(focus).id); | ||
| 435 | } | ||
| 436 | |||
| 437 | /** Turns the mirror on: once a screen reader asks for it, and on every visit after. */ | ||
| 438 | function offerAccessibility() { | ||
| 439 | const on = () => { | ||
| 440 | localStorage.setItem("snowbound-accessibility", "on"); | ||
| 441 | document.getElementById("accessible").remove(); | ||
| 442 | wasm.accessibility(true); | ||
| 443 | }; | ||
| 444 | document.getElementById("accessible").onclick = on; | ||
| 445 | if (localStorage.getItem("snowbound-accessibility") === "on") on(); | ||
| 446 | } | ||
| 447 | |||
| 374 | function modifiers(event) { | 448 | function modifiers(event) { |
| 375 | return ( | 449 | return ( |
| 376 | (event.shiftKey ? 1 : 0) | | 450 | (event.shiftKey ? 1 : 0) | |
| ... | @@ -547,5 +621,6 @@ export function attach(module) { | ... | @@ -547,5 +621,6 @@ export function attach(module) { |
| 547 | Math.round(entry.contentRect.height * devicePixelRatio), | 621 | Math.round(entry.contentRect.height * devicePixelRatio), |
| 548 | ), | 622 | ), |
| 549 | ).observe(canvas); | 623 | ).observe(canvas); |
| 624 | offerAccessibility(); | ||
| 550 | input.focus({ preventScroll: true }); | 625 | input.focus({ preventScroll: true }); |
| 551 | } | 626 | } |
crates/snowbound/web/index.html+13-3| ... | @@ -14,6 +14,11 @@ | ... | @@ -14,6 +14,11 @@ |
| 14 | opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px; | 14 | opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px; |
| 15 | caret-color: transparent; pointer-events: none; | 15 | caret-color: transparent; pointer-events: none; |
| 16 | } | 16 | } |
| 17 | /* Read by screen readers, not seen. */ | ||
| 18 | .hidden { | ||
| 19 | position: fixed; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; | ||
| 20 | clip-path: inset(50%); white-space: nowrap; | ||
| 21 | } | ||
| 17 | .reconnect { | 22 | .reconnect { |
| 18 | position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); | 23 | position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); |
| 19 | font: 14px system-ui, sans-serif; padding: 6px 14px; | 24 | font: 14px system-ui, sans-serif; padding: 6px 14px; |
| ... | @@ -25,7 +30,9 @@ | ... | @@ -25,7 +30,9 @@ |
| 25 | </style> | 30 | </style> |
| 26 | </head> | 31 | </head> |
| 27 | <body> | 32 | <body> |
| 33 | <button id="accessible" class="hidden">Turn on screen reader support</button> | ||
| 28 | <canvas id="page"></canvas> | 34 | <canvas id="page"></canvas> |
| 35 | <div id="a11y" class="hidden"></div> | ||
| 29 | <textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea> | 36 | <textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea> |
| 30 | <input id="files" type="file" hidden> | 37 | <input id="files" type="file" hidden> |
| 31 | <div id="status">Loading Snowbound…</div> | 38 | <div id="status">Loading Snowbound…</div> |
| ... | @@ -65,11 +72,14 @@ | ... | @@ -65,11 +72,14 @@ |
| 65 | throw new Error("Snowbound is open in another tab"); | 72 | throw new Error("Snowbound is open in another tab"); |
| 66 | } | 73 | } |
| 67 | try { | 74 | try { |
| 68 | const [, fonts] = await Promise.all([ | 75 | const fetched = (url) => bytes(url).then((data) => new Uint8Array(data)); |
| 76 | const [, fonts, dictionary] = await Promise.all([ | ||
| 69 | init({ module_or_path: module }), | 77 | init({ module_or_path: module }), |
| 70 | Promise.all(FONTS.map((name) => bytes(`fonts/${name}.ttf`).then((data) => new Uint8Array(data)))), | 78 | Promise.all(FONTS.map((name) => fetched(`fonts/${name}.ttf`))), |
| 79 | // Spelling waits for none: without the dictionary, words go unmarked. | ||
| 80 | Promise.all(["aff", "dic"].map((kind) => fetched(`dictionaries/en_US.${kind}`))).catch(() => []), | ||
| 71 | ]); | 81 | ]); |
| 72 | await snowbound.start(snowbound, fonts); | 82 | await snowbound.start(snowbound, fonts, dictionary); |
| 73 | status.remove(); | 83 | status.remove(); |
| 74 | console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`); | 84 | console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`); |
| 75 | } catch (error) { | 85 | } catch (error) { |
tools/release_web.py+30-5| ... | @@ -31,16 +31,17 @@ def run(command, **kwargs): | ... | @@ -31,16 +31,17 @@ def run(command, **kwargs): |
| 31 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) | 31 | subprocess.run(command, cwd=ROOT, check=True, **kwargs) |
| 32 | 32 | ||
| 33 | 33 | ||
| 34 | def tool(name, nix=None, wasm=False): | 34 | def tool(name, nix=None, wasm=False, store=False): |
| 35 | """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`, | 35 | """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`, |
| 36 | from nixpkgs only, as the system's clang may not build for wasm32.""" | 36 | from nixpkgs only, as the system's clang may not build for wasm32; with `store`, the |
| 37 | found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) | 37 | package's folder.""" |
| 38 | found = None if wasm or store else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) | ||
| 38 | if found: | 39 | if found: |
| 39 | return found | 40 | return found |
| 40 | if nix and shutil.which('nix'): | 41 | if nix and shutil.which('nix'): |
| 41 | store = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], | 42 | built = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], |
| 42 | text=True).split()[-1] | 43 | text=True).split()[-1] |
| 43 | return str(Path(store) / 'bin' / name) | 44 | return built if store else str(Path(built) / 'bin' / name) |
| 44 | sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, ' | 45 | sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, ' |
| 45 | 'and binaryen for wasm-opt') | 46 | 'and binaryen for wasm-opt') |
| 46 | 47 | ||
| ... | @@ -68,15 +69,39 @@ def build(out): | ... | @@ -68,15 +69,39 @@ def build(out): |
| 68 | bound = out / 'snowbound_web_bg.wasm' | 69 | bound = out / 'snowbound_web_bg.wasm' |
| 69 | run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) | 70 | run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) |
| 70 | shutil.copy(WEB / 'index.html', out) | 71 | shutil.copy(WEB / 'index.html', out) |
| 72 | # Hunspell's American English dictionary (SCOWL, BSD-3-Clause), for spelling. | ||
| 73 | (out / 'dictionaries').mkdir() | ||
| 74 | store = Path(tool('hunspell', 'hunspellDicts.en_US', store=True)) | ||
| 75 | for name in ('share/hunspell/en_US.aff', 'share/hunspell/en_US.dic', 'share/doc/hunspell-dict-en-us-wordlist.txt'): | ||
| 76 | shutil.copy(store / name, out / 'dictionaries') | ||
| 71 | (out / 'fonts').mkdir() | 77 | (out / 'fonts').mkdir() |
| 72 | for font in sorted(FONTS.glob('*')): | 78 | for font in sorted(FONTS.glob('*')): |
| 73 | if font.suffix in ('.ttf', '.txt'): | 79 | if font.suffix in ('.ttf', '.txt'): |
| 74 | shutil.copy(font, out / 'fonts') | 80 | shutil.copy(font, out / 'fonts') |
| 81 | fallbacks(out / 'fonts') | ||
| 75 | for path in sorted(out.rglob('*')): | 82 | for path in sorted(out.rglob('*')): |
| 76 | if path.is_file(): | 83 | if path.is_file(): |
| 77 | print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') | 84 | print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') |
| 78 | 85 | ||
| 79 | 86 | ||
| 87 | def fallbacks(fonts): | ||
| 88 | """Noto's faces for scripts the bundled ones lack, which the page fetches as it needs them | ||
| 89 | (`FALLBACKS` in src/web.rs), under the SIL Open Font License.""" | ||
| 90 | noto = Path(tool('noto', 'noto-fonts', store=True)) / 'share/fonts/noto' | ||
| 91 | for name in ('NotoSansArabic.ttf', 'NotoSansHebrew.ttf', 'NotoSansDevanagari.ttf', 'NotoSansThai.ttf', | ||
| 92 | 'NotoSansSymbols2-Regular.otf'): | ||
| 93 | shutil.copy(noto / name, fonts) | ||
| 94 | # Cutting the CJK face takes a minute or two, so the cut is kept between builds. | ||
| 95 | cjk = TARGET / 'NotoSansCJK.otf' | ||
| 96 | if not cjk.exists(): | ||
| 97 | collection = Path(tool('noto', 'noto-fonts-cjk-sans', store=True)) / \ | ||
| 98 | 'share/fonts/opentype/noto-cjk/NotoSansCJK-VF.otf.ttc' | ||
| 99 | run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py', | ||
| 100 | collection, cjk]) | ||
| 101 | shutil.copy(cjk, fonts) | ||
| 102 | (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') | ||
| 103 | |||
| 104 | |||
| 80 | def publish(built): | 105 | def publish(built): |
| 81 | """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" | 106 | """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" |
| 82 | if not PUBLISHED.parent.is_dir(): | 107 | if not PUBLISHED.parent.is_dir(): |
tools/web/subset_cjk.py created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Writes Noto Sans CJK SC at its regular weight, cut to the characters GB 2312, JIS X 0208 | ||
| 3 | and KS X 1001's Hangul hold, as the web build's fallback for Chinese, Japanese and Korean. | ||
| 4 | |||
| 5 | uv run --with fonttools python tools/web/subset_cjk.py NotoSansCJK-VF.otf.ttc OUT.otf | ||
| 6 | """ | ||
| 7 | import sys | ||
| 8 | |||
| 9 | from fontTools import subset | ||
| 10 | from fontTools.ttLib import TTCollection | ||
| 11 | from fontTools.varLib import instancer | ||
| 12 | |||
| 13 | |||
| 14 | def main(collection, out): | ||
| 15 | font = next(face for face in TTCollection(collection).fonts if face['name'].getDebugName(1) == 'Noto Sans CJK SC') | ||
| 16 | font = instancer.instantiateVariableFont(font, {'wght': 400}) | ||
| 17 | # Punctuation, kana, Hangul compatibility jamo and full-width forms whole; ideographs and | ||
| 18 | # syllables as the national standards list them. | ||
| 19 | codes = set(range(0x3000, 0x3100)) | set(range(0x3130, 0x3190)) | set(range(0xff00, 0xfff0)) | ||
| 20 | for code, encodings in [*((code, ('gb2312', 'shift_jis')) for code in range(0x4e00, 0xa000)), | ||
| 21 | *((code, ('euc_kr',)) for code in range(0xac00, 0xd7a4))]: | ||
| 22 | for encoding in encodings: | ||
| 23 | try: | ||
| 24 | chr(code).encode(encoding) | ||
| 25 | except UnicodeEncodeError: | ||
| 26 | continue | ||
| 27 | codes.add(code) | ||
| 28 | break | ||
| 29 | options = subset.Options() | ||
| 30 | options.layout_features = ['*'] | ||
| 31 | options.name_IDs = ['*'] | ||
| 32 | options.notdef_outline = True | ||
| 33 | cut = subset.Subsetter(options) | ||
| 34 | cut.populate(unicodes=codes) | ||
| 35 | cut.subset(font) | ||
| 36 | font.save(out) | ||
| 37 | |||
| 38 | |||
| 39 | if __name__ == '__main__': | ||
| 40 | main(*sys.argv[1:]) | ||