authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 18:02:34-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 19:43:20-07:00
log309ad5f20a2a5bcb3ed236cb23f75d2d18064f3d
tree194422846523197098dd02d94b36294b0d53622d
parentce8ac92ba35e5cac71d188d311c0c2c241541692
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the web build checks spelling, reads to screen readers and draws every script

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.5

10 files changed, 455 insertions(+), 21 deletions(-)

Cargo.lock+11
......@@ -3528,6 +3528,7 @@ dependencies = [
35283528 "serde",
35293529 "serde_json",
35303530 "smithay-clipboard",
3531 "spellbook",
35313532 "ui",
35323533 "unicode_names2",
35333534 "ureq",
......@@ -3555,6 +3556,16 @@ dependencies = [
35553556 "windows-sys 0.61.2",
35563557]
35573558
3559[[package]]
3560name = "spellbook"
3561version = "0.4.2"
3562source = "registry+https://github.com/rust-lang/crates.io-index"
3563checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
3564dependencies = [
3565 "foldhash",
3566 "hashbrown 0.17.1",
3567]
3568
35583569[[package]]
35593570name = "spirv"
35603571version = "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.
168168 it is uncertain, as one whose answer was lost, and it goes again on top of another app's
169169 write. The sync popup says the folder isn't locked.
170170- 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
172 ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility
173 are still to come.
171 browser keeps its own window and tab chords. Dialogs are the browser's; Insert, and Open
172 without a folder to give, ask for files to copy in; printing downloads the PDF. Servers and
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.
174181
175182## iOS: native around the canvas
176183
crates/canvas/src/spelling.rs+8
......@@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, mpsc};
1313/// Checked paragraphs kept before the cache starts over.
1414const KEPT: usize = 8192;
1515/// Paragraphs waiting to be checked that one call to the dictionary takes.
16#[cfg(not(target_arch = "wasm32"))]
1617const BATCH: usize = 64;
1718
1819/// A platform spell checker, called from the spelling thread and the host's. Languages are
......@@ -327,6 +328,10 @@ impl Spelling {
327328 });
328329 let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>();
329330 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"))]
330335 std::thread::Builder::new()
331336 .name("spelling".into())
332337 .spawn(move || {
......@@ -353,6 +358,9 @@ impl Spelling {
353358 /// The marks on `paragraph` once it is checked, less accepted words; until then none,
354359 /// and it is checked.
355360 pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec<Mark> {
361 if cfg!(target_arch = "wasm32") {
362 return self.marks_now(paragraph);
363 }
356364 let key = key(paragraph);
357365 let mut state = self.shared.state.lock().unwrap();
358366 if let Some(marks) = state.marks(key, paragraph) {
crates/snowbound/Cargo.toml+2
......@@ -46,6 +46,8 @@ ring = "0.17"
4646[target.'cfg(target_arch = "wasm32")'.dependencies]
4747wgpu = { workspace = true, features = ["webgl"] }
4848getrandom = { version = "0.4", features = ["wasm_js"] }
49# Checks spelling with Hunspell dictionaries, as the browser offers pages no checker.
50spellbook = "0.4"
4951wasm-bindgen = "0.2"
5052wasm-bindgen-futures = "0.4"
5153js-sys = "0.3"
crates/snowbound/src/spell_web.rs+60-4
......@@ -1,8 +1,64 @@
1//! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until
2//! Hunspell dictionaries load in wasm.
1//! Spelling in the browser, which gives pages no checker to ask: Hunspell's American English
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.
34
4use canvas::spelling::Dictionary;
5use canvas::spelling::{Dictionary, pick};
6use std::{io::Write, sync::Mutex};
7
8/// The dictionary's files, where `web::start` puts them, and the words learned.
9pub const AFFIX: &str = "/Dictionaries/en_US.aff";
10pub const WORDS: &str = "/Dictionaries/en_US.dic";
11const LEARNED: &str = "/Settings/dictionary.txt";
12
13struct Checker(Mutex<spellbook::Dictionary>);
514
615pub 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.
29fn serves(language: u32) -> bool {
30 pick(language, &["en_US".to_owned()]).is_some()
31}
32
33impl 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 }
864}
crates/snowbound/src/web.rs+206-6
......@@ -10,7 +10,9 @@ use std::{
1010 collections::VecDeque,
1111 error::Error,
1212 marker::PhantomData,
13 ops::RangeInclusive,
1314 path::{Path, PathBuf},
15 sync::Arc,
1416 time::Duration,
1517};
1618use ui::Ui;
......@@ -70,6 +72,12 @@ extern "C" {
7072 /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a
7173 /// fourth `true`, a section committed under a folder of the user's, written only where
7274 /// 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);
7381 #[wasm_bindgen(js_name = storeFiles)]
7482 fn store_files(changes: js_sys::Array);
7583}
......@@ -84,6 +92,41 @@ const SETTINGS: &str = "/Settings";
8492const FOLDERS: &str = "/Folders";
8593/// Files chosen to insert, open or paste, kept where the page links them from.
8694const 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.
97const 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];
87130/// How long changed files wait to be written out, so a burst of edits writes once.
88131const STORE_AFTER: Duration = Duration::from_millis(500);
89132
......@@ -123,6 +166,13 @@ thread_local! {
123166 static EVENTS: RefCell<VecDeque<UserEvent>> = const { RefCell::new(VecDeque::new()) };
124167 /// Work deferred to after the frame, as threads run it elsewhere.
125168 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) };
126176 /// Input waits a frame for what a chord opened.
127177 static HOLDING: Cell<bool> = const { Cell::new(false) };
128178 /// Modifier bits as the page's input last reported them.
......@@ -268,8 +318,9 @@ impl EventLoopProxy<UserEvent> {
268318 }
269319}
270320
271/// Assistive technology's view of the window, which a DOM mirror takes in a later phase;
272/// until then no tree is asked for.
321/// Assistive technology's view of the window: a DOM of ARIA roles the glue keeps beside the
322/// canvas once a screen reader asks for it (`accessibility`), as AccessKit has no adapter
323/// for the web.
273324pub struct AccessAdapter;
274325
275326impl AccessAdapter {
......@@ -281,7 +332,98 @@ impl AccessAdapter {
281332 Self
282333 }
283334
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]`.
344fn 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]
401pub 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]
411pub 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 })));
285427}
286428
287429/// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's
......@@ -670,10 +812,15 @@ pub mod smb {
670812 }
671813}
672814
673/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is
674/// the module's own exports, which the glue calls.
815/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts` and, where it
816/// could, the spelling dictionary's affix and word files. `module` is the module's own
817/// exports, which the glue calls.
675818#[wasm_bindgen]
676pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
819pub async fn start(
820 module: JsValue,
821 fonts: Vec<js_sys::Uint8Array>,
822 dictionary: Vec<js_sys::Uint8Array>,
823) -> Result<(), JsValue> {
677824 std::panic::set_hook(Box::new(|info| report(info)));
678825 let window = web_sys::window().ok_or("No window")?;
679826 let navigator = window.navigator();
......@@ -701,6 +848,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<()
701848 canvas.set_width(host(|host| host.size.width));
702849 canvas.set_height(host(|host| host.size.height));
703850 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 }
704857 for folder in load_folders().await?.iter() {
705858 let folder = js_sys::Array::from(&folder);
706859 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) {
8931046#[wasm_bindgen]
8941047pub fn key(key: &str, text: Option<String>, held: u8) {
8951048 follow_modifiers(held);
1049 TYPED.set(TYPED.get() || text.is_some());
8961050 queue(Input::Ui(ui::Event::Key {
8971051 key: logical_key(key),
8981052 text,
......@@ -915,6 +1069,7 @@ pub fn compose(text: String) {
9151069
9161070#[wasm_bindgen]
9171071pub fn commit(text: String) {
1072 TYPED.set(true);
9181073 queue(Input::Ui(ui::Event::Ime(Ime::Commit(text))));
9191074}
9201075
......@@ -922,6 +1077,7 @@ pub fn commit(text: String) {
9221077#[wasm_bindgen]
9231078pub fn paste(text: Option<String>, html: Option<String>, files: js_sys::Array) {
9241079 let files = keep(files, CHOSEN);
1080 TYPED.set(true);
9251081 PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files });
9261082 send(UserEvent::Choose(commands::Choice::Command(
9271083 commands::Id::Paste,
......@@ -1136,6 +1292,10 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
11361292 if host(|host| std::mem::take(&mut host.redraw)) {
11371293 state.frame()?;
11381294 }
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 }
11391299 park_input(state);
11401300 let now = Instant::now();
11411301 let (repaint, blink) = state.view.blink(now);
......@@ -1159,6 +1319,46 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
11591319 Ok(())
11601320}
11611321
1322/// Fetches the fallback faces `text` calls for that are not yet asked for.
1323fn 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]
1339pub 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
11621362/// Parks the text area while the page takes no text, keeping it writable for the
11631363/// interface's fields.
11641364fn park_input(state: &State) {
crates/snowbound/web/glue.js+75
......@@ -287,6 +287,13 @@ export function pickNotebook() {
287287 .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error));
288288}
289289
290export 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
290297export function requestFrame() {
291298 if (!framePending) {
292299 framePending = true;
......@@ -371,6 +378,73 @@ export function openLink(url) {
371378 open(url, "_blank", "noopener");
372379}
373380
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.
383const 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};
396const mirrored = new Map();
397export 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. */
438function 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
374448function modifiers(event) {
375449 return (
376450 (event.shiftKey ? 1 : 0) |
......@@ -547,5 +621,6 @@ export function attach(module) {
547621 Math.round(entry.contentRect.height * devicePixelRatio),
548622 ),
549623 ).observe(canvas);
624 offerAccessibility();
550625 input.focus({ preventScroll: true });
551626}
crates/snowbound/web/index.html+13-3
......@@ -14,6 +14,11 @@
1414 opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px;
1515 caret-color: transparent; pointer-events: none;
1616 }
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 }
1722 .reconnect {
1823 position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
1924 font: 14px system-ui, sans-serif; padding: 6px 14px;
......@@ -25,7 +30,9 @@
2530</style>
2631</head>
2732<body>
33<button id="accessible" class="hidden">Turn on screen reader support</button>
2834<canvas id="page"></canvas>
35<div id="a11y" class="hidden"></div>
2936<textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea>
3037<input id="files" type="file" hidden>
3138<div id="status">Loading Snowbound…</div>
......@@ -65,11 +72,14 @@
6572 throw new Error("Snowbound is open in another tab");
6673 }
6774 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([
6977 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(() => []),
7181 ]);
72 await snowbound.start(snowbound, fonts);
82 await snowbound.start(snowbound, fonts, dictionary);
7383 status.remove();
7484 console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`);
7585 } catch (error) {
tools/release_web.py+30-5
......@@ -31,16 +31,17 @@ def run(command, **kwargs):
3131 subprocess.run(command, cwd=ROOT, check=True, **kwargs)
3232
3333
34def tool(name, nix=None, wasm=False):
34def tool(name, nix=None, wasm=False, store=False):
3535 """`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."""
37 found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin'))
36 from nixpkgs only, as the system's clang may not build for wasm32; with `store`, the
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'))
3839 if found:
3940 return found
4041 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'],
4243 text=True).split()[-1]
43 return str(Path(store) / 'bin' / name)
44 return built if store else str(Path(built) / 'bin' / name)
4445 sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, '
4546 'and binaryen for wasm-opt')
4647
......@@ -68,15 +69,39 @@ def build(out):
6869 bound = out / 'snowbound_web_bg.wasm'
6970 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])
7071 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')
7177 (out / 'fonts').mkdir()
7278 for font in sorted(FONTS.glob('*')):
7379 if font.suffix in ('.ttf', '.txt'):
7480 shutil.copy(font, out / 'fonts')
81 fallbacks(out / 'fonts')
7582 for path in sorted(out.rglob('*')):
7683 if path.is_file():
7784 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')
7885
7986
87def 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
80105def publish(built):
81106 """Replaces the share's web/ folder with `built`, leaving every other folder alone."""
82107 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
3and 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"""
7import sys
8
9from fontTools import subset
10from fontTools.ttLib import TTCollection
11from fontTools.varLib import instancer
12
13
14def 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
39if __name__ == '__main__':
40 main(*sys.argv[1:])