From 309ad5f20a2a5bcb3ed236cb23f75d2d18064f3d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 18:02:34 -0700 Subject: [PATCH] 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 --- Cargo.lock | 11 ++ arc/platforms.md | 13 +- crates/canvas/src/spelling.rs | 8 ++ crates/snowbound/Cargo.toml | 2 + crates/snowbound/src/spell_web.rs | 64 ++++++++- crates/snowbound/src/web.rs | 212 +++++++++++++++++++++++++++++- crates/snowbound/web/glue.js | 75 +++++++++++ crates/snowbound/web/index.html | 16 ++- tools/release_web.py | 35 ++++- tools/web/subset_cjk.py | 40 ++++++ 10 files changed, 455 insertions(+), 21 deletions(-) create mode 100644 tools/web/subset_cjk.py diff --git a/Cargo.lock b/Cargo.lock index 0d33777aff3a96a41613618c304b701e22d727c3..804cc918f836d72f2dec229754e3021070773200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,6 +3528,7 @@ dependencies = [ "serde", "serde_json", "smithay-clipboard", + "spellbook", "ui", "unicode_names2", "ureq", @@ -3555,6 +3556,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spellbook" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a" +dependencies = [ + "foldhash", + "hashbrown 0.17.1", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" diff --git a/arc/platforms.md b/arc/platforms.md index ca8913ce255bb5cadd0ca3873c8dee7102e88d44..c2e64c1e7b34ba5721931390a7643397448cd544 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -168,9 +168,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. it is uncertain, as one whose answer was lost, and it goes again on top of another app's write. The sync popup says the folder isn't locked. - Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the - browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert - ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility - are still to come. + browser keeps its own window and tab chords. Dialogs are the browser's; Insert, and Open + without a folder to give, ask for files to copy in; printing downloads the PDF. Servers and + recording are still to come. +- Spelling is Hunspell's American English dictionary through `spellbook`, fetched beside the + module; Add to Dictionary keeps its words in the browser's files. Text the bundled faces + lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to + the national standards' characters by `tools/web/subset_cjk.py`). +- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden + button, then on every visit) the trees AccessKit would get are mirrored as hidden + elements with ARIA roles; acting on one sends AccessKit's action back. ## iOS: native around the canvas diff --git a/crates/canvas/src/spelling.rs b/crates/canvas/src/spelling.rs index e0ba73c5aba8b3f650cf7962166e150e595f81ad..2d6a7e49a6339e35ee1c739ca9d75661c6bde704 100644 --- a/crates/canvas/src/spelling.rs +++ b/crates/canvas/src/spelling.rs @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, mpsc}; /// Checked paragraphs kept before the cache starts over. const KEPT: usize = 8192; /// Paragraphs waiting to be checked that one call to the dictionary takes. +#[cfg(not(target_arch = "wasm32"))] const BATCH: usize = 64; /// A platform spell checker, called from the spelling thread and the host's. Languages are @@ -327,6 +328,10 @@ impl Spelling { }); let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>(); let worker = Arc::clone(&shared); + // The browser gives a page one thread: paragraphs are checked as they are drawn. + #[cfg(target_arch = "wasm32")] + drop((receiver, worker, redraw)); + #[cfg(not(target_arch = "wasm32"))] std::thread::Builder::new() .name("spelling".into()) .spawn(move || { @@ -353,6 +358,9 @@ impl Spelling { /// The marks on `paragraph` once it is checked, less accepted words; until then none, /// and it is checked. pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec { + if cfg!(target_arch = "wasm32") { + return self.marks_now(paragraph); + } let key = key(paragraph); let mut state = self.shared.state.lock().unwrap(); if let Some(marks) = state.marks(key, paragraph) { diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index be6a38917856789c564d2a362d58359b27a94884..1e3fa9fd109539a188384da44f0273703757fd6b 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -46,6 +46,8 @@ ring = "0.17" [target.'cfg(target_arch = "wasm32")'.dependencies] wgpu = { workspace = true, features = ["webgl"] } getrandom = { version = "0.4", features = ["wasm_js"] } +# Checks spelling with Hunspell dictionaries, as the browser offers pages no checker. +spellbook = "0.4" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" diff --git a/crates/snowbound/src/spell_web.rs b/crates/snowbound/src/spell_web.rs index d2cb30da44c38cb94516abb5682def02ebc90572..ba4a29fade9167ecc02168bc4acd278ded4b6aa1 100644 --- a/crates/snowbound/src/spell_web.rs +++ b/crates/snowbound/src/spell_web.rs @@ -1,8 +1,64 @@ -//! Spelling in the browser, which offers pages no checker to ask: words stay unmarked until -//! Hunspell dictionaries load in wasm. +//! Spelling in the browser, which gives pages no checker to ask: Hunspell's American English +//! dictionary through `spellbook`, which `index.html` fetches beside the module, with the +//! words Add to Dictionary learns kept in the browser's files. -use canvas::spelling::Dictionary; +use canvas::spelling::{Dictionary, pick}; +use std::{io::Write, sync::Mutex}; + +/// The dictionary's files, where `web::start` puts them, and the words learned. +pub const AFFIX: &str = "/Dictionaries/en_US.aff"; +pub const WORDS: &str = "/Dictionaries/en_US.dic"; +const LEARNED: &str = "/Settings/dictionary.txt"; + +struct Checker(Mutex); pub fn dictionary() -> Option> { - None + let affix = notebook::fs::read_to_string(AFFIX).ok()?; + let words = notebook::fs::read_to_string(WORDS).ok()?; + let mut dictionary = spellbook::Dictionary::new(&affix, &words).ok()?; + for word in notebook::fs::read_to_string(LEARNED) + .unwrap_or_default() + .lines() + { + let _ = dictionary.add(word); + } + Some(Box::new(Checker(Mutex::new(dictionary)))) +} + +/// Whether the dictionary serves `language`, an LCID. +fn serves(language: u32) -> bool { + pick(language, &["en_US".to_owned()]).is_some() +} + +impl Dictionary for Checker { + fn misspelled(&self, words: &[(&str, u32)]) -> Vec { + let Ok(dictionary) = self.0.lock() else { + return vec![false; words.len()]; + }; + words + .iter() + .map(|(word, language)| serves(*language) && !dictionary.check(word)) + .collect() + } + + fn suggest(&self, word: &str, language: u32) -> Vec { + let mut suggestions = Vec::new(); + if serves(language) + && let Ok(dictionary) = self.0.lock() + { + dictionary.suggest(word, &mut suggestions); + } + suggestions + } + + fn learn(&self, word: &str) { + if let Ok(mut dictionary) = self.0.lock() { + let _ = dictionary.add(word); + } + let _ = notebook::fs::OpenOptions::new() + .create(true) + .append(true) + .open(LEARNED) + .and_then(|mut file| writeln!(file, "{word}")); + } } diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index d4da589bd3d2051e0447322263cbbe4bf33562ab..5a7bf65dcd3c4ac2d01351bbb3ac6470db7b1d14 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -10,7 +10,9 @@ use std::{ collections::VecDeque, error::Error, marker::PhantomData, + ops::RangeInclusive, path::{Path, PathBuf}, + sync::Arc, time::Duration, }; use ui::Ui; @@ -70,6 +72,12 @@ extern "C" { /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a /// fourth `true`, a section committed under a folder of the user's, written only where /// nothing else wrote the file since it was read (`refreshed` answers). + /// Applies an accessibility tree update to the DOM mirror (`mirrored`). + #[wasm_bindgen(js_name = mirrorTree)] + fn mirror_tree(update: &js_sys::Object); + /// Fetches `fonts/{name}` for `font_arrived`. + #[wasm_bindgen(js_name = fetchFont)] + fn fetch_font(name: &str); #[wasm_bindgen(js_name = storeFiles)] fn store_files(changes: js_sys::Array); } @@ -84,6 +92,41 @@ const SETTINGS: &str = "/Settings"; const FOLDERS: &str = "/Folders"; /// Files chosen to insert, open or paste, kept where the page links them from. const CHOSEN: &str = "/Chosen"; +/// Faces for what the bundled ones lack, beside them in `fonts/`, fetched the first time a +/// page holds a character in their ranges: Noto's, by script. +const FALLBACKS: [(&str, &[RangeInclusive]); 6] = [ + ( + "NotoSansArabic.ttf", + &[ + 0x600..=0x6ff, + 0x750..=0x77f, + 0x8a0..=0x8ff, + 0xfb50..=0xfdff, + 0xfe70..=0xfeff, + ], + ), + ("NotoSansHebrew.ttf", &[0x590..=0x5ff, 0xfb1d..=0xfb4f]), + ("NotoSansDevanagari.ttf", &[0x900..=0x97f, 0xa8e0..=0xa8ff]), + ("NotoSansThai.ttf", &[0xe00..=0xe7f]), + ( + "NotoSansSymbols2-Regular.otf", + &[ + 0x2190..=0x21ff, + 0x2300..=0x23ff, + 0x25a0..=0x27bf, + 0x2b00..=0x2bff, + ], + ), + ( + "NotoSansCJK.otf", + &[ + 0x2e80..=0x9fff, + 0xac00..=0xd7af, + 0xf900..=0xfaff, + 0xff00..=0xffef, + ], + ), +]; /// How long changed files wait to be written out, so a burst of edits writes once. const STORE_AFTER: Duration = Duration::from_millis(500); @@ -123,6 +166,13 @@ thread_local! { static EVENTS: RefCell> = const { RefCell::new(VecDeque::new()) }; /// Work deferred to after the frame, as threads run it elsewhere. static LATER: RefCell>> = const { RefCell::new(Vec::new()) }; + /// The fallback faces asked for, by bit, and the page whose text was last looked through. + static FETCHED: Cell = const { Cell::new(0) }; + static LOOKED: Cell> = const { Cell::new(None) }; + /// Text arrived from the keyboard, input methods or the clipboard since the last look. + static TYPED: Cell = const { Cell::new(false) }; + /// A screen reader asked for the accessibility mirror. + static ACCESSIBLE: Cell = const { Cell::new(false) }; /// Input waits a frame for what a chord opened. static HOLDING: Cell = const { Cell::new(false) }; /// Modifier bits as the page's input last reported them. @@ -268,8 +318,9 @@ impl EventLoopProxy { } } -/// Assistive technology's view of the window, which a DOM mirror takes in a later phase; -/// until then no tree is asked for. +/// Assistive technology's view of the window: a DOM of ARIA roles the glue keeps beside the +/// canvas once a screen reader asks for it (`accessibility`), as AccessKit has no adapter +/// for the web. pub struct AccessAdapter; impl AccessAdapter { @@ -281,7 +332,98 @@ impl AccessAdapter { Self } - pub fn update_if_active(&mut self, _: impl FnOnce() -> accesskit::TreeUpdate) {} + pub fn update_if_active(&mut self, update: impl FnOnce() -> accesskit::TreeUpdate) { + if ACCESSIBLE.get() { + mirror_tree(&mirrored(&update())); + } + } +} + +/// `update` as the glue mirrors it: the tree, its focus and root where it has one, and each +/// node as `[id, role, name, value, children, bounds, grafted tree, disabled, toggled]`. +fn mirrored(update: &accesskit::TreeUpdate) -> js_sys::Object { + let tree = |id: accesskit::TreeId| JsValue::from_str(&id.0.to_string()); + let nodes: js_sys::Array = update + .nodes + .iter() + .map(|(id, node)| { + let children: js_sys::Array = node + .children() + .iter() + .map(|child| JsValue::from_f64(child.0 as f64)) + .collect(); + let bounds = node.bounds().map_or(JsValue::NULL, |rect| { + js_sys::Array::of4( + &rect.x0.into(), + &rect.y0.into(), + &rect.x1.into(), + &rect.y1.into(), + ) + .into() + }); + let text = |text: Option<&str>| text.map_or(JsValue::NULL, JsValue::from_str); + [ + JsValue::from_f64(id.0 as f64), + JsValue::from_str(&format!("{:?}", node.role())), + text(node.label()), + text(node.value()), + children.into(), + bounds, + node.tree_id().map_or(JsValue::NULL, tree), + node.is_disabled().into(), + node.toggled().map_or(JsValue::NULL, |toggled| { + (toggled == accesskit::Toggled::True).into() + }), + ] + .into_iter() + .collect::() + }) + .collect(); + let mirrored = js_sys::Object::new(); + let set = |key: &str, value: &JsValue| { + let _ = js_sys::Reflect::set(&mirrored, &key.into(), value); + }; + set("tree", &tree(update.tree_id)); + set("focus", &JsValue::from_f64(update.focus.0 as f64)); + set( + "root", + &update + .tree + .as_ref() + .map_or(JsValue::NULL, |info| JsValue::from_f64(info.root.0 as f64)), + ); + set("nodes", &nodes); + mirrored +} + +/// A screen reader turned the mirror on, or off: the whole tree is sent afresh. +#[wasm_bindgen] +pub fn accessibility(on: bool) { + ACCESSIBLE.set(on); + send(UserEvent::Then(Box::new(|state| { + state.deactivate_accessibility(); + state.update_accessibility(true) + }))); +} + +/// Assistive technology acted on node `node` of tree `tree`: 0 clicks it, 1 focuses it. +#[wasm_bindgen] +pub fn access(tree: String, node: f64, action: u8) { + let Ok(tree) = tree.parse() else { + return; + }; + let request = accesskit::ActionRequest { + action: match action { + 1 => accesskit::Action::Focus, + _ => accesskit::Action::Click, + }, + target_tree: accesskit::TreeId(tree), + target_node: accesskit::NodeId(node as u64), + data: None, + }; + send(UserEvent::Then(Box::new(move |state| { + state.access_action(request) + }))); } /// The browser's clipboard: text out through `navigator.clipboard`, and in through the page's @@ -670,10 +812,15 @@ pub mod smb { } } -/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts`. `module` is -/// the module's own exports, which the glue calls. +/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts` and, where it +/// could, the spelling dictionary's affix and word files. `module` is the module's own +/// exports, which the glue calls. #[wasm_bindgen] -pub async fn start(module: JsValue, fonts: Vec) -> Result<(), JsValue> { +pub async fn start( + module: JsValue, + fonts: Vec, + dictionary: Vec, +) -> Result<(), JsValue> { std::panic::set_hook(Box::new(|info| report(info))); let window = web_sys::window().ok_or("No window")?; let navigator = window.navigator(); @@ -701,6 +848,12 @@ pub async fn start(module: JsValue, fonts: Vec) -> Result<() canvas.set_width(host(|host| host.size.width)); canvas.set_height(host(|host| host.size.height)); restore(load_files().await?); + if let [affix, words] = dictionary.as_slice() { + notebook::fs::restore("/Dictionaries", notebook::fs::Saved::Directory); + for (path, file) in [(crate::spell::AFFIX, affix), (crate::spell::WORDS, words)] { + notebook::fs::restore(path, notebook::fs::Saved::File(file.to_vec(), 0.0)); + } + } for folder in load_folders().await?.iter() { let folder = js_sys::Array::from(&folder); 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) { #[wasm_bindgen] pub fn key(key: &str, text: Option, held: u8) { follow_modifiers(held); + TYPED.set(TYPED.get() || text.is_some()); queue(Input::Ui(ui::Event::Key { key: logical_key(key), text, @@ -915,6 +1069,7 @@ pub fn compose(text: String) { #[wasm_bindgen] pub fn commit(text: String) { + TYPED.set(true); queue(Input::Ui(ui::Event::Ime(Ime::Commit(text)))); } @@ -922,6 +1077,7 @@ pub fn commit(text: String) { #[wasm_bindgen] pub fn paste(text: Option, html: Option, files: js_sys::Array) { let files = keep(files, CHOSEN); + TYPED.set(true); PASTED.with_borrow_mut(|pasted| *pasted = Pasted { text, html, files }); send(UserEvent::Choose(commands::Choice::Command( commands::Id::Paste, @@ -1136,6 +1292,10 @@ fn turn(state: &mut State) -> Result<(), Box> { if host(|host| std::mem::take(&mut host.redraw)) { state.frame()?; } + let space = state.session.as_ref().map(|session| session.space); + if TYPED.take() || LOOKED.replace(space) != space { + fetch_fallbacks(&crate::page_text(&state.view.editor)); + } park_input(state); let now = Instant::now(); let (repaint, blink) = state.view.blink(now); @@ -1159,6 +1319,46 @@ fn turn(state: &mut State) -> Result<(), Box> { Ok(()) } +/// Fetches the fallback faces `text` calls for that are not yet asked for. +fn fetch_fallbacks(text: &str) { + for character in text.chars().map(u32::from) { + for (index, (name, ranges)) in FALLBACKS.iter().enumerate() { + if FETCHED.get() & (1 << index) == 0 + && ranges.iter().any(|range| range.contains(&character)) + { + FETCHED.set(FETCHED.get() | 1 << index); + fetch_font(name); + } + } + } +} + +/// A fallback face arrived: the page, its loaders and the interface draw what the bundled +/// faces lack with it, and the page is laid out again. +#[wasm_bindgen] +pub fn font_arrived(bytes: Vec) { + let face = parley::fontique::Blob::new(Arc::new(bytes)); + send(UserEvent::Then(Box::new(move |state| { + let collection = &mut state.view.engine.fonts.collection; + let families: Vec = collection + .register_fonts(face.clone(), None) + .into_iter() + .filter_map(|(family, _)| collection.family_name(family).map(String::from)) + .collect(); + draw::fall_back_to(collection, &families); + if let Ok(mut layouts) = state.layouts.lock() { + layouts.fonts.collection.register_fonts(face.clone(), None); + draw::fall_back_to(&mut layouts.fonts.collection, &families); + } + for family in &families { + state.ui.preview_font(face.clone(), family); + } + state.ui.fall_back_to(&families); + state.renderer.clear_glyph_cache(); + state.refresh() + }))); +} + /// Parks the text area while the page takes no text, keeping it writable for the /// interface's fields. fn park_input(state: &State) { diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 958970ba34d7e27075ef2a70e64268af64f2ec78..5f5b1f822775cb705faae0bfb77ede8f4f6f8965 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -287,6 +287,13 @@ export function pickNotebook() { .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); } +export function fetchFont(name) { + fetch(`fonts/${name}`) + .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status))) + .then((data) => wasm.font_arrived(new Uint8Array(data))) + .catch((error) => console.warn("Font", name, error)); +} + export function requestFrame() { if (!framePending) { framePending = true; @@ -371,6 +378,73 @@ export function openLink(url) { open(url, "_blank", "noopener"); } +// Assistive technology's view of the window: AccessKit's trees mirrored as elements with +// ARIA roles, visually hidden, which a screen reader reads and acts on. +const ROLES = { + Button: "button", DefaultButton: "button", CheckBox: "checkbox", RadioButton: "radio", + Switch: "switch", TextInput: "textbox", MultilineTextInput: "textbox", SearchInput: "searchbox", + Document: "document", Group: "group", GenericContainer: "generic", Label: "none", + Paragraph: "paragraph", List: "list", ListItem: "listitem", ListBox: "listbox", + ListBoxOption: "option", Menu: "menu", MenuBar: "menubar", MenuItem: "menuitem", + MenuItemCheckBox: "menuitemcheckbox", MenuItemRadio: "menuitemradio", Tab: "tab", + TabList: "tablist", TabPanel: "tabpanel", Toolbar: "toolbar", Tooltip: "tooltip", + Tree: "tree", TreeItem: "treeitem", Dialog: "dialog", AlertDialog: "alertdialog", + Image: "img", Link: "link", Heading: "heading", Table: "table", Row: "row", Cell: "cell", + ColumnHeader: "columnheader", ComboBox: "combobox", ScrollBar: "scrollbar", Slider: "slider", + Window: "application", Pane: "region", Separator: "separator", Status: "status", +}; +const mirrored = new Map(); +export function mirrorTree({ tree, focus, root, nodes }) { + if (!mirrored.has(tree)) mirrored.set(tree, { nodes: new Map(), root: null }); + const held = mirrored.get(tree); + const element = (id) => { + if (!held.nodes.has(id)) { + const made = document.createElement("div"); + made.id = `a11y-${tree}-${id}`; + made.onclick = () => wasm.access(tree, id, 0); + held.nodes.set(id, made); + } + return held.nodes.get(id); + }; + for (const [id, role, name, value, children, , grafted, disabled, toggled] of nodes) { + const node = element(id); + const aria = ROLES[role]; + if (aria && aria !== "none") node.setAttribute("role", aria); + else node.removeAttribute("role"); + const text = role === "Label" || role === "TextRun" || role === "StaticText"; + if (name && !text) node.setAttribute("aria-label", name); + else node.removeAttribute("aria-label"); + node.toggleAttribute("aria-disabled", disabled); + if (toggled === null) node.removeAttribute("aria-checked"); + else node.setAttribute("aria-checked", String(toggled)); + const kids = children.map(element); + if (grafted && mirrored.get(grafted)?.root) kids.push(mirrored.get(grafted).root); + if (text && !kids.length) node.textContent = value ?? name ?? ""; + else node.replaceChildren(...kids); + if (grafted) node.dataset.grafted = grafted; + } + if (root !== null) { + held.root = element(root); + if (tree === "00000000-0000-0000-0000-000000000000") + document.getElementById("a11y").replaceChildren(held.root); + else + for (const { nodes: all } of mirrored.values()) + for (const node of all.values()) if (node.dataset.grafted === tree) node.append(held.root); + } + if (held.nodes.has(focus)) input.setAttribute("aria-activedescendant", held.nodes.get(focus).id); +} + +/** Turns the mirror on: once a screen reader asks for it, and on every visit after. */ +function offerAccessibility() { + const on = () => { + localStorage.setItem("snowbound-accessibility", "on"); + document.getElementById("accessible").remove(); + wasm.accessibility(true); + }; + document.getElementById("accessible").onclick = on; + if (localStorage.getItem("snowbound-accessibility") === "on") on(); +} + function modifiers(event) { return ( (event.shiftKey ? 1 : 0) | @@ -547,5 +621,6 @@ export function attach(module) { Math.round(entry.contentRect.height * devicePixelRatio), ), ).observe(canvas); + offerAccessibility(); input.focus({ preventScroll: true }); } diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index aeba9b93f647ac608a4a9146266c03b5d14bff3f..0efd2211509a955aa0396d1c0adcb7239597bb27 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -14,6 +14,11 @@ opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px; caret-color: transparent; pointer-events: none; } + /* Read by screen readers, not seen. */ + .hidden { + position: fixed; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; + clip-path: inset(50%); white-space: nowrap; + } .reconnect { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); font: 14px system-ui, sans-serif; padding: 6px 14px; @@ -25,7 +30,9 @@ + +
Loading Snowbound…
@@ -65,11 +72,14 @@ throw new Error("Snowbound is open in another tab"); } try { - const [, fonts] = await Promise.all([ + const fetched = (url) => bytes(url).then((data) => new Uint8Array(data)); + const [, fonts, dictionary] = await Promise.all([ init({ module_or_path: module }), - Promise.all(FONTS.map((name) => bytes(`fonts/${name}.ttf`).then((data) => new Uint8Array(data)))), + Promise.all(FONTS.map((name) => fetched(`fonts/${name}.ttf`))), + // Spelling waits for none: without the dictionary, words go unmarked. + Promise.all(["aff", "dic"].map((kind) => fetched(`dictionaries/en_US.${kind}`))).catch(() => []), ]); - await snowbound.start(snowbound, fonts); + await snowbound.start(snowbound, fonts, dictionary); status.remove(); console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`); } catch (error) { diff --git a/tools/release_web.py b/tools/release_web.py index 8a71104bbc4b5ab948d12e361a5606baaf292c11..faca19e7b9cc4091c1850a7e323125b941761df5 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -31,16 +31,17 @@ def run(command, **kwargs): subprocess.run(command, cwd=ROOT, check=True, **kwargs) -def tool(name, nix=None, wasm=False): +def tool(name, nix=None, wasm=False, store=False): """`name` from PATH or Cargo's bin folder, or else from nixpkgs package `nix`; with `wasm`, - from nixpkgs only, as the system's clang may not build for wasm32.""" - found = None if wasm else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) + from nixpkgs only, as the system's clang may not build for wasm32; with `store`, the + package's folder.""" + found = None if wasm or store else shutil.which(name) or shutil.which(name, path=str(Path.home() / '.cargo/bin')) if found: return found if nix and shutil.which('nix'): - store = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], + built = subprocess.check_output(['nix', 'build', f'nixpkgs#{nix}', '--no-link', '--print-out-paths'], text=True).split()[-1] - return str(Path(store) / 'bin' / name) + return built if store else str(Path(built) / 'bin' / name) sys.exit(f'{name} is missing: cargo install wasm-bindgen-cli at the version Cargo.lock pins, ' 'and binaryen for wasm-opt') @@ -68,15 +69,39 @@ def build(out): bound = out / 'snowbound_web_bg.wasm' run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) shutil.copy(WEB / 'index.html', out) + # Hunspell's American English dictionary (SCOWL, BSD-3-Clause), for spelling. + (out / 'dictionaries').mkdir() + store = Path(tool('hunspell', 'hunspellDicts.en_US', store=True)) + for name in ('share/hunspell/en_US.aff', 'share/hunspell/en_US.dic', 'share/doc/hunspell-dict-en-us-wordlist.txt'): + shutil.copy(store / name, out / 'dictionaries') (out / 'fonts').mkdir() for font in sorted(FONTS.glob('*')): if font.suffix in ('.ttf', '.txt'): shutil.copy(font, out / 'fonts') + fallbacks(out / 'fonts') for path in sorted(out.rglob('*')): if path.is_file(): print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') +def fallbacks(fonts): + """Noto's faces for scripts the bundled ones lack, which the page fetches as it needs them + (`FALLBACKS` in src/web.rs), under the SIL Open Font License.""" + noto = Path(tool('noto', 'noto-fonts', store=True)) / 'share/fonts/noto' + for name in ('NotoSansArabic.ttf', 'NotoSansHebrew.ttf', 'NotoSansDevanagari.ttf', 'NotoSansThai.ttf', + 'NotoSansSymbols2-Regular.otf'): + shutil.copy(noto / name, fonts) + # Cutting the CJK face takes a minute or two, so the cut is kept between builds. + cjk = TARGET / 'NotoSansCJK.otf' + if not cjk.exists(): + collection = Path(tool('noto', 'noto-fonts-cjk-sans', store=True)) / \ + 'share/fonts/opentype/noto-cjk/NotoSansCJK-VF.otf.ttc' + run(['uv', 'run', '--no-project', '--with', 'fonttools', 'python', ROOT / 'tools/web/subset_cjk.py', + collection, cjk]) + shutil.copy(cjk, fonts) + (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') + + def publish(built): """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" if not PUBLISHED.parent.is_dir(): diff --git a/tools/web/subset_cjk.py b/tools/web/subset_cjk.py new file mode 100644 index 0000000000000000000000000000000000000000..ea1dfc4c7ff46a98bed6957cf9cbb483f39daec0 --- /dev/null +++ b/tools/web/subset_cjk.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Writes Noto Sans CJK SC at its regular weight, cut to the characters GB 2312, JIS X 0208 +and KS X 1001's Hangul hold, as the web build's fallback for Chinese, Japanese and Korean. + + uv run --with fonttools python tools/web/subset_cjk.py NotoSansCJK-VF.otf.ttc OUT.otf +""" +import sys + +from fontTools import subset +from fontTools.ttLib import TTCollection +from fontTools.varLib import instancer + + +def main(collection, out): + font = next(face for face in TTCollection(collection).fonts if face['name'].getDebugName(1) == 'Noto Sans CJK SC') + font = instancer.instantiateVariableFont(font, {'wght': 400}) + # Punctuation, kana, Hangul compatibility jamo and full-width forms whole; ideographs and + # syllables as the national standards list them. + codes = set(range(0x3000, 0x3100)) | set(range(0x3130, 0x3190)) | set(range(0xff00, 0xfff0)) + for code, encodings in [*((code, ('gb2312', 'shift_jis')) for code in range(0x4e00, 0xa000)), + *((code, ('euc_kr',)) for code in range(0xac00, 0xd7a4))]: + for encoding in encodings: + try: + chr(code).encode(encoding) + except UnicodeEncodeError: + continue + codes.add(code) + break + options = subset.Options() + options.layout_features = ['*'] + options.name_IDs = ['*'] + options.notdef_outline = True + cut = subset.Subsetter(options) + cut.populate(unicodes=codes) + cut.subset(font) + font.save(out) + + +if __name__ == '__main__': + main(*sys.argv[1:]) -- 2.54.0