From f837389ba02268ce5ee180e09bba96a337be406b Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 12:09:48 -0700 Subject: [PATCH] feat: the web build checks spelling in ten more languages (#64) Dictionaries are fetched the first time a word in their language is checked, and text no run tags counts as the browser's language. The site offers en_US, en_GB, de_DE, fr_FR, es_ES, pt_BR, pt_PT, it_IT, nl_NL, sv_SE and ru_RU from nixpkgs' Hunspell packages, converted to UTF-8 and gzipped, each with its readme and its licences' texts; a row in release_web.py's DICTIONARIES adds another. A bare pt picks pt_BR, as Windows does. Assisted-by: claude-opus-5.5 --- arc/platforms.md | 5 +- crates/canvas/src/spelling.rs | 83 +++++++++++++++++++++++---- crates/snowbound/src/spell_web.rs | 94 ++++++++++++++++++++----------- crates/snowbound/src/web.rs | 36 ++++++++---- crates/snowbound/web/glue.js | 26 ++++++--- crates/snowbound/web/index.html | 9 +-- tools/release_web.py | 53 +++++++++++++++-- 7 files changed, 233 insertions(+), 73 deletions(-) diff --git a/arc/platforms.md b/arc/platforms.md index 53b8ba79bffac25d3831bee378976397079cf180..a13355358700b6fd3a8826894175c90280496285 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -177,8 +177,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. 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 +- Spelling is Hunspell's dictionaries through `spellbook`, each fetched beside the module the + first time a word in its language is checked, text no run tags counting as the browser's + language; 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`; emoji in Noto Color Emoji's COLRv1 outlines, which `draw` paints itself), and the page is laid out again. diff --git a/crates/canvas/src/spelling.rs b/crates/canvas/src/spelling.rs index 2d6a7e49a6339e35ee1c739ca9d75661c6bde704..88fdec38807c6b625dce28f185e5ba8214b4a429 100644 --- a/crates/canvas/src/spelling.rs +++ b/crates/canvas/src/spelling.rs @@ -8,6 +8,7 @@ use onestore::page::text::Paragraph; use std::collections::{HashMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Range; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex, mpsc}; /// Checked paragraphs kept before the cache starts over. @@ -36,6 +37,7 @@ pub fn pick(language: u32, available: &[String]) -> Option<&str> { // A bare tag is the locale Windows picks, most often the language's own country. let home = match bare { "en" => "US".to_owned(), + "pt" => "BR".to_owned(), _ => bare.to_uppercase(), }; let wanted = if region.is_empty() { @@ -70,7 +72,8 @@ pub(crate) struct Mark { /// A word of a paragraph: its bytes, its language, and whether its spelling is checked. struct Word { range: Range, - language: u32, + /// The run's language, an LCID; `None` where untagged. + language: Option, checked: bool, } @@ -157,9 +160,7 @@ fn words(paragraph: &Paragraph) -> Vec { } words.push(Word { range: at + offset..at + end, - language: format(at + offset) - .language - .unwrap_or(crate::language::EN_US), + language: format(at + offset).language, checked: word.chars().any(char::is_lowercase), }); } @@ -203,7 +204,8 @@ fn repeated(text: &str, words: &[Word]) -> Vec { /// The marks `dictionary` gives each of `paragraphs`, asking it once: each word repeating /// the one before it, and each other checked word it finds misspelled. -fn check(paragraphs: &[&Paragraph], dictionary: &dyn Dictionary) -> Vec> { +/// Checks `paragraphs`, reading text no run tags as language `untagged`. +fn check(paragraphs: &[&Paragraph], dictionary: &dyn Dictionary, untagged: u32) -> Vec> { let words: Vec<(Vec, Vec)> = paragraphs .iter() .map(|paragraph| { @@ -220,7 +222,10 @@ fn check(paragraphs: &[&Paragraph], dictionary: &dyn Dictionary) -> Vec, + /// The language text no run tags is checked in, an LCID: US English, as OneNote's. + untagged: AtomicU32, state: Mutex, } @@ -324,6 +331,7 @@ impl Spelling { pub fn new(dictionary: Box, redraw: std::task::Waker) -> Self { let shared = Arc::new(Shared { dictionary, + untagged: AtomicU32::new(crate::language::EN_US), state: Mutex::default(), }); let (jobs, receiver) = mpsc::channel::<(u64, Paragraph)>(); @@ -341,7 +349,8 @@ impl Spelling { .collect(); let paragraphs: Vec<&Paragraph> = jobs.iter().map(|(_, paragraph)| paragraph).collect(); - let marks = check(¶graphs, &*worker.dictionary); + let untagged = worker.untagged.load(Ordering::Relaxed); + let marks = check(¶graphs, &*worker.dictionary, untagged); let mut state = worker.state.lock().unwrap(); for ((key, paragraph), marks) in jobs.iter().zip(marks) { state.pending.remove(key); @@ -379,17 +388,30 @@ impl Spelling { if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) { return marks; } - let marks = check(&[paragraph], &*self.shared.dictionary).remove(0); + let untagged = self.shared.untagged.load(Ordering::Relaxed); + let marks = check(&[paragraph], &*self.shared.dictionary, untagged).remove(0); let mut state = self.shared.state.lock().unwrap(); state.keep(key, paragraph, marks); state.marks(key, paragraph).unwrap_or_default() } + /// Checks every paragraph again when next asked, as after a dictionary arrived. + pub fn recheck(&self) { + self.shared.state.lock().unwrap().checked.clear(); + } + + /// Checks text no run tags in `language`, an LCID, as a browser's text is in its own. + pub fn untagged(&self, language: u32) { + self.shared.untagged.store(language, Ordering::Relaxed); + self.recheck(); + } + /// Corrections for `word` in `language`, best first. pub(crate) fn suggest(&self, word: &str, language: Option) -> Vec { - self.shared - .dictionary - .suggest(word, language.unwrap_or(crate::language::EN_US)) + self.shared.dictionary.suggest( + word, + language.unwrap_or(self.shared.untagged.load(Ordering::Relaxed)), + ) } /// Ignore: leaves `word` unmarked everywhere until the app quits. @@ -470,10 +492,13 @@ pub(crate) mod tests { let hunspell: Vec = ["en_AU", "en_US", "fr_FR"].map(String::from).into(); assert_eq!(pick(1033, &hunspell), Some("en_US")); assert_eq!(pick(1036, &hunspell), Some("fr_FR")); + let portuguese: Vec = ["pt_PT", "pt_BR"].map(String::from).into(); + assert_eq!(pick(1046, &portuguese), Some("pt_BR")); + assert_eq!(pick(2070, &portuguese), Some("pt_PT")); } fn marked(paragraph: &Paragraph) -> Vec<(&str, bool)> { - check(&[paragraph], &Fake) + check(&[paragraph], &Fake, crate::language::EN_US) .remove(0) .into_iter() .map(|mark| (¶graph.text()[mark.range], mark.repeated)) @@ -583,4 +608,38 @@ pub(crate) mod tests { .expect("the spelling thread wakes the host"); assert_eq!(spelling.marks(&french).len(), 2); } + + #[test] + fn arriving_dictionaries_and_untagged_languages_check_paragraphs_again() { + struct Late(Arc); + impl Dictionary for Late { + fn misspelled(&self, words: &[(&str, u32)]) -> Vec { + match self.0.load(std::sync::atomic::Ordering::Relaxed) { + true => Fake.misspelled(words), + false => vec![false; words.len()], + } + } + fn suggest(&self, _: &str, _: u32) -> Vec { + Vec::new() + } + fn learn(&self, _: &str) {} + } + let arrived = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let spelling = Spelling::new( + Box::new(Late(Arc::clone(&arrived))), + std::task::Waker::noop().clone(), + ); + let paragraph = Paragraph::new("Ths sentense here".into(), Format::default()); + assert!(spelling.marks_now(¶graph).is_empty()); + arrived.store(true, std::sync::atomic::Ordering::Relaxed); + assert!( + spelling.marks_now(¶graph).is_empty(), + "kept until asked again" + ); + spelling.recheck(); + assert_eq!(spelling.marks_now(¶graph).len(), 2); + // Untagged text is US English until the host says otherwise. + spelling.untagged(1036); + assert_eq!(spelling.marks_now(¶graph).len(), 3); + } } diff --git a/crates/snowbound/src/spell_web.rs b/crates/snowbound/src/spell_web.rs index ba4a29fade9167ecc02168bc4acd278ded4b6aa1..336c50a2f0b027487e3170537b66a0f7b615898d 100644 --- a/crates/snowbound/src/spell_web.rs +++ b/crates/snowbound/src/spell_web.rs @@ -1,59 +1,89 @@ -//! 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. +//! Spelling in the browser, which gives pages no checker to ask: Hunspell dictionaries through +//! `spellbook`, each fetched beside the module the first time a word in its language is +//! checked, with the words Add to Dictionary learns kept in the browser's files. use canvas::spelling::{Dictionary, pick}; -use std::{io::Write, sync::Mutex}; +use std::{collections::BTreeMap, 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); +/// The dictionaries the site has, as `en_US`, which `platform::start` lists. +static AVAILABLE: Mutex> = Mutex::new(Vec::new()); +/// The dictionaries asked for, by name: `None` until one arrives, or where it failed to. +static LOADED: Mutex>> = Mutex::new(BTreeMap::new()); +struct Checker; + +/// The checker, where the site has dictionaries. pub fn dictionary() -> Option> { - 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); + let available = AVAILABLE.lock().ok()?; + (!available.is_empty()).then(|| Box::new(Checker) as Box) +} + +/// Lists the dictionaries the site has. +pub fn offer(names: Vec) { + if let Ok(mut available) = AVAILABLE.lock() { + *available = names; + } +} + +/// Dictionary `name` arrived as its affix and word files. +pub fn arrived(name: &str, affix: &str, words: &str) { + let dictionary = spellbook::Dictionary::new(affix, words) + .ok() + .map(|mut dictionary| { + for word in notebook::fs::read_to_string(LEARNED) + .unwrap_or_default() + .lines() + { + let _ = dictionary.add(word); + } + dictionary + }); + if let Ok(mut loaded) = LOADED.lock() { + loaded.insert(name.to_owned(), dictionary); } - 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() +/// Runs `check` with the dictionary serving `language`, an LCID, asking for it the first +/// time; `None` until it arrives, or where none serves the language. +fn with(language: u32, check: impl FnOnce(&mut spellbook::Dictionary) -> T) -> Option { + let name = pick(language, &AVAILABLE.lock().ok()?)?.to_owned(); + let mut loaded = LOADED.lock().ok()?; + match loaded.get_mut(&name) { + Some(dictionary) => dictionary.as_mut().map(check), + None => { + loaded.insert(name.clone(), None); + crate::platform::fetch_dictionary(&name); + None + } + } } 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)) + .map(|(word, language)| { + with(*language, |dictionary| !dictionary.check(word)).unwrap_or(false) + }) .collect() } fn suggest(&self, word: &str, language: u32) -> Vec { - let mut suggestions = Vec::new(); - if serves(language) - && let Ok(dictionary) = self.0.lock() - { + with(language, |dictionary| { + let mut suggestions = Vec::new(); dictionary.suggest(word, &mut suggestions); - } - suggestions + suggestions + }) + .unwrap_or_default() } fn learn(&self, word: &str) { - if let Ok(mut dictionary) = self.0.lock() { - let _ = dictionary.add(word); + if let Ok(mut loaded) = LOADED.lock() { + for dictionary in loaded.values_mut().flatten() { + let _ = dictionary.add(word); + } } let _ = notebook::fs::OpenOptions::new() .create(true) diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index b3c45cae1c3e58f6a90ea85ba895d8fd24357c2a..da4c9c8d006c6932dbb485efd76b0843eb089563 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -80,6 +80,9 @@ extern "C" { /// Fetches `fonts/{name}` for `font_arrived`. #[wasm_bindgen(js_name = fetchFont)] fn fetch_font(name: &str); + /// Fetches dictionary `name`'s files for `dictionary_arrived`. + #[wasm_bindgen(js_name = fetchDictionary)] + pub fn fetch_dictionary(name: &str); #[wasm_bindgen(js_name = storeFiles)] fn store_files(changes: js_sys::Array); } @@ -865,14 +868,14 @@ pub mod smb { } } -/// 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. +/// Starts Snowbound on the page's canvas, `index.html` having fetched `fonts` and the names +/// of the spelling dictionaries the site has. `module` is the module's own exports, which +/// the glue calls. #[wasm_bindgen] pub async fn start( module: JsValue, fonts: Vec, - dictionary: Vec, + dictionaries: Vec, ) -> Result<(), JsValue> { std::panic::set_hook(Box::new(|info| report(info))); let window = web_sys::window().ok_or("No window")?; @@ -901,12 +904,7 @@ pub async fn start( 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)); - } - } + crate::spell::offer(dictionaries); for folder in load_folders().await?.iter() { let folder = js_sys::Array::from(&folder); let root = folder.get(0).as_string().unwrap_or_default(); @@ -916,6 +914,10 @@ pub async fn start( let state = open(fonts) .await .map_err(|error| JsValue::from_str(&error.to_string()))?; + // Text no run tags is in the browser's language, as typed in it. + if let Some(spelling) = &state.spelling { + spelling.untagged(canvas::language::lcid(&input_language())); + } STATE.with_borrow_mut(|slot| *slot = Some(state)); attach(module); request_frame(); @@ -1423,6 +1425,20 @@ pub fn font_arrived(name: String, bytes: Vec) { }))); } +/// Spelling dictionary `name` arrived as its affix and word files: words in its languages +/// are checked again. +#[wasm_bindgen] +pub fn dictionary_arrived(name: String, affix: String, words: String) { + crate::spell::arrived(&name, &affix, &words); + send(UserEvent::Then(Box::new(|state| { + if let Some(spelling) = &state.spelling { + spelling.recheck(); + } + state.window.request_redraw(); + Ok(()) + }))); +} + /// 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 799f7f06881a06b23ce35dc45e68bc1adc0e7f3d..a3423eca9ad1e89efac9d4af0ea71435e5315f11 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -287,19 +287,29 @@ export function pickNotebook() { .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); } +// The bytes at `url`, inflated where it ends in .gz. +async function inflated(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: ${response.status}`); + const body = url.endsWith(".gz") + ? new Response(response.body.pipeThrough(new DecompressionStream("gzip"))) + : response; + return body.arrayBuffer(); +} + export function fetchFont(name) { - fetch(`fonts/${name}`) - .then((response) => { - if (!response.ok) return Promise.reject(response.status); - const body = name.endsWith(".gz") - ? new Response(response.body.pipeThrough(new DecompressionStream("gzip"))) - : response; - return body.arrayBuffer(); - }) + inflated(`fonts/${name}`) .then((data) => wasm.font_arrived(name, new Uint8Array(data))) .catch((error) => console.warn("Font", name, error)); } +export function fetchDictionary(name) { + const text = new TextDecoder(); + Promise.all(["aff", "dic"].map((kind) => inflated(`dictionaries/${name}.${kind}.gz`))) + .then(([affix, words]) => wasm.dictionary_arrived(name, text.decode(affix), text.decode(words))) + .catch((error) => console.warn("Dictionary", name, error)); +} + export function requestFrame() { if (!framePending) { framePending = true; diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index eba995fa13a92a9e761a4cd71171925f03608f41..3b1672ec9b0b57e2a108111c1bf2ca99788e6037 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -83,13 +83,14 @@ } try { const fetched = (url) => bytes(url).then((data) => new Uint8Array(data)); - const [, fonts, dictionary] = await Promise.all([ + const [, fonts, dictionaries] = await Promise.all([ init({ module_or_path: module }), 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(() => []), + // The spelling dictionaries the site has, fetched as pages need them; without the + // list, words go unmarked. + fetch("dictionaries/index.json").then((response) => response.json()).catch(() => []), ]); - await snowbound.start(snowbound, fonts, dictionary); + await snowbound.start(snowbound, fonts, dictionaries); 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 ddbb8bb22e9ca11ed75f31d174a92de1f18bd429..73d08b6def2b734f89b52ca132722fb958a15cac 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -8,6 +8,7 @@ static folder and publishes it to the share's web/ folder. import argparse import gzip import hashlib +import json import os from pathlib import Path import shutil @@ -28,6 +29,22 @@ EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COL EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' # As `EMOJI` in src/web.rs names it. EMOJI_FONT = 'Noto-COLRv1.ttf.gz' +# Spelling dictionaries: the name the page asks for, the nixpkgs hunspellDicts package, its +# files' stem, and the SPDX licences it comes under, each checked to allow redistribution +# (the LGPL's text with the GPL's, which it amends). Add a row to offer another language. +DICTIONARIES = [ + ('en_US', 'en_US', 'en_US', ['BSD-3-Clause']), + ('en_GB', 'en_GB-ise', 'en_GB', ['BSD-3-Clause']), + ('de_DE', 'de_DE', 'de_DE', ['GPL-2.0-only', 'GPL-3.0-only']), + ('fr_FR', 'fr-moderne', 'fr-moderne', ['MPL-2.0']), + ('es_ES', 'es_ES', 'es_ES', ['GPL-3.0-only', 'LGPL-3.0-only', 'MPL-1.1']), + ('pt_BR', 'pt_BR', 'pt_BR', ['LGPL-3.0-only', 'GPL-3.0-only']), + ('pt_PT', 'pt_PT', 'pt_PT', ['GPL-2.0-only', 'LGPL-2.1-only', 'MPL-1.1']), + ('it_IT', 'it_IT', 'it_IT', ['GPL-3.0-only']), + ('nl_NL', 'nl_NL', 'nl_NL', ['BSD-3-Clause', 'CC-BY-3.0']), + ('sv_SE', 'sv_SE', 'sv_SE', ['LGPL-3.0-only', 'GPL-3.0-only']), + ('ru_RU', 'ru_RU', 'ru_RU', ['MPL-2.0', 'LGPL-3.0-only', 'GPL-3.0-only']), +] # Size over speed where it costs little: the module is most of the first load. PROFILE = ['--config', 'profile.release.opt-level="s"'] @@ -75,11 +92,7 @@ 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') + dictionaries(out / 'dictionaries') (out / 'fonts').mkdir() for font in sorted(FONTS.glob('*')): if font.suffix in ('.ttf', '.txt'): @@ -90,6 +103,36 @@ def build(out): print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') +def dictionaries(folder): + """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate, + each beside its readme, the texts of the licences they come under in licenses/, and their + names in index.json for the page to pick from (`pick` in canvas/src/spelling.rs).""" + (folder / 'licenses').mkdir(parents=True) + texts = Path(tool('spdx', 'spdx-license-list-data.text', store=True)) / 'text' + for name, package, stem, licenses in DICTIONARIES: + store = Path(tool('hunspell', f'hunspellDicts.{package}', store=True)) + affix = (store / f'share/hunspell/{stem}.aff').read_bytes() + # Hunspell names the files' encoding in the affix file's SET line. + encoding = next((line.split()[1] for line in affix.decode('latin-1').splitlines() + if line.startswith('SET ')), 'UTF-8') + for kind in ('aff', 'dic'): + text = (store / f'share/hunspell/{stem}.{kind}').read_bytes().decode(encoding) + # One encoding and one line ending for spellbook, which reads only UTF-8. An + # 8-bit file's flags are its characters by default, as FLAG UTF-8 keeps them. + utf8 = 'SET UTF-8' if encoding == 'UTF-8' or 'FLAG ' in text else 'SET UTF-8\nFLAG UTF-8' + text = ''.join((utf8 if line.startswith('SET ') else line) + '\n' + for line in text.splitlines()) + (folder / f'{name}.{kind}.gz').write_bytes(gzip.compress(text.encode(), 9, mtime=0)) + # Where a package has no readme, its affix file's header comment names the licences. + readme = list((store / 'share/doc').glob('*.txt')) + notice = readme[0].read_bytes() if readme else \ + ''.join(line + '\n' for line in affix.decode(encoding).splitlines() if line.startswith('#')).encode() + (folder / f'{name}.txt').write_bytes(notice + f'\nLicences: {", ".join(licenses)} (licenses/)\n'.encode()) + for license in licenses: + shutil.copyfile(texts / f'{license}.txt', folder / 'licenses' / f'{license}.txt') + (folder / 'index.json').write_text(json.dumps([name for name, *_ in DICTIONARIES])) + + 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.""" -- 2.54.0