From fa0c0d93b8b36708abfd1715c7efbd106f7e5790 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 01:04:33 +0200 Subject: [PATCH 01/10] feat(ide-wasm): serve missing project files from a library of images by file name A project file that does not exist is looked up by its bare file name in a host-supplied set of library images, the same rule as `typst compile --fallback-dir`. Project files always win, package files never fall back, and names the host hands in are accepted only as bare file names. Lookups that need bytes not supplied yet are reported through take_missing_library_images. Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/lib.rs | 134 +++++++++++++++++++++++++++++ crates/typst-ide-wasm/src/world.rs | 99 ++++++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/crates/typst-ide-wasm/src/lib.rs b/crates/typst-ide-wasm/src/lib.rs index 1e17daac0b33..fcdd7507c525 100644 --- a/crates/typst-ide-wasm/src/lib.rs +++ b/crates/typst-ide-wasm/src/lib.rs @@ -162,6 +162,25 @@ impl Session { self.dirty = true; } + /// Replaces the set of library image names (see + /// [`BrowserWorld::set_library_images`]). + pub fn set_library_images(&mut self, names: &[String]) { + self.world.set_library_images(names); + self.dirty = true; + } + + /// Supplies the bytes of a library image. + pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) { + self.world.add_library_image(name, bytes); + self.dirty = true; + } + + /// Returns and forgets the library images that lookups needed but whose + /// bytes have not been supplied yet. + pub fn take_missing_library_images(&mut self) -> Vec { + self.world.take_missing_library_images() + } + /// Compiles the project rooted at `main_path` and caches the result. /// /// Returns the error messages if compilation failed; the cached document @@ -644,4 +663,119 @@ mod tests { assert_eq!(sanitize_cursor(text, 9_999), text.len()); assert_eq!(sanitize_cursor("", 5), 0); } + + /// A 10×10 SVG: small, valid, and decodable without any image feature. + const SVG: &[u8] = + br#""#; + + fn names(list: &[&str]) -> Vec { + list.iter().map(|name| name.to_string()).collect() + } + + #[test] + fn library_images_resolve_by_bare_name_from_any_directory() { + let mut s = session_with("#include \"chapters/intro.typ\"\n#image(\"logo.svg\")"); + s.add_source("chapters/intro.typ", "#image(\"logo.svg\")"); + s.set_library_images(&names(&["logo.svg"])); + s.add_library_image("logo.svg", SVG); + s.compile_impl("main.typ") + .unwrap_or_else(|errors| panic!("compile failed: {errors:?}")); + assert!(s.take_missing_library_images().is_empty()); + } + + #[test] + fn a_project_file_wins_over_the_library() { + // The project's own `logo.svg` is not an image at all, so the + // compilation can only succeed if the library copy were used. + let mut s = session_with("#image(\"logo.svg\")"); + s.add_asset("logo.svg", b"not an image"); + s.set_library_images(&names(&["logo.svg"])); + s.add_library_image("logo.svg", SVG); + assert!(s.compile_impl("main.typ").is_err()); + } + + #[test] + fn a_project_file_in_a_subfolder_wins_there() { + let mut s = session_with("#include \"chapters/intro.typ\""); + s.add_source("chapters/intro.typ", "#image(\"logo.svg\")"); + s.add_asset("chapters/logo.svg", b"not an image"); + s.set_library_images(&names(&["logo.svg"])); + s.add_library_image("logo.svg", SVG); + assert!(s.compile_impl("main.typ").is_err()); + } + + #[test] + fn missing_bytes_are_reported_once_then_served() { + let mut s = session_with("#image(\"logo.svg\")"); + s.set_library_images(&names(&["logo.svg"])); + let errors = s.compile_impl("main.typ").expect_err("bytes not there yet"); + assert!(errors.iter().any(|e| e.contains("not found")), "{errors:?}"); + assert_eq!(s.take_missing_library_images(), names(&["logo.svg"])); + assert!(s.take_missing_library_images().is_empty(), "drained"); + + s.add_library_image("logo.svg", SVG); + s.compile_impl("main.typ") + .unwrap_or_else(|errors| panic!("compile failed: {errors:?}")); + } + + #[test] + fn names_outside_the_set_are_never_served_or_reported() { + let mut s = session_with("#image(\"other.svg\")"); + s.set_library_images(&names(&["logo.svg"])); + s.add_library_image("logo.svg", SVG); + // Bytes for a name that is not in the set are ignored. + s.add_library_image("other.svg", SVG); + assert!(s.compile_impl("main.typ").is_err()); + assert!(s.take_missing_library_images().is_empty()); + } + + #[test] + fn hostile_names_are_ignored() { + let hostile = ["../x.svg", "a/b.svg", "a\\b.svg", "", ".", ".."]; + let mut s = session_with("#image(\"x.svg\")"); + s.set_library_images(&names(&hostile)); + for name in hostile { + s.add_library_image(name, SVG); + } + assert!(s.compile_impl("main.typ").is_err()); + assert!(s.take_missing_library_images().is_empty()); + } + + #[test] + fn replacing_the_set_drops_bytes_and_invalidates_the_document() { + // A cursor jump only lands on text, so the image comes after a word + // the cursor (offset 1) sits in. + let mut s = session_with("Hello\n#image(\"logo.svg\")"); + s.set_library_images(&names(&["logo.svg"])); + s.add_library_image("logo.svg", SVG); + s.compile_impl("main.typ").expect("compiles"); + assert!(!s.jump_from_cursor_impl("main.typ", "main.typ", 1).is_empty()); + + s.set_library_images(&names(&[])); + // The jump recompiles because the set changed; the image is gone, + // so the project no longer compiles and there is nothing to jump to. + assert!(s.jump_from_cursor_impl("main.typ", "main.typ", 1).is_empty()); + // Re-adding the name without bytes does not resurrect the old bytes. + s.set_library_images(&names(&["logo.svg"])); + assert!(s.compile_impl("main.typ").is_err()); + } + + #[test] + fn package_files_never_fall_back() { + use typst::World; + use typst::syntax::package::PackageSpec; + use typst::syntax::{RootedPath, VirtualPath, VirtualRoot}; + + let mut world = BrowserWorld::new(); + world.set_library_images(&names(&["logo.svg"])); + world.add_library_image("logo.svg", SVG); + let spec: PackageSpec = "@preview/pkg:0.1.0".parse().expect("spec"); + let id = RootedPath::new( + VirtualRoot::Package(spec), + VirtualPath::new("logo.svg").expect("path"), + ) + .intern(); + assert!(world.file(id).is_err()); + assert!(world.take_missing_library_images().is_empty()); + } } diff --git a/crates/typst-ide-wasm/src/world.rs b/crates/typst-ide-wasm/src/world.rs index 70d6490ee0e4..02aa5c43d324 100644 --- a/crates/typst-ide-wasm/src/world.rs +++ b/crates/typst-ide-wasm/src/world.rs @@ -1,7 +1,10 @@ //! An in-memory [`World`] implementation suitable for browsers. +use std::collections::BTreeSet; +use std::sync::Mutex; + use ecow::EcoString; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Duration}; use typst::syntax::package::PackageSpec; @@ -25,6 +28,19 @@ pub fn file_id(path: &str) -> Option { Some(RootedPath::new(VirtualRoot::Project, vpath).intern()) } +/// Accepts a library image name only if it is a bare file name. +/// +/// Anything with a separator, an empty name or a `.`/`..` component is +/// rejected, so a name handed in by the host can never become a path that +/// reaches beyond the directory it is looked up in. +pub(crate) fn library_name(name: &str) -> Option { + if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) { + return None; + } + let path = VirtualPath::new(name).ok()?; + (path.file_name() == Some(name)).then(|| name.into()) +} + /// A [`World`] whose entire file system and font set live in memory. /// /// Everything is owned by the world itself: there is no file system access, no @@ -43,6 +59,15 @@ pub struct BrowserWorld { assets: FxHashMap, /// The file that is currently treated as the main file. main: FileId, + /// Image names that any project file which does not exist may resolve + /// to, by its bare file name (the same rule as the CLI's + /// `--fallback-dir`). + library_names: FxHashSet, + /// The bytes of library images the host has supplied so far. + library_bytes: FxHashMap, + /// Library images a lookup needed but whose bytes were not supplied yet. + /// `World::file` takes `&self`, hence the lock. + missing_library: Mutex>, } impl BrowserWorld { @@ -64,6 +89,9 @@ impl BrowserWorld { sources: FxHashMap::default(), assets: FxHashMap::default(), main, + library_names: FxHashSet::default(), + library_bytes: FxHashMap::default(), + missing_library: Mutex::new(BTreeSet::new()), } } @@ -110,6 +138,71 @@ impl BrowserWorld { pub fn source_at(&self, path: &str) -> Option { self.sources.get(&file_id(path)?).cloned() } + + /// Replaces the set of library image names. + /// + /// Invalid names are ignored. Bytes and pending lookups of names that are + /// no longer in the set are dropped. + pub fn set_library_images(&mut self, names: &[String]) { + self.library_names = names.iter().filter_map(|name| library_name(name)).collect(); + self.library_bytes.retain(|name, _| self.library_names.contains(name)); + self.missing_library + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|name| self.library_names.contains(name.as_str())); + } + + /// Supplies the bytes of a library image. + /// + /// Ignored unless `name` is in the current set. + pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) { + let Some(name) = library_name(name) else { return }; + if !self.library_names.contains(&name) { + return; + } + self.missing_library + .get_mut() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(name.as_str()); + self.library_bytes.insert(name, Bytes::new(bytes.to_vec())); + } + + /// Returns, sorted, the library images lookups needed but that have no + /// bytes yet, and forgets them. + pub fn take_missing_library_images(&self) -> Vec { + let mut missing = self + .missing_library + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + std::mem::take(&mut *missing).into_iter().collect() + } + + /// Serves a project file that does not exist from the library. + /// + /// Returns `None` when the library has nothing to say about `id`: a + /// package file, a name outside the set, or a file the project has. + fn library_file(&self, id: FileId) -> Option> { + if !matches!(id.root(), VirtualRoot::Project) + || self.sources.contains_key(&id) + || self.assets.contains_key(&id) + { + return None; + } + let name = id.vpath().file_name()?; + if !self.library_names.contains(name) { + return None; + } + Some(match self.library_bytes.get(name) { + Some(bytes) => Ok(bytes.clone()), + None => { + self.missing_library + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(name.to_owned()); + Err(FileError::NotFound(id.vpath().get_without_slash().into())) + } + }) + } } impl Default for BrowserWorld { @@ -151,6 +244,10 @@ impl World for BrowserWorld { return Ok(Bytes::from_string(source.text().to_owned())); } + if let Some(result) = self.library_file(id) { + return result; + } + Err(FileError::NotFound(id.vpath().get_without_slash().into())) } From b26b3180af4d0a84d771431b0465306b0d40eaa4 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 01:36:35 +0200 Subject: [PATCH 02/10] feat(ide-wasm): offer library images in path completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdeWorld::files now also yields, for each known library image name, the path it would resolve to from the completing file's directory — unless a project file already sits there, which wins and is already listed. Carried from the prior review: library_name gets direct unit coverage (bare names accepted, anything else rejected), and World::source notes that sources deliberately never fall back to the library, unlike the CLI's own fallback which serves both. Co-Authored-By: Claude Sonnet 5 --- crates/typst-ide-wasm/src/lib.rs | 72 ++++++++++++++++++++++++++++++ crates/typst-ide-wasm/src/world.rs | 47 +++++++++++++++---- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/crates/typst-ide-wasm/src/lib.rs b/crates/typst-ide-wasm/src/lib.rs index fcdd7507c525..a3a47531b2f2 100644 --- a/crates/typst-ide-wasm/src/lib.rs +++ b/crates/typst-ide-wasm/src/lib.rs @@ -672,6 +672,12 @@ mod tests { list.iter().map(|name| name.to_string()).collect() } + fn completion_labels(s: &mut Session, path: &str, cursor: usize) -> Vec { + s.autocomplete_impl("main.typ", path, cursor, true) + .map(|r| r.completions.into_iter().map(|c| c.label.to_string()).collect()) + .unwrap_or_default() + } + #[test] fn library_images_resolve_by_bare_name_from_any_directory() { let mut s = session_with("#include \"chapters/intro.typ\"\n#image(\"logo.svg\")"); @@ -778,4 +784,70 @@ mod tests { assert!(world.file(id).is_err()); assert!(world.take_missing_library_images().is_empty()); } + + #[test] + fn library_name_rejects_anything_but_a_bare_file_name() { + use crate::world::library_name; + assert_eq!(library_name("a/b.svg"), None); + assert_eq!(library_name("a\\b.svg"), None); + assert_eq!(library_name(".."), None); + assert_eq!(library_name("."), None); + assert_eq!(library_name(""), None); + assert_eq!(library_name("logo.svg"), Some("logo.svg".into())); + } + + #[test] + fn library_images_complete_by_bare_name() { + let mut s = session_with("#image(\"\")"); + s.set_library_images(&names(&["logo.svg"])); + let labels = completion_labels(&mut s, "main.typ", 8); + assert!(labels.iter().any(|l| l.contains("logo.svg")), "{labels:?}"); + } + + #[test] + fn library_images_complete_by_bare_name_in_a_subfolder() { + let mut s = session_with("#include \"chapters/intro.typ\""); + s.add_source("chapters/intro.typ", "#image(\"\")"); + s.set_library_images(&names(&["logo.svg"])); + let labels = completion_labels(&mut s, "chapters/intro.typ", 8); + assert!(labels.iter().any(|l| l.trim_matches('"') == "logo.svg"), "{labels:?}"); + assert!(!labels.iter().any(|l| l.contains("../logo.svg")), "{labels:?}"); + } + + #[test] + fn a_project_file_and_a_library_image_of_the_same_name_complete_once() { + let mut s = session_with("#image(\"\")"); + s.add_asset("logo.svg", SVG); + s.set_library_images(&names(&["logo.svg"])); + let labels = completion_labels(&mut s, "main.typ", 8); + let count = labels.iter().filter(|l| l.contains("logo.svg")).count(); + assert_eq!(count, 1, "{labels:?}"); + } + + #[test] + fn library_completions_respect_the_typed_prefix_and_extension() { + let mut s = session_with("#image(\"zz\")"); + s.set_library_images(&names(&["logo.svg", "notes.csv"])); + assert!( + !completion_labels(&mut s, "main.typ", 10) + .iter() + .any(|l| l.contains("logo")) + ); + + let mut s = session_with("#image(\"\")"); + s.set_library_images(&names(&["logo.svg", "notes.csv"])); + let labels = completion_labels(&mut s, "main.typ", 8); + assert!(!labels.iter().any(|l| l.contains("notes.csv")), "{labels:?}"); + } + + #[test] + fn hostile_names_never_complete() { + let mut s = session_with("#image(\"\")"); + s.set_library_images(&names(&["../x.svg", "a/b.svg", "a\\b.svg"])); + let labels = completion_labels(&mut s, "main.typ", 8); + assert!( + !labels.iter().any(|l| l.contains("x.svg") || l.contains("b.svg")), + "{labels:?}" + ); + } } diff --git a/crates/typst-ide-wasm/src/world.rs b/crates/typst-ide-wasm/src/world.rs index 02aa5c43d324..0d951552a89e 100644 --- a/crates/typst-ide-wasm/src/world.rs +++ b/crates/typst-ide-wasm/src/world.rs @@ -225,6 +225,9 @@ impl World for BrowserWorld { } fn source(&self, id: FileId) -> FileResult { + // Deliberately no library fallback here: the library holds images + // only, even though the CLI's own `--fallback-dir` serves sources + // too. match self.sources.get(&id) { Some(source) => Ok(source.clone()), None => Err(FileError::NotFound(id.vpath().get_without_slash().into())), @@ -272,18 +275,44 @@ impl IdeWorld for BrowserWorld { // relative to the file being completed, optionally narrowed by what // the user has already typed. let dir = base.vpath().parent(); - self.sources + let matches_prefix = |path: &VirtualPath| { + prefix.is_none_or(|prefix| { + dir.as_ref() + .map(|dir| path.relative_from(dir)) + .is_none_or(|relative| relative.starts_with(prefix)) + }) + }; + + let mut paths: Vec = self + .sources .keys() .chain(self.assets.keys()) .map(|id| id.vpath().clone()) - .filter(|path| { - prefix.is_none_or(|prefix| { - dir.as_ref() - .map(|dir| path.relative_from(dir)) - .is_none_or(|relative| relative.starts_with(prefix)) - }) - }) - .collect() + .filter(|path| matches_prefix(path)) + .collect(); + + // A library image resolves by its bare name from any directory, so it + // is offered as if it sat next to the file being completed — unless + // the project has a file there, which wins and is already listed. + if let Some(dir) = &dir { + // `library_names` is a hash set, whose iteration order is + // unspecified; sorting first keeps the offered completions + // deterministic. + let mut names: Vec<&EcoString> = self.library_names.iter().collect(); + names.sort(); + for name in names { + let Ok(path) = dir.join(name) else { continue }; + let id = RootedPath::new(VirtualRoot::Project, path.clone()).intern(); + if self.sources.contains_key(&id) || self.assets.contains_key(&id) { + continue; + } + if matches_prefix(&path) { + paths.push(path); + } + } + } + + paths } fn packages(&self) -> &[(PackageSpec, Option)] { From f22f7bc0459f051b4e65bd1ed84c7f43d81dd4c6 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 01:41:47 +0200 Subject: [PATCH 03/10] feat(ide-wasm): read font face metadata with the compiler's parser Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/fonts.rs | 208 +++++++++++++++++++++++++++++ crates/typst-ide-wasm/src/lib.rs | 2 + 2 files changed, 210 insertions(+) create mode 100644 crates/typst-ide-wasm/src/fonts.rs diff --git a/crates/typst-ide-wasm/src/fonts.rs b/crates/typst-ide-wasm/src/fonts.rs new file mode 100644 index 000000000000..b1df24e4b3a5 --- /dev/null +++ b/crates/typst-ide-wasm/src/fonts.rs @@ -0,0 +1,208 @@ +//! Font metadata as Typst's own parser sees it. +//! +//! A host that stores font files can record, at upload time, the family, +//! style, weight and stretch the compiler will match `text(font: ..)` against, +//! without re-implementing any of Typst's naming rules. + +use typst::foundations::Bytes; +use typst::text::{Font, FontFlags, FontStyle}; + +/// The most faces read from a single file. +/// +/// Guards against a collection header that claims billions of faces. +const MAX_FACES: u32 = 256; + +/// One face of a font file. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FontFace { + /// The face's index in its file (0 unless the file is a collection). + pub index: u32, + /// The family name `text(font: ..)` matches. + pub family: String, + /// `"normal"`, `"italic"` or `"oblique"`. + pub style: FontStyle, + /// 100 (thin) to 900 (black). + pub weight: u16, + /// Width as a ratio of the normal width (1.0). + pub stretch: f64, + /// All glyphs have the same width. + pub monospace: bool, + /// Glyphs have serifs. + pub serif: bool, + /// The face has a math table. + pub math: bool, + /// The face is a variable font. + pub variable: bool, +} + +/// Reads every face of a font file (`.ttf`, `.otf`, or a `.ttc`/`.otc` +/// collection). +/// +/// Returns an error when no face can be read — including for formats Typst +/// does not load, such as WOFF2. +pub fn font_faces(bytes: &[u8]) -> Result, String> { + if let Some(count) = collection_count(bytes) { + // Every face needs a 4-byte offset in the header, so a count the + // header cannot hold is forged. + if count > MAX_FACES || count as usize > bytes.len().saturating_sub(12) / 4 { + return Err(format!("the font collection claims {count} faces")); + } + } + + let faces: Vec = Font::iter(Bytes::new(bytes.to_vec())) + .take(MAX_FACES as usize) + .map(|font| { + let info = font.info(); + FontFace { + index: font.index(), + family: info.family.clone(), + style: info.variant.style, + weight: info.variant.weight.to_number(), + stretch: info.variant.stretch.to_ratio().get(), + monospace: info.flags.contains(FontFlags::MONOSPACE), + serif: info.flags.contains(FontFlags::SERIF), + math: info.flags.contains(FontFlags::MATH), + variable: info.flags.contains(FontFlags::VARIABLE), + } + }) + .collect(); + + if faces.is_empty() { + return Err("no font face could be read from these bytes".into()); + } + Ok(faces) +} + +/// The face count a collection header declares, if `bytes` is a collection. +fn collection_count(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"ttcf") { + return None; + } + let count = bytes.get(8..12)?; + Some(u32::from_be_bytes(count.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Embedded fonts shipped with Typst: real files, no network. + fn some_font() -> &'static [u8] { + typst_assets::fonts().next().expect("typst-assets ships fonts") + } + + /// Builds a TrueType collection from standalone font files. + /// + /// A collection is a `ttcf` header followed by the fonts; each font's + /// table records hold absolute offsets, so they are shifted by where the + /// font lands in the collection. + fn collection(fonts: &[&[u8]]) -> Vec { + let header_len = 12 + 4 * fonts.len(); + let mut out = Vec::new(); + out.extend_from_slice(b"ttcf"); + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(&(fonts.len() as u32).to_be_bytes()); + let mut base = header_len; + let mut bodies = Vec::new(); + for font in fonts { + out.extend_from_slice(&(base as u32).to_be_bytes()); + let mut body = font.to_vec(); + let tables = u16::from_be_bytes([body[4], body[5]]) as usize; + for i in 0..tables { + let at = 12 + 16 * i + 8; + let offset = u32::from_be_bytes(body[at..at + 4].try_into().unwrap()); + body[at..at + 4].copy_from_slice(&(offset + base as u32).to_be_bytes()); + } + // Keep every font 4-byte aligned, as table offsets require. + while body.len() % 4 != 0 { + body.push(0); + } + base += body.len(); + bodies.push(body); + } + for body in bodies { + out.extend_from_slice(&body); + } + out + } + + #[test] + fn a_font_file_yields_one_face_with_its_metadata() { + let faces = font_faces(some_font()).expect("a font"); + assert_eq!(faces.len(), 1); + let face = &faces[0]; + assert_eq!(face.index, 0); + assert!(!face.family.is_empty()); + assert!((100..=900).contains(&face.weight)); + assert!(face.stretch > 0.4 && face.stretch < 2.1); + } + + #[test] + fn metadata_matches_what_the_compiler_sees() { + use typst::foundations::Bytes; + use typst::text::Font; + let data = some_font(); + let font = Font::iter(Bytes::new(data.to_vec())).next().expect("font"); + let face = &font_faces(data).expect("a font")[0]; + assert_eq!(face.family, font.info().family); + assert_eq!(face.weight, font.info().variant.weight.to_number()); + } + + #[test] + fn a_collection_yields_every_face_with_its_index() { + let fonts: Vec<&[u8]> = typst_assets::fonts().take(2).collect(); + let faces = font_faces(&collection(&fonts)).expect("a collection"); + assert_eq!(faces.len(), 2); + assert_eq!(faces[0].index, 0); + assert_eq!(faces[1].index, 1); + assert_eq!(faces[0].family, font_faces(fonts[0]).unwrap()[0].family); + assert_eq!(faces[1].family, font_faces(fonts[1]).unwrap()[0].family); + // The first two bundled fonts share a family, so the weights are + // what pins each face to its place in the file. + assert_eq!(faces[0].weight, font_faces(fonts[0]).unwrap()[0].weight); + assert_eq!(faces[1].weight, font_faces(fonts[1]).unwrap()[0].weight); + assert_ne!(faces[0].weight, faces[1].weight); + } + + #[test] + fn serializes_to_the_documented_shape() { + let json = serde_json::to_value(&font_faces(some_font()).unwrap()[0]).unwrap(); + let keys: Vec<&str> = + json.as_object().unwrap().keys().map(String::as_str).collect(); + for key in [ + "index", + "family", + "style", + "weight", + "stretch", + "monospace", + "serif", + "math", + "variable", + ] { + assert!(keys.contains(&key), "missing {key} in {json}"); + } + assert!( + ["normal", "italic", "oblique"].contains(&json["style"].as_str().unwrap()) + ); + } + + #[test] + fn bytes_that_are_not_a_font_are_an_error() { + let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0]; + assert!(font_faces(&png).is_err()); + assert!(font_faces(&[]).is_err()); + assert!(font_faces(&some_font()[..64]).is_err()); + } + + #[test] + fn a_forged_collection_header_is_rejected_quickly() { + let mut forged = b"ttcf\0\x01\0\0".to_vec(); + forged.extend_from_slice(&u32::MAX.to_be_bytes()); + forged.extend_from_slice(&[0; 16]); + let started = std::time::Instant::now(); + assert!(font_faces(&forged).is_err()); + assert!(started.elapsed() < std::time::Duration::from_millis(200)); + } +} diff --git a/crates/typst-ide-wasm/src/lib.rs b/crates/typst-ide-wasm/src/lib.rs index a3a47531b2f2..1be2de77532a 100644 --- a/crates/typst-ide-wasm/src/lib.rs +++ b/crates/typst-ide-wasm/src/lib.rs @@ -15,10 +15,12 @@ #[cfg(target_arch = "wasm32")] mod bindings; +mod fonts; mod world; #[cfg(target_arch = "wasm32")] pub use self::bindings::IdeSession; +pub use self::fonts::{FontFace, font_faces}; pub use self::world::BrowserWorld; use std::num::NonZeroUsize; From c47387316a61bdb1517b61b13f9323d4e9383b05 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 01:48:33 +0200 Subject: [PATCH 04/10] fix(ide-wasm): bound the cmap work of reading font face metadata Reading a face lists every codepoint its cmap declares, and a single format 12 group can declare four billion. Count the declared codepoints from the raw table first and reject reversed or out-of-range groups and faces or files that declare too many. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/typst-ide-wasm/Cargo.toml | 1 + crates/typst-ide-wasm/src/fonts.rs | 258 +++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b97a1e3b4634..59a492b12c81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3507,6 +3507,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "ttf-parser", "typst", "typst-assets", "typst-ide", diff --git a/crates/typst-ide-wasm/Cargo.toml b/crates/typst-ide-wasm/Cargo.toml index 9189c85552c6..98d3fad3cbb6 100644 --- a/crates/typst-ide-wasm/Cargo.toml +++ b/crates/typst-ide-wasm/Cargo.toml @@ -23,6 +23,7 @@ comemo = { workspace = true } ecow = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } +ttf-parser = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" diff --git a/crates/typst-ide-wasm/src/fonts.rs b/crates/typst-ide-wasm/src/fonts.rs index b1df24e4b3a5..57ff2b90255e 100644 --- a/crates/typst-ide-wasm/src/fonts.rs +++ b/crates/typst-ide-wasm/src/fonts.rs @@ -49,6 +49,7 @@ pub fn font_faces(bytes: &[u8]) -> Result, String> { return Err(format!("the font collection claims {count} faces")); } } + check_coverage(bytes)?; let faces: Vec = Font::iter(Bytes::new(bytes.to_vec())) .take(MAX_FACES as usize) @@ -83,6 +84,150 @@ fn collection_count(bytes: &[u8]) -> Option { Some(u32::from_be_bytes(count.try_into().ok()?)) } +/// The most codepoints one face's `cmap` subtables may declare, summed. +/// +/// Reading a face lists every codepoint of its `cmap` one by one, and a +/// 12-byte range can declare four billion of them. Four times the Unicode +/// range leaves room for a real face's several subtables. +const MAX_FACE_CODEPOINTS: u64 = 4 * 0x110000; + +/// The most codepoints all faces of one file may declare, summed. +const MAX_FILE_CODEPOINTS: u64 = 16 * 0x110000; + +/// Rejects a file whose `cmap` tables would make reading it unbounded. +/// +/// Counts, from the raw table bytes, how many codepoints each face's +/// subtables declare, before the face is read. Every encoding record is +/// counted, since several records may point at the same subtable and each +/// one is listed again. The count stops as soon as a limit is crossed, so +/// the check itself stays bounded. A face the font parser cannot read is +/// left to the reader, which skips it. +fn check_coverage(bytes: &[u8]) -> Result<(), String> { + let count = collection_count(bytes).unwrap_or(1); + let mut file_total = 0; + for index in 0..count { + let Ok(face) = ttf_parser::Face::parse(bytes, index) else { continue }; + let Some(cmap) = face.raw_face().table(ttf_parser::Tag::from_bytes(b"cmap")) + else { + continue; + }; + let face_total = cmap_codepoints(cmap, MAX_FACE_CODEPOINTS)?; + if face_total > MAX_FACE_CODEPOINTS { + return Err(format!("font face {index} declares too many codepoints")); + } + file_total += face_total; + if file_total > MAX_FILE_CODEPOINTS { + return Err("the font file declares too many codepoints".into()); + } + } + Ok(()) +} + +/// Sums the codepoints declared by every encoding record of a `cmap` table, +/// stopping once the sum passes `limit`. +/// +/// Errors on a format 12 or 13 group that is reversed or ends past U+10FFFF. +/// A subtable too short to hold what it declares counts as empty, since the +/// font parser does not read it either. +fn cmap_codepoints(cmap: &[u8], limit: u64) -> Result { + let records = read_u16(cmap, 2).unwrap_or(0); + let mut total = 0; + for record in 0..usize::from(records) { + let Some(offset) = read_u32(cmap, 4 + 8 * record + 4) else { break }; + let Some(subtable) = cmap.get(offset as usize..) else { continue }; + total += subtable_codepoints(subtable, limit.saturating_sub(total))?; + if total > limit { + break; + } + } + Ok(total) +} + +/// The codepoints one `cmap` subtable declares (an upper bound of what the +/// font parser lists), stopping once past `limit`. +fn subtable_codepoints(data: &[u8], limit: u64) -> Result { + let Some(format) = read_u16(data, 0) else { return Ok(0) }; + let count = match format { + 0 => 256, + 2 => { + // 256 sub-header keys, then sub-headers of 8 bytes each. + let mut total = 0; + for byte in 0..256 { + let Some(key) = read_u16(data, 6 + 2 * byte) else { return Ok(0) }; + let sub = usize::from(key / 8); + total += match sub { + 0 => 1, + _ => u64::from(read_u16(data, 518 + 8 * sub + 2).unwrap_or(0)), + }; + } + total + } + 4 => { + let Some(seg_x2) = read_u16(data, 6) else { return Ok(0) }; + let segs = usize::from(seg_x2 / 2); + let mut total = 0; + for seg in 0..segs { + let (Some(end), Some(start)) = ( + read_u16(data, 14 + 2 * seg), + read_u16(data, 16 + usize::from(seg_x2) + 2 * seg), + ) else { + return Ok(0); + }; + // A reversed segment lists nothing, but counts as one so + // that every segment moves the count toward the limit. + total += u64::from(end.saturating_sub(start)) + 1; + if total > limit { + break; + } + } + total + } + 6 => { + let entries = u64::from(read_u16(data, 8).unwrap_or(0)); + entries.min((data.len().saturating_sub(10) / 2) as u64) + } + 10 => { + let chars = u64::from(read_u32(data, 16).unwrap_or(0)); + chars.min((data.len().saturating_sub(20) / 2) as u64) + } + 12 | 13 => { + let Some(groups) = read_u32(data, 12) else { return Ok(0) }; + if groups as usize > data.len().saturating_sub(16) / 12 { + return Ok(0); + } + let mut total = 0; + for group in 0..groups as usize { + let at = 16 + 12 * group; + let (Some(start), Some(end)) = + (read_u32(data, at), read_u32(data, at + 4)) + else { + return Ok(0); + }; + if start > end || end > 0x10FFFF { + return Err(format!( + "a font cmap maps the invalid range {start:#X}..={end:#X}" + )); + } + total += u64::from(end - start) + 1; + if total > limit { + break; + } + } + total + } + _ => 0, + }; + Ok(count) +} + +fn read_u16(data: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(data.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn read_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + #[cfg(test)] mod tests { use super::*; @@ -205,4 +350,117 @@ mod tests { assert!(font_faces(&forged).is_err()); assert!(started.elapsed() < std::time::Duration::from_millis(200)); } + + #[test] + fn a_collection_header_longer_than_its_bytes_is_rejected() { + let mut forged = b"ttcf\0\x01\0\0".to_vec(); + forged.extend_from_slice(&200_u32.to_be_bytes()); + forged.resize(30, 0); + // Under the face limit, but 30 bytes cannot hold 200 offsets. + let err = font_faces(&forged).expect_err("a forged header"); + assert!(err.contains("claims 200 faces"), "{err}"); + } + + /// Replaces a font's `cmap` table with `cmap`, appended at the end. + fn with_cmap(font: &[u8], cmap: &[u8]) -> Vec { + let mut out = font.to_vec(); + while out.len() % 4 != 0 { + out.push(0); + } + let offset = out.len() as u32; + let tables = u16::from_be_bytes([out[4], out[5]]) as usize; + let record = (0..tables) + .map(|i| 12 + 16 * i) + .find(|&at| &out[at..at + 4] == b"cmap") + .expect("the font has a cmap"); + out[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); + out[record + 12..record + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + out.extend_from_slice(cmap); + out + } + + /// A `cmap` whose `records` encoding records all point at one subtable. + fn cmap(records: u16, subtable: &[u8]) -> Vec { + let mut out = vec![0, 0]; + out.extend_from_slice(&records.to_be_bytes()); + for _ in 0..records { + out.extend_from_slice(&[0, 3, 0, 10]); + out.extend_from_slice(&(4 + 8 * u32::from(records)).to_be_bytes()); + } + out.extend_from_slice(subtable); + out + } + + /// A format 12 subtable with the given `(start, end)` groups. + fn format12(groups: &[(u32, u32)]) -> Vec { + let mut out = vec![0, 12, 0, 0]; + out.extend_from_slice(&(16 + 12 * groups.len() as u32).to_be_bytes()); + out.extend_from_slice(&[0; 4]); + out.extend_from_slice(&(groups.len() as u32).to_be_bytes()); + for &(start, end) in groups { + out.extend_from_slice(&start.to_be_bytes()); + out.extend_from_slice(&end.to_be_bytes()); + out.extend_from_slice(&1_u32.to_be_bytes()); + } + out + } + + /// A format 4 subtable with one `start..=end` segment and the final one. + fn format4(start: u16, end: u16) -> Vec { + let mut out = vec![0, 4, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0]; + for value in [end, 0xFFFF, 0, start, 0xFFFF, 0, 1, 0, 0] { + out.extend_from_slice(&value.to_be_bytes()); + } + let len = out.len() as u16; + out[2..4].copy_from_slice(&len.to_be_bytes()); + out + } + + fn quickly_rejected(font: &[u8]) -> String { + let started = std::time::Instant::now(); + let err = font_faces(font).expect_err("the font is rejected"); + assert!(started.elapsed() < std::time::Duration::from_millis(200)); + err + } + + #[test] + fn a_sane_replacement_cmap_still_reads() { + // Proves the fixtures below build a readable font, so their errors + // come from the guard. + let font = with_cmap(some_font(), &cmap(1, &format12(&[(0x20, 0x7E)]))); + assert_eq!(font_faces(&font).expect("a font").len(), 1); + let font = with_cmap(some_font(), &cmap(1, &format4(0x20, 0x7E))); + assert_eq!(font_faces(&font).expect("a font").len(), 1); + } + + #[test] + fn a_cmap_group_past_unicode_is_rejected_quickly() { + let font = with_cmap(some_font(), &cmap(1, &format12(&[(0, u32::MAX)]))); + assert!(quickly_rejected(&font).contains("cmap")); + } + + #[test] + fn a_reversed_cmap_group_is_rejected() { + let font = with_cmap(some_font(), &cmap(1, &format12(&[(0x7E, 0x20)]))); + assert!(quickly_rejected(&font).contains("cmap")); + } + + #[test] + fn a_face_declaring_too_many_codepoints_is_rejected_quickly() { + // Each group is valid, but together they list the Unicode range five + // times over. + let groups = [(0, 0x10FFFF); 5]; + let font = with_cmap(some_font(), &cmap(1, &format12(&groups))); + quickly_rejected(&font); + } + + #[test] + fn records_sharing_one_subtable_count_once_each() { + // A single full-range subtable is fine; listed by 100 records, it is + // read 100 times. + let font = with_cmap(some_font(), &cmap(100, &format12(&[(0, 0x10FFFF)]))); + quickly_rejected(&font); + let font = with_cmap(some_font(), &cmap(100, &format4(0, 0xFFFE))); + quickly_rejected(&font); + } } From d4bc49aa069171300d756de4875be78433b896be Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 02:01:50 +0200 Subject: [PATCH 05/10] fix(ide-wasm): check the cmap the font parser actually reads The table directory need not be sorted and may list cmap twice; the parser takes the last cmap record in a linear pass, so the coverage check now locates the table the same way instead of by binary search. Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/fonts.rs | 106 ++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/crates/typst-ide-wasm/src/fonts.rs b/crates/typst-ide-wasm/src/fonts.rs index 57ff2b90255e..b912e7cad9fa 100644 --- a/crates/typst-ide-wasm/src/fonts.rs +++ b/crates/typst-ide-wasm/src/fonts.rs @@ -107,10 +107,7 @@ fn check_coverage(bytes: &[u8]) -> Result<(), String> { let mut file_total = 0; for index in 0..count { let Ok(face) = ttf_parser::Face::parse(bytes, index) else { continue }; - let Some(cmap) = face.raw_face().table(ttf_parser::Tag::from_bytes(b"cmap")) - else { - continue; - }; + let Some(cmap) = cmap_table(face.raw_face()) else { continue }; let face_total = cmap_codepoints(cmap, MAX_FACE_CODEPOINTS)?; if face_total > MAX_FACE_CODEPOINTS { return Err(format!("font face {index} declares too many codepoints")); @@ -123,6 +120,28 @@ fn check_coverage(bytes: &[u8]) -> Result<(), String> { Ok(()) } +/// The `cmap` table the font parser reads for a face. +/// +/// Mirrors how the parser collects tables: a linear pass over the table +/// directory, which need not be sorted, where the last `cmap` record wins. +/// A record whose end overflows is passed over, and one whose range lies +/// outside the file leaves the face without a `cmap`. A binary search of the +/// directory could pick a different table, or none, than the one the face is +/// read with. +fn cmap_table<'a>(raw: &ttf_parser::RawFace<'a>) -> Option<&'a [u8]> { + let tag = ttf_parser::Tag::from_bytes(b"cmap"); + let mut cmap = None; + for record in raw.table_records { + if record.tag != tag { + continue; + } + let start = record.offset as usize; + let Some(end) = start.checked_add(record.length as usize) else { continue }; + cmap = raw.data.get(start..end); + } + cmap +} + /// Sums the codepoints declared by every encoding record of a `cmap` table, /// stopping once the sum passes `limit`. /// @@ -364,21 +383,53 @@ mod tests { /// Replaces a font's `cmap` table with `cmap`, appended at the end. fn with_cmap(font: &[u8], cmap: &[u8]) -> Vec { let mut out = font.to_vec(); - while out.len() % 4 != 0 { - out.push(0); - } - let offset = out.len() as u32; - let tables = u16::from_be_bytes([out[4], out[5]]) as usize; - let record = (0..tables) - .map(|i| 12 + 16 * i) - .find(|&at| &out[at..at + 4] == b"cmap") - .expect("the font has a cmap"); - out[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); - out[record + 12..record + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); - out.extend_from_slice(cmap); + let record = record_of(&out, *b"cmap"); + repoint(&mut out, record, cmap); out } + /// The position of the first table record tagged `tag`. + fn record_of(font: &[u8], tag: [u8; 4]) -> usize { + let tables = u16::from_be_bytes([font[4], font[5]]) as usize; + (0..tables) + .map(|i| 12 + 16 * i) + .find(|&at| font[at..at + 4] == tag) + .expect("the font has the table") + } + + /// Appends `table` and points the table record at `record` to it. + fn repoint(font: &mut Vec, record: usize, table: &[u8]) { + while font.len() % 4 != 0 { + font.push(0); + } + let offset = font.len() as u32; + font[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); + font[record + 12..record + 16] + .copy_from_slice(&(table.len() as u32).to_be_bytes()); + font.extend_from_slice(table); + } + + /// Moves the `cmap` record to the front of the table directory, which + /// leaves the directory unsorted. + fn cmap_first(mut font: Vec) -> Vec { + let record = record_of(&font, *b"cmap"); + font[12..record + 16].rotate_right(16); + font + } + + /// Gives the font two `cmap` records: the original one points at + /// `first`, and the later `post` record, retagged, at `last`. + fn two_cmaps(first: &[u8], last: &[u8]) -> Vec { + let mut font = some_font().to_vec(); + let original = record_of(&font, *b"cmap"); + let later = record_of(&font, *b"post"); + assert!(later > original); + font[later..later + 4].copy_from_slice(b"cmap"); + repoint(&mut font, original, first); + repoint(&mut font, later, last); + font + } + /// A `cmap` whose `records` encoding records all point at one subtable. fn cmap(records: u16, subtable: &[u8]) -> Vec { let mut out = vec![0, 0]; @@ -463,4 +514,27 @@ mod tests { let font = with_cmap(some_font(), &cmap(100, &format4(0, 0xFFFE))); quickly_rejected(&font); } + + #[test] + fn a_cmap_first_in_an_unsorted_directory_is_still_checked() { + let benign = cmap(1, &format12(&[(0x20, 0x7E)])); + let font = cmap_first(with_cmap(some_font(), &benign)); + assert_eq!(font_faces(&font).expect("a font").len(), 1); + + let reversed = cmap(1, &format12(&[(0x7E, 0x20)])); + let font = cmap_first(with_cmap(some_font(), &reversed)); + assert!(quickly_rejected(&font).contains("cmap")); + + let wide = cmap(1, &format12(&[(0, 0x10FFFF); 5])); + quickly_rejected(&cmap_first(with_cmap(some_font(), &wide))); + } + + #[test] + fn of_two_cmap_records_the_last_one_is_checked() { + let benign = cmap(1, &format12(&[(0x20, 0x7E)])); + let reversed = cmap(1, &format12(&[(0x7E, 0x20)])); + assert!(quickly_rejected(&two_cmaps(&benign, &reversed)).contains("cmap")); + let faces = font_faces(&two_cmaps(&reversed, &benign)).expect("a font"); + assert_eq!(faces.len(), 1); + } } From 23cccf777919f64dbb317b817714d9ef091951f1 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 02:06:13 +0200 Subject: [PATCH 06/10] feat(ide-wasm): expose library images and font faces to JavaScript Co-Authored-By: Claude Sonnet 5 --- crates/typst-ide-wasm/src/bindings.rs | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/typst-ide-wasm/src/bindings.rs b/crates/typst-ide-wasm/src/bindings.rs index cdfc55954ebe..3ec0d50686c1 100644 --- a/crates/typst-ide-wasm/src/bindings.rs +++ b/crates/typst-ide-wasm/src/bindings.rs @@ -1,5 +1,6 @@ //! The JavaScript-facing surface of the crate. +use wasm_bindgen::JsError; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -113,6 +114,28 @@ impl IdeSession { None => JsValue::NULL, } } + + /// Replaces the set of library image names: images a project file that + /// does not exist resolves to by its bare file name. Invalid names are + /// ignored. + #[wasm_bindgen(js_name = setLibraryImages)] + pub fn set_library_images(&mut self, names: Vec) { + self.0.set_library_images(&names); + } + + /// Supplies the bytes of a library image. Ignored unless `name` is in the + /// current set. + #[wasm_bindgen(js_name = addLibraryImage)] + pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) { + self.0.add_library_image(name, bytes); + } + + /// Returns, sorted, the library images the last lookups needed but whose + /// bytes have not been supplied yet, and forgets them. + #[wasm_bindgen(js_name = takeMissingLibraryImages)] + pub fn take_missing_library_images(&mut self) -> Vec { + self.0.take_missing_library_images() + } } impl Default for IdeSession { @@ -121,6 +144,16 @@ impl Default for IdeSession { } } +/// Reads every face of a font file. +/// +/// Returns `[{ index, family, style, weight, stretch, monospace, serif, math, +/// variable }]` and throws when no face can be read. +#[wasm_bindgen(js_name = fontFaces)] +pub fn font_faces(bytes: &[u8]) -> Result { + let faces = crate::font_faces(bytes).map_err(|message| JsError::new(&message))?; + serde_wasm_bindgen::to_value(&faces).map_err(|error| JsError::new(&error.to_string())) +} + /// Serializes a result, falling back to `null` if that somehow fails. fn to_js(value: &impl serde::Serialize) -> JsValue { serde_wasm_bindgen::to_value(value).unwrap_or(JsValue::NULL) From 945c727f9ea1e2cab9bec110e3e95ec4ce9ee4ca Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 03:17:07 +0200 Subject: [PATCH 07/10] fix(ide-wasm): complete library images without interning file ids File ids come from a global interner that never frees one and holds at most 65,535. Listing files interned a path for every library name in every directory completions were asked about, so a long session with a large library ran out of ids and panicked. Compare virtual paths against the project's files instead, and leave sorting to the IDE, which sorts what it gets. Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/lib.rs | 34 ++++++++++++++++++++++++++++++ crates/typst-ide-wasm/src/world.rs | 33 ++++++++++++++--------------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/typst-ide-wasm/src/lib.rs b/crates/typst-ide-wasm/src/lib.rs index 1be2de77532a..19c12d1e0bf9 100644 --- a/crates/typst-ide-wasm/src/lib.rs +++ b/crates/typst-ide-wasm/src/lib.rs @@ -852,4 +852,38 @@ mod tests { "{labels:?}" ); } + + #[test] + fn library_completions_intern_no_file_ids() { + use typst::syntax::{RootedPath, VirtualPath, VirtualRoot}; + + // File ids come from a global interner that never frees one and holds + // at most 65,535, so completions must not intern a path per library + // name and directory: a long session would run out and panic. + let sentinel = |name: &str| { + RootedPath::new(VirtualRoot::Project, VirtualPath::new(name).unwrap()) + .intern() + .into_raw() + .get() + }; + let mut s = session_with("#image(\"\")"); + let many: Vec = (0..200).map(|i| format!("img{i}.png")).collect(); + s.set_library_images(&many); + let dirs: Vec = (0..20).map(|d| format!("dir{d}/page.typ")).collect(); + for path in &dirs { + s.add_source(path, "#image(\"\")"); + } + + let before = sentinel("interner-sentinel-before.typ"); + for path in &dirs { + let labels = completion_labels(&mut s, path, 8); + assert!(labels.iter().any(|l| l.contains("img7.png")), "{labels:?}"); + } + let after = sentinel("interner-sentinel-after.typ"); + + // Adjacent when run alone; the slack absorbs tests running in + // parallel threads, far below the 4,000 ids interning per name and + // directory would take. + assert!(after - before < 500, "{} file ids interned", after - before - 1); + } } diff --git a/crates/typst-ide-wasm/src/world.rs b/crates/typst-ide-wasm/src/world.rs index 0d951552a89e..e97ad5b510b8 100644 --- a/crates/typst-ide-wasm/src/world.rs +++ b/crates/typst-ide-wasm/src/world.rs @@ -283,33 +283,32 @@ impl IdeWorld for BrowserWorld { }) }; - let mut paths: Vec = self + let project: FxHashSet<&VirtualPath> = self .sources .keys() .chain(self.assets.keys()) - .map(|id| id.vpath().clone()) + .map(|id| id.vpath()) + .collect(); + let mut paths: Vec = project + .iter() .filter(|path| matches_prefix(path)) + .map(|&path| path.clone()) .collect(); // A library image resolves by its bare name from any directory, so it // is offered as if it sat next to the file being completed — unless // the project has a file there, which wins and is already listed. + // Paths are compared as they are, never interned: file ids are never + // freed, and interning one per name and directory asked about would + // exhaust them over a long session. The order of the hash set does + // not matter: the IDE sorts the paths it gets. if let Some(dir) = &dir { - // `library_names` is a hash set, whose iteration order is - // unspecified; sorting first keeps the offered completions - // deterministic. - let mut names: Vec<&EcoString> = self.library_names.iter().collect(); - names.sort(); - for name in names { - let Ok(path) = dir.join(name) else { continue }; - let id = RootedPath::new(VirtualRoot::Project, path.clone()).intern(); - if self.sources.contains_key(&id) || self.assets.contains_key(&id) { - continue; - } - if matches_prefix(&path) { - paths.push(path); - } - } + paths.extend( + self.library_names + .iter() + .filter_map(|name| dir.join(name).ok()) + .filter(|path| !project.contains(path) && matches_prefix(path)), + ); } paths From 5e2fa175f659a1468a01b8cc1eda71aa3adf4df9 Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 03:17:15 +0200 Subject: [PATCH 08/10] fix(ide-wasm): keep the cached document across no-op library changes Supplying bytes for a name outside the set, or setting the same set of library images again, changes nothing the compiler sees, so it no longer marks the session for recompilation. Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/lib.rs | 34 ++++++++++++++++++++++++++---- crates/typst-ide-wasm/src/world.rs | 22 +++++++++++++------ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/typst-ide-wasm/src/lib.rs b/crates/typst-ide-wasm/src/lib.rs index 19c12d1e0bf9..de076293c2ef 100644 --- a/crates/typst-ide-wasm/src/lib.rs +++ b/crates/typst-ide-wasm/src/lib.rs @@ -166,15 +166,22 @@ impl Session { /// Replaces the set of library image names (see /// [`BrowserWorld::set_library_images`]). + /// + /// Keeps the cached document when the set is unchanged. pub fn set_library_images(&mut self, names: &[String]) { - self.world.set_library_images(names); - self.dirty = true; + if self.world.set_library_images(names) { + self.dirty = true; + } } /// Supplies the bytes of a library image. + /// + /// Keeps the cached document when the name is not in the set, since the + /// bytes are then ignored. pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) { - self.world.add_library_image(name, bytes); - self.dirty = true; + if self.world.add_library_image(name, bytes) { + self.dirty = true; + } } /// Returns and forgets the library images that lookups needed but whose @@ -886,4 +893,23 @@ mod tests { // directory would take. assert!(after - before < 500, "{} file ids interned", after - before - 1); } + + #[test] + fn no_op_library_changes_keep_the_cached_document() { + let mut s = session_with("#image(\"logo.svg\")"); + s.set_library_images(&names(&["logo.svg", "other.svg"])); + s.add_library_image("logo.svg", SVG); + s.compile_impl("main.typ").expect("compiles"); + assert!(!s.dirty); + + // Bytes for a name outside the set are ignored, so nothing changed. + s.add_library_image("unknown.svg", SVG); + assert!(!s.dirty, "a name outside the set"); + // The same set, in another order and with an invalid name. + s.set_library_images(&names(&["other.svg", "a/b.svg", "logo.svg"])); + assert!(!s.dirty, "an unchanged set"); + + s.set_library_images(&names(&["logo.svg"])); + assert!(s.dirty, "a changed set"); + } } diff --git a/crates/typst-ide-wasm/src/world.rs b/crates/typst-ide-wasm/src/world.rs index e97ad5b510b8..f8264c9b6c9d 100644 --- a/crates/typst-ide-wasm/src/world.rs +++ b/crates/typst-ide-wasm/src/world.rs @@ -142,29 +142,37 @@ impl BrowserWorld { /// Replaces the set of library image names. /// /// Invalid names are ignored. Bytes and pending lookups of names that are - /// no longer in the set are dropped. - pub fn set_library_images(&mut self, names: &[String]) { - self.library_names = names.iter().filter_map(|name| library_name(name)).collect(); + /// no longer in the set are dropped. Returns whether the set changed. + pub fn set_library_images(&mut self, names: &[String]) -> bool { + let names: FxHashSet = + names.iter().filter_map(|name| library_name(name)).collect(); + if names == self.library_names { + return false; + } + self.library_names = names; self.library_bytes.retain(|name, _| self.library_names.contains(name)); self.missing_library .get_mut() .unwrap_or_else(|poisoned| poisoned.into_inner()) .retain(|name| self.library_names.contains(name.as_str())); + true } /// Supplies the bytes of a library image. /// - /// Ignored unless `name` is in the current set. - pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) { - let Some(name) = library_name(name) else { return }; + /// Ignored unless `name` is in the current set. Returns whether the bytes + /// were taken. + pub fn add_library_image(&mut self, name: &str, bytes: &[u8]) -> bool { + let Some(name) = library_name(name) else { return false }; if !self.library_names.contains(&name) { - return; + return false; } self.missing_library .get_mut() .unwrap_or_else(|poisoned| poisoned.into_inner()) .remove(name.as_str()); self.library_bytes.insert(name, Bytes::new(bytes.to_vec())); + true } /// Returns, sorted, the library images lookups needed but that have no From 6225b67b4187e189098b5cf9990d46b45498332f Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 03:17:24 +0200 Subject: [PATCH 09/10] fix(ide-wasm): bound the cmap work of empty format 2 subtables The font parser walks all 256 keys of a format 2 subtable even when its sub-headers list nothing, but such a subtable counted as empty, so a face with thousands of encoding records pointing at one could take seconds to read. Count every key as at least one and every encoding record as at least one, so the count bounds the parser's work and not only its output. Each subtable is counted once and charged to every record that points at it, which keeps the check itself linear in the table's size. Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/fonts.rs | 55 +++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/typst-ide-wasm/src/fonts.rs b/crates/typst-ide-wasm/src/fonts.rs index b912e7cad9fa..55173602d21b 100644 --- a/crates/typst-ide-wasm/src/fonts.rs +++ b/crates/typst-ide-wasm/src/fonts.rs @@ -4,6 +4,7 @@ //! style, weight and stretch the compiler will match `text(font: ..)` against, //! without re-implementing any of Typst's naming rules. +use rustc_hash::FxHashMap; use typst::foundations::Bytes; use typst::text::{Font, FontFlags, FontStyle}; @@ -99,7 +100,9 @@ const MAX_FILE_CODEPOINTS: u64 = 16 * 0x110000; /// Counts, from the raw table bytes, how many codepoints each face's /// subtables declare, before the face is read. Every encoding record is /// counted, since several records may point at the same subtable and each -/// one is listed again. The count stops as soon as a limit is crossed, so +/// one is listed again; each counts as at least one, as does every step the +/// parser takes through a subtable, so the count bounds the parser's work +/// and not only its output. The count stops as soon as a limit is crossed, so /// the check itself stays bounded. A face the font parser cannot read is /// left to the reader, which skips it. fn check_coverage(bytes: &[u8]) -> Result<(), String> { @@ -148,13 +151,33 @@ fn cmap_table<'a>(raw: &ttf_parser::RawFace<'a>) -> Option<&'a [u8]> { /// Errors on a format 12 or 13 group that is reversed or ends past U+10FFFF. /// A subtable too short to hold what it declares counts as empty, since the /// font parser does not read it either. +/// +/// Each subtable is counted once and its count charged to every record that +/// points at it, so the check costs one pass over the table's bytes plus one +/// step per record however many records share a subtable. A count cut short +/// by the limit pushes the sum past it at once, so no cut count is reused. fn cmap_codepoints(cmap: &[u8], limit: u64) -> Result { let records = read_u16(cmap, 2).unwrap_or(0); + let mut counted = FxHashMap::::default(); let mut total = 0; for record in 0..usize::from(records) { let Some(offset) = read_u32(cmap, 4 + 8 * record + 4) else { break }; - let Some(subtable) = cmap.get(offset as usize..) else { continue }; - total += subtable_codepoints(subtable, limit.saturating_sub(total))?; + let count = match counted.get(&offset) { + Some(&count) => count, + None => { + let count = match cmap.get(offset as usize..) { + Some(subtable) => { + subtable_codepoints(subtable, limit.saturating_sub(total))? + } + None => 0, + }; + counted.insert(offset, count); + count + } + }; + // Every record counts as at least one, even one that lists nothing, + // so that the record count alone moves toward the limit. + total += count.max(1); if total > limit { break; } @@ -169,14 +192,16 @@ fn subtable_codepoints(data: &[u8], limit: u64) -> Result { let count = match format { 0 => 256, 2 => { - // 256 sub-header keys, then sub-headers of 8 bytes each. + // 256 sub-header keys, then sub-headers of 8 bytes each. The + // parser visits every key, so each counts as at least one, even + // one whose sub-header lists nothing. let mut total = 0; for byte in 0..256 { let Some(key) = read_u16(data, 6 + 2 * byte) else { return Ok(0) }; let sub = usize::from(key / 8); total += match sub { 0 => 1, - _ => u64::from(read_u16(data, 518 + 8 * sub + 2).unwrap_or(0)), + _ => u64::from(read_u16(data, 518 + 8 * sub + 2).unwrap_or(0)).max(1), }; } total @@ -537,4 +562,24 @@ mod tests { let faces = font_faces(&two_cmaps(&reversed, &benign)).expect("a font"); assert_eq!(faces.len(), 1); } + + /// A format 2 subtable whose 256 keys all point at one empty sub-header. + fn format2_empty() -> Vec { + let mut out = vec![0, 2, 0, 0, 0, 0]; + for _ in 0..256 { + out.extend_from_slice(&8_u16.to_be_bytes()); + } + out.extend_from_slice(&[0; 16]); + let len = out.len() as u16; + out[2..4].copy_from_slice(&len.to_be_bytes()); + out + } + + #[test] + fn empty_format2_subtables_still_count_their_work() { + // Each record lists nothing, but the parser walks all 256 keys of its + // subtable, so 65,535 records are over 16 million steps. + let font = with_cmap(some_font(), &cmap(u16::MAX, &format2_empty())); + quickly_rejected(&font); + } } From a654abf0b37f97a15f0c25bdb943c58f755422aa Mon Sep 17 00:00:00 2001 From: Maxanox Date: Sat, 26 Sep 2026 03:17:28 +0200 Subject: [PATCH 10/10] docs(ide-wasm): say what the serif flag of a font face really is Co-Authored-By: Claude Opus 5.5 --- crates/typst-ide-wasm/src/fonts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-ide-wasm/src/fonts.rs b/crates/typst-ide-wasm/src/fonts.rs index 55173602d21b..62c5e5922c2a 100644 --- a/crates/typst-ide-wasm/src/fonts.rs +++ b/crates/typst-ide-wasm/src/fonts.rs @@ -29,7 +29,7 @@ pub struct FontFace { pub stretch: f64, /// All glyphs have the same width. pub monospace: bool, - /// Glyphs have serifs. + /// Typst's PANOSE-based serif guess. pub serif: bool, /// The face has a math table. pub math: bool,