web: raster images, WBLOCK/SAVEALL/SCRIPT/CTB/LAYTRANS/INSERT, filled TTF text

A fresh web-vs-desktop audit found the remaining gaps; this closes the
top ten.

Raster images: IMAGE attach read the picked file via image::open on a
name-only path (dead on wasm), and raster references inside opened
drawings decoded the same way. Picked bytes are now registered in a
name-keyed store (the PDF-underlay pattern), decoding runs from memory,
and dropped .png/.jpg/.bmp/.tif files register by name so unresolved
references in opened drawings re-resolve and render (and export to PDF).

Command gaps fixed with the bytes-at-pick / download_bytes pattern:
- WBLOCK now downloads the extracted block instead of failing on fs.
- SAVEALL saves every dirty tab instead of printing a no-op message.
- SCRIPT picks and reads the .scr file through the browser picker.
- CENTERLTYPEFILE picks the .lin/.dnl file and populates from text.
- Custom .ctb tables picked on web now parse, register for the
  session, appear in the plot dialog list, and apply (load_named
  previously returned builtin-only on wasm).
- LAYTRANS 'Load standards file' parses the picked drawing on the
  existing web worker.
- INSERT 'from file' in the block palette parses picked bytes and
  feeds the shared import path.

Text fidelity: TTF-named styles rendered as thin stroke glyphs on web
because the font database was empty. Fetched Noto faces are now
registered into it with a family alias table (Arial, Times New Roman,
Cambria, ... -> Noto Sans/Serif), so those styles render filled, shape,
export to PDF as vector fills, and appear in the style picker.
ViewCube labels request semibold again on web.

docs/native-vs-web.md: the hatch and font rows were stale (both work on
web now), the GPU row claimed WebGPU is used where available (the
backend is pinned to OpenGL), and the parallelism row missed the parse
worker — all corrected.

(cherry picked from commit c643a10e)

Ported onto main (#1717 review): conflicts resolved toward main — the
font-file lookup, PDF searchable text, purge service, block palette and
plot-style copy stay as they are on main; the web pieces (font registry
and aliases, session plot styles, SCRIPT/SAVEALL/CENTERLTYPEFILE picks,
block-from-file on the web) are layered on top.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 69fa7cae4f
commit 9cd898a17a
42 files changed
+1315 -155

No files matched your search

+23
View File
@@ -2468,6 +2468,29 @@ impl Scene {
self.bump_geometry();
}
/// Web drop flow: `names` are raster files whose bytes were just
/// registered (name-keyed). When this drawing references one of them by
/// name but carries no model for it — the reference pointed at the
/// author's machine and was unresolvable at open time — rebuild the image
/// models so the entity finally renders. Returns whether a rebuild ran.
pub fn resolve_registered_image_names(&mut self, names: &[String]) -> bool {
let matches_name = |img: &codec::entities::RasterImage| {
names
.iter()
.any(|name| crate::scene::model::image_model::same_file_name(&img.file_path, name))
};
let unresolved = self.document.entities().any(|entity| match entity {
EntityType::RasterImage(img) => {
matches_name(img) && !self.images.contains_key(&img.common.handle)
}
_ => false,
});
if unresolved {
self.populate_images_from_document();
}
unresolved
}
fn populate_images_from_document_unbumped(&mut self) {
self.images.clear();
let entries: Vec<(Handle, codec::entities::EntityType)> = self
+205 -6
View File
@@ -4,6 +4,7 @@
// from the RasterImage entity's insertion point, u/v vectors, and pixel size.
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
@@ -496,6 +497,82 @@ fn image_cache() -> &'static Mutex<HashMap<String, ImageCacheEntry>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Lower-cased trailing component of a path-style reference. Drawings store
/// image paths as the author wrote them (often an absolute foreign path), so
/// name — not full path — is the only component a dropped or picked file on
/// another machine can be expected to match.
pub(crate) fn file_name_key(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Do two image references name the same file? Compares trailing components
/// case-insensitively (the web has no path beyond the name at all, and Windows
/// paths are case-insensitive where the drawing was authored).
pub(crate) fn same_file_name(a: &str, b: &str) -> bool {
let (a, b) = (file_name_key(a), file_name_key(b));
!a.is_empty() && a == b
}
/// Keep bytes selected through a file dialog or dropped onto the page, keyed
/// by the reference the drawing will carry. The browser exposes no reusable
/// filesystem path, so `decode_reference` reads these instead of a disk file;
/// native builds also skip a second disk read. Registering invalidates every
/// memoised answer that names the same file, so a re-pick (or a drop that
/// finally supplies a missing reference) re-decodes instead of replaying a
/// stale success or a cached failure.
pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(path.to_string(), bytes);
if let Ok(mut cache) = image_cache().lock() {
cache.retain(|cached, _| !same_file_name(cached, path));
}
}
/// Every reference with bytes currently registered (picked or dropped).
/// Used after a web open settles to catch images registered while the load —
/// which builds the raster models — was already running.
pub fn registered_image_names() -> Vec<String> {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.keys()
.cloned()
.collect()
}
/// Bytes registered for exactly this reference.
fn registered_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(path)
.cloned()
}
/// Bytes registered under a different path that names the same file — the
/// web fallback for images inside opened drawings, whose stored path is the
/// original author's (a foreign absolute path no browser can open) while the
/// user dropped or picked only the bare file.
fn registered_bytes_by_name(path: &str) -> Option<Arc<Vec<u8>>> {
let cache = source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner());
cache
.iter()
.find(|(key, _)| same_file_name(key, path))
.map(|(_, bytes)| Arc::clone(bytes))
}
/// Drop every memoised image so the next resolve re-reads / re-fetches. Called
/// when a new document is opened — a fresh drawing shouldn't inherit a prior
/// one's stale successes or offline failures.
@@ -529,15 +606,42 @@ pub fn resolve_image(path: &str) -> Option<DecodedImage> {
entry.get_or_init(|| decode_reference(path)).clone()
}
/// Decode a reference (local path or remote URL) to RGBA pixels.
/// Decode a reference to RGBA pixels. Priority: registered bytes (picked or
/// dropped — the only source on the web, where the reference is a name-only
/// path), then the filesystem (native), then any registered file that merely
/// shares the name (a drawing's foreign absolute path vs a dropped file).
/// `http`/`https` references stay a native-only network fetch.
fn decode_reference(path: &str) -> Option<DecodedImage> {
let lower = path.to_ascii_lowercase();
let img = if lower.starts_with("http://") || lower.starts_with("https://") {
if lower.starts_with("http://") || lower.starts_with("https://") {
let bytes = fetch_remote(path)?;
image::load_from_memory(&bytes).ok()?
} else {
image::open(Path::new(path)).ok()?
};
return decode_from_memory(&bytes);
}
if let Some(bytes) = registered_bytes(path) {
return decode_from_memory(&bytes);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(decoded) = decode_from_file(path) {
return Some(decoded);
}
registered_bytes_by_name(path)
.as_deref()
.and_then(|bytes| decode_from_memory(bytes))
}
/// Decode in-memory image bytes (picked file, drop, or network body) to RGBA.
fn decode_from_memory(bytes: &[u8]) -> Option<DecodedImage> {
let img = image::load_from_memory(bytes).ok()?;
rgba_image(img)
}
#[cfg(not(target_arch = "wasm32"))]
fn decode_from_file(path: &str) -> Option<DecodedImage> {
let img = image::open(Path::new(path)).ok()?;
rgba_image(img)
}
fn rgba_image(img: image::DynamicImage) -> Option<DecodedImage> {
let rgba = img.into_rgba8();
let (width, height) = rgba.dimensions();
Some(DecodedImage {
@@ -575,3 +679,98 @@ fn fetch_remote(url: &str) -> Option<Vec<u8>> {
fn fetch_remote(_url: &str) -> Option<Vec<u8>> {
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
/// Encode a small deterministic PNG entirely in memory — the same codec
/// `decode_from_memory` reads, so no fixture file is needed.
fn png_bytes(w: u32, h: u32, tint: u8) -> Vec<u8> {
let img =
image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([x as u8, y as u8, tint, 0xff]));
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(img)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.expect("encode test png");
out
}
/// Registered bytes decode without any filesystem path — the web attach
/// flow, where the reference is the picked file's name and nothing else.
#[test]
fn registered_bytes_decode_without_a_filesystem_path() {
let path = "memory://image-registry-exact.png";
register_source(path, Arc::new(png_bytes(7, 5, 0x42)));
let decoded = resolve_image(path).expect("registered bytes should decode");
assert_eq!((decoded.width, decoded.height), (7, 5));
assert_eq!(decoded.pixels.len(), 7 * 5 * 4);
// First texel is (x=0, y=0) — row 0 counted from the top in the PNG.
assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x42]);
}
/// A re-registration replaces the bytes AND the memoised decode: picking
/// the same name again must show the new pixels, not the cached old ones.
#[test]
fn reregistering_replaces_the_memoised_decode() {
let path = "memory://image-registry-repick.png";
register_source(path, Arc::new(png_bytes(3, 3, 0x11)));
assert_eq!(resolve_image(path).expect("first decode").width, 3);
register_source(path, Arc::new(png_bytes(9, 4, 0x22)));
let decoded = resolve_image(path).expect("second decode");
assert_eq!((decoded.width, decoded.height), (9, 4));
assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x22]);
}
/// The opened-drawing flow: the entity stores the author's absolute path,
/// which exists on no machine but this one's — a registered file that
/// merely shares the (case-insensitive) name resolves it.
#[test]
fn a_name_only_match_resolves_a_foreign_path() {
register_source("Registry-Name.png", Arc::new(png_bytes(4, 2, 0x33)));
let foreign = if cfg!(windows) {
"C:\\far away\\registry-name.PNG"
} else {
"/mnt/far away/registry-name.PNG"
};
let decoded = resolve_image(foreign).expect("name match should decode");
assert_eq!((decoded.width, decoded.height), (4, 2));
assert_eq!(decoded.pixels.len(), 4 * 2 * 4);
}
/// A cached failure must not outlive the registration that fixes it: the
/// drop flow registers bytes after the open already memoised `None`.
#[test]
fn registering_after_a_failed_resolve_retries_the_decode() {
let path = "memory://image-registry-late.png";
let foreign = format!("/definitely/not/here/{path}");
assert!(resolve_image(&foreign).is_none(), "nothing registered yet");
register_source(path, Arc::new(png_bytes(2, 2, 0x44)));
let decoded = resolve_image(&foreign).expect("late registration should resolve");
assert_eq!((decoded.width, decoded.height), (2, 2));
}
/// A reference nothing can satisfy stays `None` (and stays memoised as
/// such) — the placeholder keeps its broken-reference treatment.
#[test]
fn an_unmatchable_reference_stays_none() {
assert!(resolve_image("memory://image-registry-missing.png").is_none());
assert!(resolve_image("").is_none());
assert!(resolve_image(" ").is_none());
}
/// The name comparison keys on the trailing component only, ignoring
/// case and separators, and rejects empty names.
#[test]
fn file_name_comparison_rules() {
assert!(same_file_name("C:\\A\\B\\site.png", "site.png"));
assert!(same_file_name("/a/b/SITE.PNG", "site.PNG"));
assert!(!same_file_name("site.png", "site.jpg"));
assert!(!same_file_name("", ""));
assert!(!same_file_name("C:\\dir\\", "dir"));
}
}
+3 -1
View File
@@ -176,7 +176,9 @@ fn font_system(
Some((
FontSystem::new_with_locale_and_db("en-US".to_owned(), database),
Some(script.family()),
Weight::NORMAL,
// Match the native request; fontdb picks the nearest available weight
// (the subsets ship Regular) and `embolden_alpha` adds the thickness.
Weight::SEMIBOLD,
))
}
+305 -45
View File
@@ -5,14 +5,21 @@
// picker and (b) borrow a face's raw bytes to extract glyph outlines (see the
// TTF glyph engine). LFF stroke fonts stay separate — this is purely the
// TrueType side of the renderer.
//
// The web build has no installed fonts to scan (`load_system_fonts` finds
// nothing on wasm), so the database is instead populated at runtime with the
// Noto subsets `web_font` fetches (#141): each completed fetch is registered
// via [`register_web_font`], and a static alias table maps the common
// office/CAD families (Arial, Cambria, …) onto that Noto set so styles naming
// them resolve to filled TTF glyphs instead of falling back to LFF strokes.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
struct SysFonts {
db: fontdb::Database,
/// Sorted, de-duplicated family names for the picker.
families: Vec<String>,
db: Mutex<fontdb::Database>,
/// Sorted, de-duplicated family names for the picker and name resolution.
families: Mutex<Vec<String>>,
/// Installed face file name (lower-case) → the family that face holds.
/// A drawing references a TrueType style by *file* name, so this is what
/// makes such a style resolve — see [`family_for_reference`].
@@ -23,16 +30,22 @@ static FONTS: OnceLock<SysFonts> = OnceLock::new();
fn fonts() -> &'static SysFonts {
FONTS.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
// No system fonts to scan on the web — the database is filled from the
// fetched Noto subsets instead (see `register_web_font`).
#[cfg(not(target_arch = "wasm32"))]
let db = {
let mut db = fontdb::Database::new();
db.load_system_fonts();
db
};
#[cfg(target_arch = "wasm32")]
let db = fontdb::Database::new();
let mut families: Vec<String> = Vec::new();
let mut by_file: HashMap<String, String> = HashMap::new();
for face in db.faces() {
let Some((family, _)) = face.families.first() else {
continue;
};
families.push(family.clone());
// Remember every face file: a DWG style names the file, and the
// family inside it is usually a different string.
if let fontdb::Source::File(path) = &face.source {
@@ -43,37 +56,145 @@ fn fonts() -> &'static SysFonts {
}
}
}
families.sort_by_key(|n| n.to_lowercase());
families.dedup();
let families = family_names(&db);
SysFonts {
db,
families,
db: Mutex::new(db),
families: Mutex::new(families),
by_file,
}
})
}
/// All installed system font families, sorted case-insensitively, de-duped.
pub fn families() -> &'static [String] {
&fonts().families
/// Sorted, de-duplicated family names of every face currently in `db`.
fn family_names(db: &fontdb::Database) -> Vec<String> {
let mut families: Vec<String> = db
.faces()
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
.collect();
families.sort_by_key(|n| n.to_lowercase());
families.dedup();
families
}
/// All known system font families, sorted case-insensitively, de-duped. On the
/// web this is the fetched Noto set, so it grows as fetches complete.
pub fn families() -> Vec<String> {
fonts().families.lock().unwrap().clone()
}
/// Sans families aliased to the Noto stand-in on the web.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
static SANS_ALIASES: &[&str] = &[
"Arial",
"Calibri",
"DejaVu Sans",
"Helvetica",
"Liberation Sans",
"Segoe UI",
"Tahoma",
"Verdana",
];
/// Serif families aliased on the web; they prefer Noto Serif when such a
/// subset is ever fetched and otherwise degrade to Noto Sans.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
static SERIF_ALIASES: &[&str] = &[
"Book Antiqua",
"Cambria",
"Constantia",
"DejaVu Serif",
"Garamond",
"Georgia",
"Liberation Serif",
"Palatino Linotype",
"Times New Roman",
];
/// Preferred stand-in families for an alias request (lowercase), in order.
/// `None` when the name is not a known alias. Pure table lookup, so it is unit
/// tested on every platform even though only the web resolution path uses it.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn alias_targets(family_lower: &str) -> Option<&'static [&'static str]> {
const SANS: &[&str] = &["Noto Sans"];
const SERIF: &[&str] = &["Noto Serif", "Noto Sans"];
if SANS_ALIASES
.iter()
.any(|alias| alias.eq_ignore_ascii_case(family_lower))
{
Some(SANS)
} else if SERIF_ALIASES
.iter()
.any(|alias| alias.eq_ignore_ascii_case(family_lower))
{
Some(SERIF)
} else {
None
}
}
/// First alias stand-in that `known` reports as available. `known` receives
/// candidate family names; on the live path it scans the registered families.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn alias_resolution(family_lower: &str, known: impl Fn(&str) -> bool) -> Option<&'static str> {
alias_targets(family_lower)?
.iter()
.copied()
.find(|t| known(t))
}
/// Append the alias names to a family list when the sans stand-in is present
/// (every fetched subset carries the Latin face, so one successful fetch makes
/// all aliases resolvable). Pure, so the picker behaviour is unit testable.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn merge_web_aliases(families: &mut Vec<String>) {
if !families.iter().any(|f| f.eq_ignore_ascii_case("Noto Sans")) {
return;
}
families.extend(
SANS_ALIASES
.iter()
.chain(SERIF_ALIASES.iter())
.map(|name| name.to_string()),
);
families.sort_by_key(|n| n.to_lowercase());
families.dedup();
}
/// Families offered by the text-style picker's "System fonts" list: installed
/// fonts natively; on the web the fetched Noto families plus the common
/// office/CAD family names that stand in via them.
pub fn picker_families() -> Vec<String> {
#[cfg(target_arch = "wasm32")]
{
let mut families = families();
merge_web_aliases(&mut families);
families
}
#[cfg(not(target_arch = "wasm32"))]
families()
}
fn canonical_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Resolve a requested family name to the canonical installed system family name (with exact case).
///
/// Memoised process-wide: `resolve_font` calls this once per word on the MTEXT
/// Memoised: `resolve_font` calls this once per word on the MTEXT
/// measure hot path for inline-`\f` TTF runs, and `Face::resolve` re-runs it
/// immediately after — the underlying fontdb query plus linear family scans are
/// not free. The cache keys on the raw request string; results are stable for
/// the process lifetime (the font DB is loaded once via `OnceLock`).
/// not free. The cache keys on the raw request string. Results are stable for
/// the process lifetime on desktop; on the web the cache is cleared whenever a
/// fetched Noto subset joins the database, because earlier `None` resolutions
/// (font still in flight) must not stick.
pub fn canonical_family_name(family: &str) -> Option<String> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(hit) = cache.lock().unwrap().get(family) {
if let Some(hit) = canonical_cache().lock().unwrap().get(family) {
return hit.clone();
}
let resolved = canonical_family_name_uncached(family);
cache
canonical_cache()
.lock()
.unwrap()
.insert(family.to_string(), resolved.clone());
@@ -81,30 +202,24 @@ pub fn canonical_family_name(family: &str) -> Option<String> {
}
fn canonical_family_name_uncached(family: &str) -> Option<String> {
let db = &fonts().db;
// Snapshot so the families lock is never held across the db lock below
// (`register_web_font` takes them in the opposite order, scoped).
let families = fonts().families.lock().unwrap().clone();
// 1. Try exact match first
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
..Default::default()
};
if db.query(&query).is_some() {
if let Some(canonical) = fonts()
.families
.iter()
.find(|&f| f.eq_ignore_ascii_case(family))
{
if fonts().db.lock().unwrap().query(&query).is_some() {
if let Some(canonical) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(canonical.clone());
}
return Some(family.to_string());
}
// 2. Try case-insensitive match on the families we have
if let Some(matched) = fonts()
.families
.iter()
.find(|&f| f.eq_ignore_ascii_case(family))
{
if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) {
return Some(matched.clone());
}
@@ -119,8 +234,7 @@ fn canonical_family_name_uncached(family: &str) -> Option<String> {
};
if let Some(alias_name) = alias {
if let Some(matched) = fonts()
.families
if let Some(matched) = families
.iter()
.find(|&f| f.eq_ignore_ascii_case(alias_name))
{
@@ -128,12 +242,23 @@ fn canonical_family_name_uncached(family: &str) -> Option<String> {
}
}
// 4. Try matching prefix/subset case-insensitively. Require at least 3
// 4. Web: the common office/CAD families the client cannot have installed
// stand in via the fetched Noto set (#141). Runs before the fuzzy prefix
// match so "Arial" can never drift onto an arbitrary Noto name.
#[cfg(target_arch = "wasm32")]
if let Some(target) = alias_resolution(&family_lower, |name| {
families.iter().any(|f| f.eq_ignore_ascii_case(name))
}) {
if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(target)) {
return Some(matched.clone());
}
}
// 5. Try matching prefix/subset case-insensitively. Require at least 3
// chars so a 1–2 letter request can't grab an arbitrary family by the
// first iteration order. Iterating sorted keeps the pick deterministic.
if family_lower.len() >= 3 {
let mut candidates: Vec<&String> = fonts()
.families
let mut candidates: Vec<&String> = families
.iter()
.filter(|&f| {
let f_low = f.to_lowercase();
@@ -158,14 +283,13 @@ fn face_id(family: &str) -> Option<fontdb::ID> {
/// (e.g. bold runs). Falls back through fontdb's fuzzy matching, so a
/// family without a bold face still resolves — to its regular face.
fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option<fontdb::ID> {
let db = &fonts().db;
let canonical = canonical_family_name(family)?;
let query = fontdb::Query {
families: &[fontdb::Family::Name(&canonical)],
weight,
..Default::default()
};
db.query(&query)
fonts().db.lock().unwrap().query(&query)
}
/// Borrow the raw face bytes for `family` and run `f` over them. The byte slice
@@ -174,7 +298,7 @@ fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option<fontdb::ID>
/// within a TrueType collection. Returns `None` if the family is unknown.
pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option<T> {
let id = face_id(family)?;
fonts().db.with_face_data(id, f)
fonts().db.lock().unwrap().with_face_data(id, f)
}
/// Borrow the raw face bytes for `family` at `weight` (see
@@ -186,7 +310,7 @@ pub fn with_face_data_weighted<T>(
f: impl FnOnce(&[u8], u32) -> T,
) -> Option<T> {
let id = face_id_weighted(family, weight)?;
fonts().db.with_face_data(id, f)
fonts().db.lock().unwrap().with_face_data(id, f)
}
/// The family of the installed face whose file is named `name` — a bare file
@@ -203,7 +327,7 @@ fn family_for_file(name: &str) -> Option<String> {
/// nothing installed answers to it.
///
/// A DWG text style stores the TrueType font *file* (`GOST2304_TypeA_italic.ttf`,
/// sometimes with a directory), which is what AutoCAD resolves; the family name
/// sometimes with a directory), which is what the style resolves by; the family name
/// lives inside the file and is usually not the same string. Inline `\f` codes
/// carry either form, so both are tried.
pub fn family_for_reference(reference: &str) -> Option<String> {
@@ -223,6 +347,30 @@ pub fn has_family(family: &str) -> bool {
face_id(family).is_some()
}
/// Web: add a fetched font's bytes (a Noto subset, see `web_font`) to the
/// database so family-name resolution starts finding it. Fetches land after
/// the first text layout pass, so the family list is rebuilt and the
/// canonical-name memo cleared — negative resolutions from while the font was
/// in flight must not stick. Callers invalidate the glyph caches separately
/// (`ttf_glyph::clear_fallback_cache`).
///
/// Faces may repeat across subsets (every file carries the Latin face, and the
/// CJK collections repeat it per language); `family_names` de-duplicates the
/// list, and fontdb picks one of the identical Latin faces per query.
#[cfg(target_arch = "wasm32")]
pub fn register_web_font(bytes: &[u8]) {
let sys = fonts();
let names = {
let mut db = sys.db.lock().unwrap();
db.load_font_data(bytes.to_vec());
family_names(&db)
// db lock released before the families lock is taken — the resolution
// path takes them in the opposite order.
};
*sys.families.lock().unwrap() = names;
canonical_cache().lock().unwrap().clear();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -232,7 +380,7 @@ mod tests {
/// and whether it comes as a bare name or as a path.
#[test]
fn face_files_resolve_to_their_family() {
let found = fonts().db.faces().find_map(|face| match &face.source {
let found = fonts().db.lock().unwrap().faces().find_map(|face| match &face.source {
fontdb::Source::File(path) => {
let file = path.file_name()?.to_string_lossy().into_owned();
let family = face.families.first().map(|(name, _)| name.clone())?;
@@ -257,4 +405,116 @@ mod tests {
assert_eq!(family_for_file("no-such-face-9f3.ttf"), None);
assert_eq!(family_for_reference(" "), None);
}
#[test]
fn sans_aliases_map_to_noto_sans() {
for name in [
"Arial",
"Helvetica",
"Verdana",
"Tahoma",
"Calibri",
"Segoe UI",
"Liberation Sans",
"DejaVu Sans",
] {
assert_eq!(
alias_targets(&name.to_lowercase()),
Some(&["Noto Sans"][..]),
"{name} must alias to Noto Sans"
);
}
}
#[test]
fn serif_aliases_prefer_serif_with_sans_fallback() {
for name in [
"Cambria",
"Times New Roman",
"Georgia",
"Liberation Serif",
"DejaVu Serif",
] {
assert_eq!(
alias_targets(&name.to_lowercase()),
Some(&["Noto Serif", "Noto Sans"][..]),
"{name} must alias to Noto Serif first, Noto Sans second"
);
}
}
#[test]
fn alias_resolution_is_case_insensitive_and_needs_a_target() {
// Only the sans stand-in available (today's web fetch set): a serif
// alias degrades to Noto Sans rather than resolving to nothing.
assert_eq!(
alias_resolution("arial", |f| f == "Noto Sans"),
Some("Noto Sans")
);
assert_eq!(
alias_resolution("ARIAL", |f| f == "Noto Sans"),
Some("Noto Sans")
);
assert_eq!(
alias_resolution("cambria", |f| f == "Noto Sans"),
Some("Noto Sans")
);
// Serif preferred when a serif subset is present.
assert_eq!(
alias_resolution("cambria", |f| f == "Noto Serif"),
Some("Noto Serif")
);
// Nothing registered yet — fetch still in flight.
assert_eq!(alias_resolution("arial", |_| false), None);
// Unknown / stroke-font names are never aliased.
assert_eq!(alias_resolution("txt", |_| true), None);
assert_eq!(alias_resolution("isocp", |_| true), None);
assert_eq!(alias_resolution("monospace", |_| true), None);
}
#[test]
fn picker_aliases_only_once_noto_arrives() {
// Before any fetch lands the picker must not offer unresolvable names.
let mut empty: Vec<String> = Vec::new();
merge_web_aliases(&mut empty);
assert!(empty.is_empty());
// With the Noto set present the aliases join, sorted and de-duplicated.
let mut with_noto = vec!["Noto Sans CJK SC".to_string(), "Noto Sans".to_string()];
merge_web_aliases(&mut with_noto);
assert_eq!(
with_noto,
vec![
"Arial".to_string(),
"Book Antiqua".to_string(),
"Calibri".to_string(),
"Cambria".to_string(),
"Constantia".to_string(),
"DejaVu Sans".to_string(),
"DejaVu Serif".to_string(),
"Garamond".to_string(),
"Georgia".to_string(),
"Helvetica".to_string(),
"Liberation Sans".to_string(),
"Liberation Serif".to_string(),
"Noto Sans".to_string(),
"Noto Sans CJK SC".to_string(),
"Palatino Linotype".to_string(),
"Segoe UI".to_string(),
"Tahoma".to_string(),
"Times New Roman".to_string(),
"Verdana".to_string(),
]
);
// A registry that already carries an alias name (case differs) dedupes.
let mut dup = vec!["noto sans".to_string()];
merge_web_aliases(&mut dup);
assert_eq!(
dup.iter()
.filter(|f| f.eq_ignore_ascii_case("Noto Sans"))
.count(),
1
);
}
}
+6
View File
@@ -692,6 +692,12 @@ pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
pub fn clear_fallback_cache() {
fallback_cache().lock().unwrap().clear();
shape_cache().lock().unwrap().clear();
// Web only: the per-family outline cache can also hold stale `None`s —
// the family only became resolvable when the font joined the sysfont
// database (`register_web_font`). On desktop the font set never changes,
// so the cache is kept.
#[cfg(target_arch = "wasm32")]
cache().lock().unwrap().clear();
#[cfg(target_arch = "wasm32")]
crate::scene::text::sdf_atlas::reset_font_entries();
}
+25 -14
View File
@@ -293,23 +293,34 @@ mod imp {
/// Record a fetch result: `Some(bytes)` on success, `None` on failure.
pub fn insert(script: Script, bytes: Option<Vec<u8>>) {
let mut store = store().lock().unwrap();
match bytes {
Some(bytes) => {
let bytes = Arc::new(bytes);
store.states.insert(script, State::Loaded(bytes.clone()));
if script != Script::Latin {
store
.states
.entry(Script::Latin)
.or_insert_with(|| State::Loaded(bytes));
let mut fetched: Option<Arc<Vec<u8>>> = None;
{
let mut store = store().lock().unwrap();
match bytes {
Some(bytes) => {
let bytes = Arc::new(bytes);
store.states.insert(script, State::Loaded(bytes.clone()));
if script != Script::Latin {
store
.states
.entry(Script::Latin)
.or_insert_with(|| State::Loaded(bytes.clone()));
}
fetched = Some(bytes);
}
None => {
store.states.insert(script, State::Failed);
}
}
None => {
store.states.insert(script, State::Failed);
}
}
drop(store);
// Join the system-font database before the generation bump wakes
// consumers: a text style naming a TTF family (or one of its aliases,
// e.g. Arial → Noto Sans) then resolves to filled glyphs instead of
// LFF strokes. Family-name memo + glyph caches are invalidated by the
// caller (`clear_fallback_cache`) on the same message.
if let Some(bytes) = fetched {
crate::scene::text::sysfont::register_web_font(&bytes);
}
super::bump_generation();
}