CJK 1.9: document fonts belong to their document

The fonts in a document's Document Fonts folder are registered under a
font scope that the document holds while it is open (Document::font_scope,
runtime only; DocState keeps the scope alive and closes it when the last
copy of the state goes: file.close, closing a tab, revert, a book
chapter's export, the session ending). Composition, preflight, font.list,
Package and the font menus, Glyphs panel and previews look fonts up
through FontDb::scoped(scope): the document's own faces first, then the
shared ones. Other open documents don't see them, and two documents with
fonts of the same name each set text in their own. Composite fonts'
member fonts and the fallback for kenten marks are looked up the same
way.

Font files stay in memory for the session (FaceRef keeps faces for the
process); a document opened again reuses the faces of its unchanged files
(same path, length and modification time) instead of reading them again.
The composition cache's signature includes the scope; the word cache and
the outline and PDF font caches are keyed by face. Variable font
instances are kept per base face.

A family's installed faces join the shared fonts all at once, and a
face two lookups load at the same time is kept once, so a parallel
fallback lookup never takes a family's bold before its regular has
loaded.
This commit is contained in:
aiongg committed 2026-10-07 10:44:46 -04:00
1 parent e6e843dc26
commit 5f52150b31
28 files changed
+753 -317

No files matched your search

+1 -1
View File
@@ -147,7 +147,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files it uses in a Document Fonts folder (except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts stay loaded until DesignCraft quits, available to every open document; on the web there is no folder to read.
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
+3 -1
View File
@@ -240,7 +240,9 @@ impl Cache {
}
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
let mut sig = vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len()];
// The document's font scope: another document's fonts of the same name set it differently.
let mut sig =
vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize];
let mut keep = Vec::new();
let mut spreads = Vec::new();
for f in &story.frames {
+6 -6
View File
@@ -30,7 +30,7 @@ use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
VerticalJustification, WrapMode, story,
};
use designcraft_fonts::FontDb;
use designcraft_fonts::{FontDb, ScopedFonts};
use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
@@ -443,6 +443,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
}
fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory {
let db = &db.scoped(doc.font_scope);
let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() };
let mut styles_tab: Vec<RunStyle> = Vec::new();
let mut missing_fonts: HashMap<String, bool> = HashMap::new();
@@ -843,7 +844,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let last = k + 1 == breaks.len();
let (mut placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
ruby::annotate(&styles_tab, &mut placed);
ruby::annotate(db, &styles_tab, &mut placed);
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
let range_start = if line_no == 0 { prange.start } else { range_start };
@@ -1427,13 +1428,12 @@ fn line_metrics(
s: usize,
base_leading: f64,
base_size: f64,
db: &FontDb,
db: &ScopedFonts<'_>,
base: &designcraft_doc::CharProps,
) -> (f64, f64, f64) {
let _ = db;
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
if src.is_empty() {
let face = FontDb::global().face(&base.font_family, &base.font_style);
let face = db.face(&base.font_family, &base.font_style);
let k = base_size / face.upem;
return (face.ascent * k, face.descent * k, base_leading);
}
@@ -1986,7 +1986,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
}
fn prepend_label(
db: &FontDb,
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
label: &str,
at: usize,
+6 -5
View File
@@ -1,6 +1,7 @@
//! Ruby and kenten: small glyphs set over laid-out text (to its right in vertical frames, where
//! the frame's turn carries "above" there). They don't change line breaks or leading.
use designcraft_fonts::ScopedFonts;
use designcraft_geom::Shape as _;
use crate::{PlacedGlyph, RunStyle, upright_in_vertical};
@@ -40,12 +41,12 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec<Placed
}
/// A sesame dot (or a bullet, when the font has none) centred over `b`.
fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
fn kenten(db: &ScopedFonts<'_>, b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
let character = if character.is_empty() { "\u{FE45}" } else { character };
let mut base = b.clone();
if let Some(c) = character.chars().next()
&& !base.face.covers(c)
&& let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id(), None)
&& let Some(face) = db.fallback_for(c, base.face.id(), None)
{
base.face = designcraft_fonts::FaceRef::of(&face);
}
@@ -68,7 +69,7 @@ fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
}
/// Add the ruby and kenten of a laid-out line's glyphs.
pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
pub(crate) fn annotate(db: &ScopedFonts<'_>, styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
let style = |g: &PlacedGlyph| styles.get(g.style as usize);
if !line.iter().any(|g| style(g).is_some_and(|s| s.ruby.is_some() || s.kenten)) {
return;
@@ -82,7 +83,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
continue;
};
if st.kenten && g.adv > 0.0 {
extra.extend(kenten(g, st.size, &st.kenten_character));
extra.extend(kenten(db, g, st.size, &st.kenten_character));
}
let Some(text) = &st.ruby else {
i += 1;
@@ -114,7 +115,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
extra.extend(glyphs);
// Kenten on the rest of the group.
for b in line[i + 1..j].iter().filter(|b| st.kenten && b.len > 0 && b.adv > 0.0) {
extra.extend(kenten(b, st.size, &st.kenten_character));
extra.extend(kenten(db, b, st.size, &st.kenten_character));
}
i = j;
}
+23 -17
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
use designcraft_fonts::{FaceRef, Feature, FontDb, FontFace, ShapedGlyph, feature};
use designcraft_fonts::{FaceRef, Feature, FontFace, ScopedFonts, ShapedGlyph, feature};
use crate::RunStyle;
@@ -131,7 +131,7 @@ pub(crate) struct StyleTable<'a> {
}
impl StyleTable<'_> {
fn intern(&mut self, p: &CharProps) -> u32 {
fn intern(&mut self, db: &ScopedFonts<'_>, p: &CharProps) -> u32 {
let rs = RunStyle {
fill: p.fill.clone(),
fill_tint: p.fill_tint,
@@ -154,10 +154,7 @@ impl StyleTable<'_> {
},
skew: p.skew,
size: p.size,
missing_font: *self
.missing
.entry(p.font_family.clone())
.or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)),
missing_font: *self.missing.entry(p.font_family.clone()).or_insert_with(|| !p.font_family.is_empty() && !db.has_family(&p.font_family)),
custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)),
condition: p.conditions.first().cloned(),
inserted: p.change == designcraft_doc::ChangeMark::Inserted,
@@ -176,7 +173,7 @@ impl StyleTable<'_> {
/// Resolve and shape one paragraph.
pub(crate) fn shape_para(
db: &FontDb,
db: &ScopedFonts<'_>,
styles: &Styles,
story: &Story,
pi: usize,
@@ -237,18 +234,18 @@ pub(crate) fn shape_para(
for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() {
let entry = font.entry(c);
if entry != selected && a + offset > start {
resolved.push((start, a + offset, composite_props(&props, selected), fmt, para_chars.clone()));
resolved.push((start, a + offset, composite_props(db, &props, selected), fmt, para_chars.clone()));
start = a + offset;
}
selected = entry;
}
resolved.push((start, b, composite_props(&props, selected), fmt, para_chars.clone()));
resolved.push((start, b, composite_props(db, &props, selected), fmt, para_chars.clone()));
} else {
resolved.push((a, b, props, fmt, para_chars.clone()));
}
}
for (a, b, props, fmt, resolved_base) in resolved {
let style = table.intern(&props);
let style = table.intern(db, &props);
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
@@ -278,11 +275,11 @@ pub(crate) fn shape_para(
}
rf.over.position = Some(sub.note_position);
let rprops = styles.resolve_char(&resolved_base, &rf);
let rstyle = table.intern(&rprops);
let rstyle = table.intern(db, &rprops);
let mut ef = fmt.clone();
ef.over.position = Some(designcraft_doc::Position::Superscript);
let eprops = styles.resolve_char(&resolved_base, &ef);
let estyle = table.intern(&eprops);
let estyle = table.intern(db, &eprops);
let mut k = a;
for (i, m) in story.text[a..b].match_indices(is_ref) {
let i = a + i;
@@ -395,7 +392,7 @@ fn is_mark(c: char) -> bool {
unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM || matches!(c, '\u{200C}' | '\u{200D}')
}
fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
fn composite_props(db: &ScopedFonts<'_>, base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
let mut p = base.clone();
if let Some(e) = entry {
p.font_family.clone_from(&e.family);
@@ -405,7 +402,7 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos
p.v_scale *= e.vertical_scale.clamp(0.01, 100.0);
p.baseline_shift += base.size * e.baseline_shift;
if e.scale_option {
let face = FontDb::global().face(&p.font_family, &p.font_style);
let face = db.face(&p.font_family, &p.font_style);
let (asc, desc) = face.vertical_metrics();
let center = (asc - desc) / (2.0 * (asc + desc).max(1e-9));
p.baseline_shift += (base.size * base.v_scale - p.size * p.v_scale) * center;
@@ -415,7 +412,16 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos
}
#[allow(clippy::too_many_arguments)]
fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharProps, env: TypeEnv, style: u32, sub: &SubstCtx, out: &mut Vec<Glyph>) {
fn shape_run(
db: &ScopedFonts<'_>,
text: &str,
range: std::ops::Range<usize>,
p: &CharProps,
env: TypeEnv,
style: u32,
sub: &SubstCtx,
out: &mut Vec<Glyph>,
) {
let start = out.len();
shape_run_raw(db, text, range, p, env, style, sub, out);
let run = &mut out[start..];
@@ -462,7 +468,7 @@ fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharPro
#[allow(clippy::too_many_arguments)]
fn shape_run_raw(
db: &FontDb,
db: &ScopedFonts<'_>,
text: &str,
range: std::ops::Range<usize>,
p: &CharProps,
@@ -774,7 +780,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
#[allow(clippy::too_many_arguments)]
fn shape_segment(
db: &FontDb,
db: &ScopedFonts<'_>,
text: &str,
range: std::ops::Range<usize>,
replacement: Option<&str>,
+60
View File
@@ -1506,6 +1506,66 @@ fn cjk_fallback_follows_the_language() {
assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone()));
}
#[test]
fn each_document_shapes_words_in_its_own_fonts() {
use designcraft_fonts::testing::font_with_glyph;
const FAMILY: &str = "DocFont Word Cache";
// Two documents' fonts of one name: the same words, drawn as `X` in one and `O` in the other.
let root = std::env::temp_dir().join(format!("dc-compose-docfonts-{}", std::process::id()));
let mut scopes = Vec::new();
for (name, glyph) in [("x", 'X'), ("o", 'O')] {
let folder = root.join(name);
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap();
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph));
}
let text = "ab ab ab ab";
let mut faces = Vec::new();
for (scope, glyph) in scopes {
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
d.font_scope = scope;
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let glyphs: Vec<&PlacedGlyph> =
all_lines(&cs).into_iter().flat_map(|l| l.glyphs.iter()).filter(|g| text[g.byte..].starts_with('a')).collect();
assert_eq!(glyphs.len(), 4);
let face = glyphs[0].face;
let drawn = designcraft_fonts::FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular").glyph_for(glyph);
assert!(glyphs.iter().all(|g| g.face == face && g.gid == drawn), "scope {scope}: {glyph}");
faces.push(face);
}
assert!(faces[0] != faces[1] && faces.iter().all(|f| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn composite_fonts_draw_from_the_documents_fonts() {
use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry};
use designcraft_fonts::testing::font_with_glyph;
const FAMILY: &str = "DocFont Composite Member";
// A composite font whose digits come from a font only the document has.
let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap();
let scope = FontDb::global().load_document_fonts(&folder).scope;
let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
d.font_scope = scope;
std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont {
name: "Mixed".into(),
entries: vec![
CompositeFontEntry { family: designcraft_fonts::DEFAULT_FAMILY.into(), ..Default::default() },
CompositeFontEntry { characters: "0123456789".into(), family: FAMILY.into(), ..Default::default() },
],
});
d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let digit = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
assert_eq!(digit.face.family, FAMILY);
assert_ne!(digit.gid, 0);
assert!(!cs.styles.iter().any(|s| s.missing_font), "the document's font isn't missing");
let _ = std::fs::remove_dir_all(&folder);
}
const KO_WORDS: &str = "한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다 한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다";
const KO_HANJA: &str = "大韓民國 憲法은 國民의 權利를 保障한다 大韓民國 憲法은 國民의 權利를 保障한다";
const KO_PUNCT: &str = "가나다라마。바사아자차」카타파하가、나다라마바。사아자차카」타파하가나、다라마바사。아자차카타」파하가나다、";
+1
View File
@@ -156,6 +156,7 @@ impl Document {
created: crate::vars::now(),
modified: 0,
next_id: 0,
font_scope: 0,
};
let lid = LayerId(d.alloc());
d.layers.push(Layer {
+5
View File
@@ -515,6 +515,11 @@ pub struct Document {
#[serde(default)]
pub modified: i64,
pub next_id: u64,
/// While the document is open, the font scope of the fonts it brought (its `Document Fonts`
/// folder): composition, export and the font menus look its fonts up there. 0: none. Not
/// saved.
#[serde(skip)]
pub font_scope: u32,
}
/// What a hyperlink is attached to.
+3 -3
View File
@@ -44,8 +44,6 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
let _ = path;
vec![]
};
// Skipped font files are logged; a chapter opens without them.
let _ = super::file::load_document_fonts(path);
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
@@ -198,7 +196,9 @@ pub fn specs() -> Vec<CommandSpec> {
let b = s.book.clone().ok_or_else(no_book)?;
let mut parts = Vec::new();
for path in &b.documents {
let d = load(path)?;
let mut d = load(path)?;
// The chapter's own fonts, for this export (skipped font files are logged).
let _fonts = super::file::load_document_fonts(&mut d, path).0;
let cache = designcraft_compose::Cache::new();
let r = designcraft_pdf::export_pdf_with_report(&d, &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let n = designcraft_render::pdf_page_count(&r.bytes).unwrap_or(0);
+143 -17
View File
@@ -160,9 +160,11 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
return super::interchange::open_idml(s, p);
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let d = from_bytes(&bytes)?;
let (faces, warnings) = load_document_fonts(path);
let i = s.add_document(DocState::new(d, Some(path.to_string())));
let mut d = from_bytes(&bytes)?;
let (fonts, faces, warnings) = load_document_fonts(&mut d, path);
let mut st = DocState::new(d, Some(path.to_string()));
st.fonts = fonts;
let i = s.add_document(st);
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
}
#[cfg(target_arch = "wasm32")]
@@ -172,20 +174,23 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Load the fonts in the `Document Fonts` folder beside the document file at `path` → (faces
/// loaded, a warning for each font file skipped). Nothing on the web (no folders).
pub(crate) fn load_document_fonts(path: &str) -> (usize, Vec<String>) {
/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone
/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each
/// font file skipped). Nothing on the web (no folders).
pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<crate::FontScope>>, usize, Vec<String>) {
#[cfg(not(target_arch = "wasm32"))]
{
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (0, Vec::new()) };
let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (None, 0, Vec::new()) };
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
(r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
d.font_scope = r.scope;
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
(scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
(0, Vec::new())
let _ = (d, path);
(None, 0, Vec::new())
}
}
@@ -597,15 +602,18 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
#[cfg(not(target_arch = "wasm32"))]
{
let d = if path.to_ascii_lowercase().ends_with(".idml") {
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
} else {
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
};
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
let (fonts, _, _) = load_document_fonts(&mut d, &path);
let uid = s.doc()?.uid;
let st = s.doc_mut()?;
let mut fresh = DocState::new(d, Some(path.clone()));
fresh.fonts = fonts;
fresh.uid = uid;
fresh.revision = st.revision + 1;
*st = fresh;
@@ -682,7 +690,7 @@ mod document_fonts_tests {
use std::path::{Path, PathBuf};
use designcraft_fonts::testing::font_with;
use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FontDb};
use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource};
use serde_json::{Value, json};
use crate::Session;
@@ -694,13 +702,22 @@ mod document_fonts_tests {
dir
}
/// A document whose text is set in `family`, written to `path` (`.idml`: as IDML).
fn write_document(path: &Path, family: &str) {
let mut s = Session::new();
/// A new document in `s` with a text frame holding `text` set in `family`.
fn new_document(s: &mut Session, family: &str, text: &str) {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap();
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
}
/// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML).
fn write_document(path: &Path, family: &str) {
write_document_with(path, family, "Hello");
}
fn write_document_with(path: &Path, family: &str, text: &str) {
let mut s = Session::new();
new_document(&mut s, family, text);
if path.extension().is_some_and(|e| e == "idml") {
std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
} else {
@@ -713,6 +730,23 @@ mod document_fonts_tests {
l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}"))
}
/// Each character of the active document's text with the face it is drawn in.
fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> {
let st = s.doc().unwrap();
let mut out = Vec::new();
for (sid, story) in &st.doc.stories {
let cs = s.cache.get(&st.doc, *sid, None);
for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) {
out.push((story.text[g.byte..].chars().next().unwrap(), g.face));
}
}
out
}
fn open(s: &mut Session, path: &Path) -> Value {
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap()
}
#[test]
fn opening_a_document_loads_its_document_fonts() {
const FAMILY: &str = "DocFont Open Test";
@@ -759,4 +793,96 @@ mod document_fonts_tests {
assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn document_fonts_belong_to_their_document() {
const FAMILY: &str = "DocFont Scoped";
let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b"));
std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
write_document(&a.join("A.designcraft"), FAMILY);
write_document(&b.join("B.designcraft"), FAMILY);
let mut s = Session::new();
assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1);
assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0);
// B, open at the same time, doesn't have A's font.
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s));
s.execute("file.activate", &json!({"index": 0})).unwrap();
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s));
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn each_document_sets_its_own_font_of_a_name() {
const FAMILY: &str = "DocFont Same";
let (a, b) = (temp_dir("same-a"), temp_dir("same-b"));
let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"));
// A's font has both letters, B's only `b`.
std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap();
std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap();
// The same words in both: the word cache must not hand one document's shaping to the other.
write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab");
write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab");
let mut s = Session::new();
open(&mut s, &a.join("A.designcraft"));
open(&mut s, &b.join("B.designcraft"));
let in_b = glyph_faces(&s);
s.execute("file.activate", &json!({"index": 0})).unwrap();
let in_a = glyph_faces(&s);
assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}");
for (c, f) in &in_a {
assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A");
}
for (c, f) in &in_b {
match c {
'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"),
_ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"),
}
}
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() {
const FAMILY: &str = "DocFont Closed";
let dir = temp_dir("closed");
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
let path = dir.join("Closed.designcraft");
write_document(&path, FAMILY);
let mut s = Session::new();
open(&mut s, &path);
let first = glyph_faces(&s)[0].1;
assert_eq!(first.family, FAMILY);
s.execute("file.close", &json!({})).unwrap();
// A new document can't use the closed document's font.
new_document(&mut s, FAMILY, "Hello");
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY));
// Reopened, the document has its font again: the face loaded the first time.
assert_eq!(open(&mut s, &path)["documentFonts"], 1);
assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_reverted_document_keeps_its_fonts() {
const FAMILY: &str = "DocFont Reverted";
let dir = temp_dir("revert");
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
let path = dir.join("Revert.designcraft");
write_document(&path, FAMILY);
let mut s = Session::new();
open(&mut s, &path);
s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) {
}
fn list(d: &Document) -> Vec<Value> {
let db = designcraft_fonts::FontDb::global();
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut used: BTreeMap<(String, String), usize> = BTreeMap::new();
for_each_story(d, &mut |st| {
let ranges = st.para_ranges();
+6 -4
View File
@@ -71,11 +71,13 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = name {
d.title = n;
}
let (faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
Some(path) => super::file::load_document_fonts(path),
None => (0, Vec::new()),
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
Some(path) => super::file::load_document_fonts(&mut d, path),
None => (None, 0, Vec::new()),
};
// Never save over the .idml with the native format: the document starts unsaved.
let i = s.add_document(DocState::new(d, None));
let mut st = DocState::new(d, None);
st.fonts = fonts;
let i = s.add_document(st);
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
}
+1 -1
View File
@@ -123,7 +123,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?;
files.insert(0, doc_path);
// The font files, and what the report says about each font.
let db = designcraft_fonts::FontDb::global();
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new());
let mut font_lines = Vec::new();
for f in fonts.as_array().into_iter().flatten() {
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let d = &st.doc;
let mut out = Vec::new();
// Stories: overset and fonts.
let db = designcraft_fonts::FontDb::global();
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut missing_fonts: Vec<String> = Vec::new();
let mut unsupported_typography = std::collections::BTreeSet::new();
for story in d.stories.values() {
+15
View File
@@ -91,6 +91,20 @@ pub struct DocState {
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: u64,
/// The fonts the document brought (`doc.font_scope`), found while it is open.
pub fonts: Option<Arc<FontScope>>,
}
/// A document's font scope ([`designcraft_fonts::FontDb::load_document_fonts`]): its fonts are
/// found until the document's state (with every copy of it) is gone — closed, reverted, or its
/// session ended.
#[derive(Debug)]
pub struct FontScope(pub u32);
impl Drop for FontScope {
fn drop(&mut self) {
designcraft_fonts::FontDb::global().close_scope(self.0);
}
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -111,6 +125,7 @@ impl DocState {
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
fonts: None,
}
}
pub fn is_dirty(&self) -> bool {
+4 -5
View File
@@ -86,16 +86,15 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
for (uid, meta) in list(dir) {
let (doc, _) = files(dir, uid);
let Ok(bytes) = std::fs::read(&doc) else { continue };
let d = match crate::cmd::from_bytes(&bytes) {
let mut d = match crate::cmd::from_bytes(&bytes) {
Ok(d) => d,
Err(_) => continue,
};
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
if let Some(p) = &path {
// Skipped font files are logged; the document opens without them.
let _ = crate::cmd::load_document_fonts(p);
}
// Skipped font files are logged; the document opens without them.
let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0);
let mut st = DocState::new(d, path);
st.fonts = fonts;
// Unsaved: the copy on disk (if any) is older than what was recovered.
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
st.revision += 1;
+358 -211
View File
@@ -75,9 +75,11 @@ pub const MAX_DOCUMENT_FONT_FILES: usize = 200;
pub const MAX_DOCUMENT_FONT_BYTES: u64 = 256 << 20;
pub const MAX_DOCUMENT_FONTS_TOTAL: u64 = 512 << 20;
/// What [`FontDb::load_document_fonts`] did: faces added, and a note for each file it skipped.
/// What [`FontDb::load_document_fonts`] did: the scope the document looks its fonts up in (0 when
/// the folder gave none), the faces in it, and a note for each file skipped.
#[derive(Debug, Default)]
pub struct DocumentFonts {
pub scope: u32,
pub faces: usize,
pub skipped: Vec<String>,
}
@@ -291,9 +293,18 @@ struct CatalogEntry {
path: std::path::PathBuf,
}
/// Process-wide font database.
/// Process-wide font database: the fonts every document shares (bundled, installed, added), and
/// each open document's own fonts under its scope (see [`FontDb::scoped`]).
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
/// Each open document's fonts (its `Document Fonts` folder), by scope.
scopes: RwLock<HashMap<u32, Arc<[Arc<FontFace>]>>>,
/// The document font files read, by path: (length, modification time, faces). A document
/// opened again finds its unchanged files here instead of reading them again.
#[cfg(not(target_arch = "wasm32"))]
document_files: Mutex<HashMap<std::path::PathBuf, (u64, Option<std::time::SystemTime>, Vec<Arc<FontFace>>)>>,
/// Variable font instances (`Style {wght:650}`), made on first use, by base face and style.
instances: RwLock<HashMap<(u32, String), Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
@@ -451,6 +462,9 @@ fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]>
}
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
/// Scopes are never reused: a closed document's scope finds no fonts ever after.
#[cfg(not(target_arch = "wasm32"))]
static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
@@ -458,6 +472,11 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
}
/// A face's family and style names (the default instance's style for a variable font).
/// Is a face of `family` and `style` among `faces`?
fn has_face(faces: &[Arc<FontFace>], family: &str, style: &str) -> bool {
faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style))
}
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
@@ -698,6 +717,10 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
scopes: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
document_files: Mutex::new(HashMap::new()),
instances: RwLock::new(HashMap::new()),
outlines: Mutex::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
@@ -739,33 +762,21 @@ impl FontDb {
self.catalog.read().unwrap_or_else(|e| e.into_inner())
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
#[cfg(not(target_arch = "wasm32"))]
v.extend(self.read_catalog().iter().map(|c| c.family.clone()));
v.sort_by_key(|a| a.to_lowercase());
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
v
/// What the document with font scope `scope` sees: its own fonts ahead of the shared ones.
/// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts.
pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> {
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() };
ScopedFonts { db: self, own }
}
/// Style names available for `family` (Regular first, then by weight).
/// The shared fonts' family names (see [`ScopedFonts::families`]).
pub fn families(&self) -> Vec<String> {
self.scoped(0).families()
}
/// The shared fonts' styles of `family` (see [`ScopedFonts::styles`]).
pub fn styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> = self
.read_faces()
.iter()
.filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{'))
.map(|f| (f.italic, f.weight, f.style.clone()))
.collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.read_catalog().iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
self.scoped(0).styles(family)
}
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
@@ -774,28 +785,32 @@ impl FontDb {
self.add_faces(bytes, FontSource::Memory)
}
/// Add the faces of a font file from `source`. A document's font is skipped only when the same
/// folder already gave that family and style (it stands in for other fonts of the name); any
/// other is skipped when a face of that family and style is loaded.
/// Add the faces of a font file from `source` to the shared fonts, skipping the family and
/// style already loaded.
fn add_faces(&self, bytes: Vec<u8>, source: FontSource) -> usize {
let faces = self.new_faces(bytes, source);
self.insert_faces(faces)
}
/// The faces of a font file from `source` whose family and style aren't loaded yet.
fn new_faces(&self, bytes: Vec<u8>, source: FontSource) -> Vec<FontFace> {
let data = Arc::new(bytes);
let folder = |s: &FontSource| match s {
FontSource::Document(p) => p.parent().map(std::path::Path::to_path_buf),
_ => None,
};
let doc_folder = folder(&source);
enumerate_faces(&data)
.into_iter()
.filter(|(_, family, style, _)| !has_face(&self.read_faces(), family, style))
.filter_map(|(i, family, style, coords)| make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords))
.collect()
}
/// Add `faces` to the shared fonts all at once, so a lookup sees all of them or none (not a
/// family's bold without its regular), skipping a family and style another thread has added
/// meanwhile. Returns the number added.
fn insert_faces(&self, new: Vec<FontFace>) -> usize {
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut added = 0;
for (i, family, style, coords) in enumerate_faces(&data) {
let same = |f: &&Arc<FontFace>| {
f.family.eq_ignore_ascii_case(&family)
&& f.style.eq_ignore_ascii_case(&style)
&& (doc_folder.is_none() || folder(&f.source) == doc_folder)
};
if self.read_faces().iter().any(|f| same(&f)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
for f in new {
if !has_face(&faces, &f.family, &f.style) {
faces.push(Arc::new(f));
added += 1;
}
}
@@ -875,24 +890,26 @@ impl FontDb {
p.dedup();
p
};
let mut any = false;
// The whole family arrives at once (see `insert_faces`).
let mut faces = Vec::new();
for p in paths {
if let Ok(data) = std::fs::read(&p) {
any |= self.add_faces(data, FontSource::Installed(p)) > 0;
faces.extend(self.new_faces(data, FontSource::Installed(p)));
}
}
any
self.insert_faces(faces) > 0
}
/// Load the font files (.ttf, .otf, .ttc, .otc; not in subfolders) of a document's `Document
/// Fonts` folder. Their faces come before other faces of the same family and style when a
/// family is looked up, and stay loaded. The files are untrusted: past
/// Fonts` folder under a new scope, for that document alone: looked up through
/// [`FontDb::scoped`], they come before the shared faces of the same family and style, until
/// [`FontDb::close_scope`]. A file loaded before and unchanged since (same length and
/// modification time) isn't read again: its faces are shared. The files are untrusted: past
/// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past
/// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A
/// missing folder loads nothing.
/// missing folder loads nothing (scope 0).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts {
use std::io::Read;
let mut out = DocumentFonts::default();
let Ok(entries) = std::fs::read_dir(folder) else { return out };
let mut files: Vec<std::path::PathBuf> = entries
@@ -905,197 +922,120 @@ impl FontDb {
.collect();
files.sort();
let mut total = 0u64;
let mut own: Vec<Arc<FontFace>> = Vec::new();
for (n, path) in files.iter().enumerate() {
if n == MAX_DOCUMENT_FONT_FILES {
out.skipped.push(format!("{} more font files not loaded (at most {MAX_DOCUMENT_FONT_FILES} are)", files.len() - n));
break;
}
let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
let mut data = Vec::new();
let too_large = format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20);
let over_total = format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20);
// The listed length skips oversized files unread; one byte past the limit while reading
// catches a file that grew since.
let listed = std::fs::metadata(path).map_or(0, |m| m.len());
let read = if listed > MAX_DOCUMENT_FONT_BYTES || total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL {
Ok(0)
// The listed length skips oversized files unread.
let meta = std::fs::metadata(path).ok();
let listed = meta.as_ref().map_or(0, |m| m.len());
let faces = if listed > MAX_DOCUMENT_FONT_BYTES {
Err(too_large())
} else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL {
Err(over_total())
} else {
std::fs::File::open(path).and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data))
let modified = meta.and_then(|m| m.modified().ok());
self.document_file(path, listed, modified, MAX_DOCUMENT_FONTS_TOTAL.saturating_sub(total))
};
let reason = match read {
Err(e) => Some(format!("can't be read ({e})")),
Ok(_) if listed > MAX_DOCUMENT_FONT_BYTES => Some(too_large),
Ok(len) if len as u64 > MAX_DOCUMENT_FONT_BYTES => Some(too_large),
Ok(len) if total.saturating_add(listed.max(len as u64)) > MAX_DOCUMENT_FONTS_TOTAL => Some(over_total),
Ok(_) if enumerate_faces(&data).is_empty() => Some("not a font DesignCraft can read".into()),
Ok(len) => {
total = total.saturating_add(len as u64);
None
match faces {
Err(r) => out.skipped.push(format!("{name}: {r}")),
Ok((faces, len)) => {
total = total.saturating_add(len);
// A family and style the folder already gave stays with its first file.
for f in faces {
if !own.iter().any(|o| o.family.eq_ignore_ascii_case(&f.family) && o.style.eq_ignore_ascii_case(&f.style)) {
own.push(f);
}
}
}
};
match reason {
Some(r) => out.skipped.push(format!("{name}: {r}")),
None => out.faces += self.add_faces(data, FontSource::Document(path.clone())),
}
}
for s in &out.skipped {
log::warn!("{}: {s}", folder.display());
}
if !own.is_empty() {
out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed);
out.faces = own.len();
self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into());
}
out
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before.
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
if let Some(f) = self.find(family, style) {
return f;
}
#[cfg(not(target_arch = "wasm32"))]
if self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
/// The faces of the document font file at `path` (`listed` bytes long, modified at
/// `modified`): those read before if it hasn't changed since, else read now, within `room`
/// bytes of the folder's total → (faces, bytes), or why the file is skipped.
#[cfg(not(target_arch = "wasm32"))]
fn document_file(
&self,
path: &std::path::Path,
listed: u64,
modified: Option<std::time::SystemTime>,
room: u64,
) -> Result<(Vec<Arc<FontFace>>, u64), String> {
use std::io::Read;
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path)
&& (*len, *m) == (listed, modified)
{
return f;
return Ok((faces.clone(), listed));
}
self.find(FALLBACK_FAMILY, style)
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
.or_else(|| self.read_faces().first().cloned())
.unwrap_or_else(last_resort_face)
// One byte past the limit catches a file that grew since it was listed.
let mut data = Vec::new();
std::fs::File::open(path)
.and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data))
.map_err(|e| format!("can't be read ({e})"))?;
let len = data.len() as u64;
if len > MAX_DOCUMENT_FONT_BYTES {
return Err(too_large());
}
if listed.max(len) > room {
return Err(over_total());
}
let data = Arc::new(data);
let faces: Vec<Arc<FontFace>> = enumerate_faces(&data)
.into_iter()
.filter_map(|(i, family, style, coords)| {
make_face(FontBytes::Owned(data.clone()), FontSource::Document(path.to_path_buf()), i, family, style, coords)
})
.map(Arc::new)
.collect();
if faces.is_empty() {
return Err("not a font DesignCraft can read".into());
}
self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone()));
Ok((faces, len))
}
/// Is `family` available (loaded, or installed on the system)?
/// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their
/// files stay read, for the document opening again.
pub fn close_scope(&self, scope: u32) {
self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope);
}
/// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]).
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
self.scoped(0).face(family, style)
}
/// Is `family` among the shared fonts (loaded, or installed on the system)?
pub fn has_family(&self, family: &str) -> bool {
if self.is_loaded(family) {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) {
return true;
}
false
self.scoped(0).has_family(family)
}
fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
/// A shared variable font's axes (see [`ScopedFonts::axes`]).
pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> {
let face = self.face(family, base_style(style));
let Some(f) = face.skrifa() else { return vec![] };
f.axes()
.iter()
.map(|a| {
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
(tag, name, a.min_value(), a.default_value(), a.max_value())
})
.collect()
self.scoped(0).axes(family, style)
}
/// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use.
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
let k = k.trim().as_bytes();
let v: f32 = v.trim().parse().ok()?;
if k.len() != 4 {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
}
}
let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
let f = Arc::new(f);
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
Some(f)
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) {
return Some(f.clone());
}
}
return self.instance(family, style);
}
let faces = self.read_faces();
// Document fonts first, the most recently loaded first: they stand in for other fonts of
// the same name.
let (mut cands, others): (Vec<&Arc<FontFace>>, Vec<&Arc<FontFace>>) =
faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).partition(|f| matches!(f.source, FontSource::Document(_)));
cands.reverse();
cands.extend(others);
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
.min_by(|a, b| {
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
sa.total_cmp(&sb)
})
.map(|f| (*f).clone())
}
/// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the
/// first face (fallback family first, then load order) that covers `c`. On native, system
/// fonts are cataloged and loaded lazily the first time no loaded face covers a character.
/// A shared face other than `exclude` that covers `c` (see [`ScopedFonts::fallback_for`]).
pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> {
if let Some(chain) = cjk_chain(c, language)
&& let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude))
{
return Some(f);
}
if let Some(f) = self.loaded_fallback(c, exclude) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback(c) {
return self.loaded_fallback(c, exclude);
}
None
}
/// The face of `family` (regular first) that covers `c`, loading the installed family the
/// first time a chain asks for it (native, while the system fallback is on).
fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option<Arc<FontFace>> {
#[cfg(not(target_arch = "wasm32"))]
if !self.is_loaded(family) {
// Held while loading, so a parallel lookup waits for the font instead of passing it by.
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if sys.enabled && sys.chain_tried.insert(family) {
self.load_cataloged(family);
}
}
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect();
order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
self.scoped(0).fallback_for(c, exclude, language)
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
@@ -1161,6 +1101,213 @@ impl FontDb {
}
}
/// One document's view of the [`FontDb`] ([`FontDb::scoped`]): the fonts of its `Document Fonts`
/// folder, then the shared fonts (bundled, installed, added). Other documents' fonts aren't in it.
#[derive(Clone)]
pub struct ScopedFonts<'a> {
db: &'a FontDb,
/// The document's own faces, in the folder's file order (none without a scope).
own: Option<Arc<[Arc<FontFace>]>>,
}
impl ScopedFonts<'_> {
fn own(&self) -> &[Arc<FontFace>] {
self.own.as_deref().unwrap_or(&[])
}
/// Family names available (the document's, loaded and cataloged system fonts), sorted and
/// deduplicated.
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = self.own().iter().chain(self.db.read_faces().iter()).map(|f| f.family.clone()).collect();
#[cfg(not(target_arch = "wasm32"))]
v.extend(self.db.read_catalog().iter().map(|c| c.family.clone()));
v.sort_by_key(|a| a.to_lowercase());
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
v
}
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> = self
.own()
.iter()
.chain(self.db.read_faces().iter())
.filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{'))
.map(|f| (f.italic, f.weight, f.style.clone()))
.collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.db.read_catalog().iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
v.dedup_by(|a, b| a.2 == b.2);
v.into_iter().map(|t| t.2).collect()
}
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before.
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
if let Some(f) = self.find(family, style) {
return f;
}
#[cfg(not(target_arch = "wasm32"))]
if self.db.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return f;
}
self.find(FALLBACK_FAMILY, style)
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
.or_else(|| self.db.read_faces().first().cloned())
.unwrap_or_else(last_resort_face)
}
/// Is `family` available (the document's, loaded, or installed on the system)?
pub fn has_family(&self, family: &str) -> bool {
if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
if self.db.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) {
return true;
}
false
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> {
let face = self.face(family, base_style(style));
let Some(f) = face.skrifa() else { return vec![] };
f.axes()
.iter()
.map(|a| {
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
(tag, name, a.min_value(), a.default_value(), a.max_value())
})
.collect()
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
self.db.outline(face, gid)
}
/// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use.
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let key = (base.id, style.to_string());
if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some(f.clone());
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
let k = k.trim().as_bytes();
let v: f32 = v.trim().parse().ok()?;
if k.len() != 4 {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
}
}
let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone())
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
return self.instance(family, style);
}
let faces = self.db.read_faces();
// The document's fonts first: they stand in for other fonts of the same name.
let named = |f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(family);
let cands: Vec<&Arc<FontFace>> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect();
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
let (tw, ti) = (style_weight(style), style_italic(style));
cands
.iter()
.min_by(|a, b| {
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
sa.total_cmp(&sb)
})
.map(|f| (*f).clone())
}
/// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when
/// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the
/// first face (fallback family first, then load order, the document's fonts last) that covers
/// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face
/// covers a character.
pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> {
if let Some(chain) = cjk_chain(c, language)
&& let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude))
{
return Some(f);
}
if let Some(f) = self.loaded_fallback(c, exclude) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.db.system_fallback(c) {
return self.loaded_fallback(c, exclude);
}
None
}
/// The face of `family` (the document's first, regular first) that covers `c`, loading the
/// installed family the first time a chain asks for it (native, while the system fallback is
/// on).
fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option<Arc<FontFace>> {
#[cfg(not(target_arch = "wasm32"))]
if !self.db.is_loaded(family) {
// Held while loading, so a parallel lookup waits for the font instead of passing it by.
let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner());
if sys.enabled && sys.chain_tried.insert(family) {
self.db.load_cataloged(family);
}
}
let faces = self.db.read_faces();
let mut order: Vec<&Arc<FontFace>> =
self.own().iter().chain(faces.iter()).filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect();
order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.db.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().chain(self.own().iter()).filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn too_large() -> String {
format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20)
}
#[cfg(not(target_arch = "wasm32"))]
fn over_total() -> String {
format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20)
}
struct FlipPen(BezPath);
impl OutlinePen for FlipPen {
+1 -1
View File
@@ -14,7 +14,7 @@ mod vertical;
pub use fontdb::{
DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES,
MAX_DOCUMENT_FONTS_TOTAL, base_style, bundled, system_font_dirs,
MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs,
};
pub use harfrust::Feature;
use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer};
+8 -2
View File
@@ -2,12 +2,18 @@
use skrifa::MetadataProvider;
/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph: the bundled
/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph (`X`): the bundled
/// Source Sans 3 Regular with its `name` and `cmap` tables replaced. Stands in for a font that
/// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font
/// can't be read.
pub fn font_with(family: &str, chars: &[char]) -> Option<Vec<u8>> {
let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, 'X')).collect();
font_with_glyph(family, chars, 'X')
}
/// [`font_with`], drawing every character as Source Sans 3's `glyph` (two fonts of one name that
/// set the same text differently).
pub fn font_with_glyph(family: &str, chars: &[char], glyph: char) -> Option<Vec<u8>> {
let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, glyph)).collect();
font_mapping(family, &glyphs)
}
+59 -10
View File
@@ -1,5 +1,6 @@
//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded ahead of the
//! installed fonts of the same name; damaged, oversized and surplus files are skipped.
//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded for that
//! document alone (its scope) ahead of the installed fonts of the same name; damaged, oversized
//! and surplus files are skipped.
use std::path::{Path, PathBuf};
@@ -37,14 +38,17 @@ fn document_fonts_load_ahead_of_installed_ones() {
let r = db.load_document_fonts(&folder);
assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped);
let face = db.face("DocFont Precedence", "Regular");
let face = db.scoped(r.scope).face("DocFont Precedence", "Regular");
assert!(face.covers('b'), "the document's face wins");
assert_eq!(face.source, FontSource::Document(file.clone()));
assert_eq!(face.source.path(), Some(file.as_path()));
assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays");
// Loading the folder again adds nothing.
// Loading the folder again (another document beside it) shares the face, in a scope of its own.
let again = db.load_document_fonts(&folder);
assert_eq!((again.faces, again.skipped.len()), (0, 0));
assert_eq!((again.faces, again.skipped.len()), (1, 0));
assert_ne!(again.scope, r.scope);
assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face));
let _ = std::fs::remove_dir_all(&root);
}
@@ -62,8 +66,9 @@ fn damaged_oversized_and_other_files_are_skipped() {
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
assert_eq!(r.faces, 1);
assert!(db.has_family("DocFont Good"));
assert!(!db.has_family("DocFont Nested"), "subfolders aren't read");
assert!(db.scoped(r.scope).has_family("DocFont Good"));
assert!(!db.has_family("DocFont Good"), "the document's alone");
assert!(!db.scoped(r.scope).has_family("DocFont Nested"), "subfolders aren't read");
assert_eq!(r.skipped.len(), 2, "{:?}", r.skipped);
assert!(r.skipped.iter().any(|s| s.contains("broken.ttf")), "{:?}", r.skipped);
assert!(r.skipped.iter().any(|s| s.contains("huge.ttc") && s.contains(&format!("{} MB", MAX_DOCUMENT_FONT_BYTES >> 20))), "{:?}", r.skipped);
@@ -81,8 +86,8 @@ fn the_number_of_files_read_is_capped() {
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
assert_eq!(r.faces, 0);
assert!(!db.has_family("DocFont Past Cap"));
assert_eq!((r.faces, r.scope), (0, 0));
assert!(!db.scoped(r.scope).has_family("DocFont Past Cap"));
assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest");
assert!(r.skipped.last().is_some_and(|s| s.contains("6 more") && s.contains("200")), "{:?}", r.skipped.last());
let _ = std::fs::remove_dir_all(&folder);
@@ -103,7 +108,51 @@ fn large_cjk_collections_are_within_the_limit() {
fn a_missing_folder_loads_nothing() {
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER));
assert_eq!((r.faces, r.skipped.len()), (0, 0));
assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0));
}
#[test]
fn each_scope_sees_its_own_fonts_only() {
const FAMILY: &str = "DocFont Scope";
let (a, b) = (temp_dir("scope-a"), temp_dir("scope-b"));
write_font(&a, "a.ttf", FAMILY, &['a']);
write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']);
let db = FontDb::with_font_dirs(vec![]);
let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b));
let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope));
assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b'));
assert!(in_a.families().iter().any(|f| f == FAMILY) && in_a.styles(FAMILY) == ["Regular"]);
assert!(!db.has_family(FAMILY) && !db.families().iter().any(|f| f == FAMILY), "no document's fonts are shared");
assert!(!db.scoped(0).has_family(FAMILY));
// Fallback finds a character in the document's own fonts, never another document's.
let exclude = in_a.face(FALLBACK_FAMILY, "Regular").id();
assert!(in_a.fallback_for('\u{E123}', exclude, None).is_none());
assert!(in_b.fallback_for('\u{E123}', exclude, None).is_some_and(|f| f.family == FAMILY));
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() {
const FAMILY: &str = "DocFont Reopened";
let folder = temp_dir("reopen");
let file = write_font(&folder, "r.ttf", FAMILY, &['r']);
let db = FontDb::with_font_dirs(vec![]);
let r = db.load_document_fonts(&folder);
let face = db.scoped(r.scope).face(FAMILY, "Regular");
db.close_scope(r.scope);
assert!(!db.scoped(r.scope).has_family(FAMILY), "closed");
let again = db.load_document_fonts(&folder);
assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again");
// A file changed since is read again.
std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap();
let changed = db.load_document_fonts(&folder);
let new_face = db.scoped(changed.scope).face(FAMILY, "Regular");
assert!(new_face.covers('s') && new_face.id() != face.id());
assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with");
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
+1
View File
@@ -2239,6 +2239,7 @@ impl<'r> Importer<'r> {
created: designcraft_doc::vars::now(),
modified: 0,
next_id: self.next_id,
font_scope: 0,
};
Ok(d)
}
+13 -11
View File
@@ -1,14 +1,14 @@
//! Glyph grids for the Glyphs panel: a font's characters drawn into square cells.
use designcraft_fonts::FontDb;
use designcraft_fonts::ScopedFonts;
use vello_cpu::{RenderContext, Resources, kurbo, peniko};
use crate::Rendered;
/// Draw `chars` of `family`/`style` into a grid of `cols` columns of `cell`-pixel cells, in
/// `color` (straight RGBA) on transparency. Glyphs are scaled to the font's em and centred.
pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered {
let db = FontDb::global();
/// Draw `chars` of `family`/`style` (as `db`, a document's fonts, has it) into a grid of `cols`
/// columns of `cell`-pixel cells, in `color` (straight RGBA) on transparency. Glyphs are scaled to
/// the font's em and centred.
pub fn glyph_grid(db: &ScopedFonts<'_>, family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered {
let face = db.face(family, style);
let cols = cols.max(1);
let rows = (chars.len() as u32).div_ceil(cols).max(1);
@@ -45,10 +45,10 @@ pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u3
Rendered { width: w as u32, height: h as u32, pixels }
}
/// `text` set in `family`/`style` on one line, `height` pixels tall (the em fits the height), in
/// `color` on transparency — for font menus that preview each family.
pub fn text_line(family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered {
let db = FontDb::global();
/// `text` set in `family`/`style` (as `db`, a document's fonts, has it) on one line, `height`
/// pixels tall (the em fits the height), in `color` on transparency — for font menus that preview
/// each family.
pub fn text_line(db: &ScopedFonts<'_>, family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered {
let face = db.face(family, style);
let upem = face.units_per_em().max(1.0);
let k = height as f64 * 0.72 / upem;
@@ -85,7 +85,8 @@ mod tests {
#[test]
fn grid_draws_each_cell() {
let img = glyph_grid(designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]);
let db = designcraft_fonts::FontDb::global().scoped(0);
let img = glyph_grid(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]);
assert_eq!((img.width, img.height), (80, 80));
let ink = |cx: u32, cy: u32| {
(cx * 40..cx * 40 + 40).flat_map(|x| (cy * 40..cy * 40 + 40).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count()
@@ -96,7 +97,8 @@ mod tests {
#[test]
fn text_line_draws_the_name() {
let img = text_line(designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]);
let db = designcraft_fonts::FontDb::global().scoped(0);
let img = text_line(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]);
assert_eq!(img.height, 24);
assert!(img.width > 30);
let ink = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count();
+4 -3
View File
@@ -1963,11 +1963,12 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
}
"chars" => {
let fonts = crate::panels::fonts(app);
egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt("psfam").selected_text(&fam).width(200.0).show_ui(ui, |ui| {
for f in designcraft_fonts::FontDb::global().families() {
for f in fonts.families() {
if ui.selectable_label(f == fam, &f).clicked() {
d.fields.insert("c.fontFamily".into(), json!(f));
}
@@ -1977,7 +1978,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:"));
let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
for s in designcraft_fonts::FontDb::global().styles(&fam) {
for s in fonts.styles(&fam) {
if ui.selectable_label(s == sty, &s).clicked() {
d.fields.insert("c.fontStyle".into(), json!(s));
}
@@ -2617,7 +2618,7 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0)));
let db = designcraft_fonts::FontDb::global();
let db = crate::panels::fonts(app);
let families = db.families();
let fam_opts: Vec<(&str, &str)> = families.iter().map(|f| (f.as_str(), f.as_str())).collect();
egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+3 -4
View File
@@ -1,6 +1,5 @@
//! Glyphs panel: every character of a font in a grid; click to insert it at the text cursor.
use designcraft_fonts::FontDb;
use egui::{Sense, vec2};
use serde_json::json;
@@ -32,7 +31,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
.unwrap_or_else(|| designcraft_fonts::DEFAULT_FAMILY.into());
st.style = a.as_ref().and_then(|a| a["chars"]["fontStyle"].as_str().map(str::to_string)).unwrap_or_else(|| "Regular".into());
}
let db = FontDb::global();
let (db, scope) = (super::fonts(app), super::font_scope(app));
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("glyph_family").selected_text(&st.family).width(140.0).show_ui(ui, |ui| {
for f in db.families() {
@@ -80,11 +79,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let ppp = ui.ctx().pixels_per_point();
let cell_px = (CELL * ppp).round() as u32;
let ink = t.text_strong;
let key = egui::Id::new(("glyph_grid", &st.family, &st.style, &q, cols, cell_px, ink.to_array()));
let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array()));
let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
Some(t) => t,
None => {
let img = designcraft_render::glyphs::glyph_grid(&st.family, &st.style, &chars, cols, cell_px, ink.to_array());
let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, h.clone()));
+17 -5
View File
@@ -102,6 +102,17 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
}
/// Resolved attributes at the text selection (or the selected text frames).
/// The fonts the active document sees: its own (its `Document Fonts` folder) ahead of the shared
/// ones.
pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> {
designcraft_fonts::FontDb::global().scoped(font_scope(app))
}
/// The active document's font scope (0 without a document or fonts of its own).
pub fn font_scope(app: &DesignApp) -> u32 {
app.session.active().map_or(0, |d| d.doc.font_scope)
}
pub fn text_attrs(app: &mut DesignApp) -> Option<Value> {
let v = app.session.execute("type.selectionAttrs", &json!({})).ok()?;
if v.is_null() { None } else { Some(serde_json::to_value(v).ok()?) }
@@ -189,7 +200,8 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu
/// The Font menu: search, favourites (★, Show Favorites Only), and each family's name shown in
/// that family.
pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
let fams = designcraft_fonts::FontDb::global().families();
let (fonts, scope) = (fonts(app), font_scope(app));
let fams = fonts.families();
let mut favs = app.session.prefs.favorite_fonts.clone();
let mut pick = None;
let mut favs_changed = false;
@@ -239,10 +251,10 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
}
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, f, egui::FontId::proportional(12.0), t.text);
// The name in its own face (rendered once per family and scale).
let key = egui::Id::new(("font_preview", f, (ppp * 100.0) as u32, t.text.to_array()));
let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array()));
let tex: Option<egui::TextureHandle> = ui.data(|d| d.get_temp(key));
let tex = tex.unwrap_or_else(|| {
let img = designcraft_render::glyphs::text_line(f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, h.clone()));
@@ -267,14 +279,14 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
}
if let Some(f) = pick {
let styles = designcraft_fonts::FontDb::global().styles(&f);
let styles = fonts.styles(&f);
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
}
}
pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) {
let styles = designcraft_fonts::FontDb::global().styles(family);
let styles = fonts(app).styles(family);
let mut pick = None;
egui::ComboBox::from_id_salt("font_style").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| {
for s in &styles {
+5 -4
View File
@@ -986,7 +986,8 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
let fw = full_width(ui);
// Family dropdown with a search segment on the left.
let fams = designcraft_fonts::FontDb::global().families();
let fonts = super::fonts(app);
let fams = fonts.families();
let resp = widgets::dropdown(ui, "", fw);
let r = resp.rect;
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon);
@@ -1011,12 +1012,12 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
});
});
if let Some(f) = pick_fam {
let styles = designcraft_fonts::FontDb::global().styles(&f);
let styles = fonts.styles(&f);
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
}
ui.add_space(1.0);
let styles = designcraft_fonts::FontDb::global().styles(&fam);
let styles = fonts.styles(&fam);
let cur = styles.iter().position(|s| *s == sty);
if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, fw, &styles, cur) {
let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}}));
@@ -2096,7 +2097,7 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl
if family.is_empty() {
return;
}
let db = designcraft_fonts::FontDb::global();
let db = super::fonts(app);
let axes = db.axes(family, style);
if axes.is_empty() {
return;
+4 -3
View File
@@ -107,15 +107,16 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
let c = a["chars"].clone();
let fam = c["fontFamily"].as_str().unwrap_or("").to_string();
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
let fams = designcraft_fonts::FontDb::global().families();
let fonts = panels::fonts(app);
let fams = fonts.families();
let cur = fams.iter().position(|f| *f == fam);
if let Some(k) = widgets::dropdown_list(ui, if fam.is_empty() { "—" } else { &fam }, 150.0, &fams, cur) {
let f = fams[k].clone();
let styles = designcraft_fonts::FontDb::global().styles(&f);
let styles = fonts.styles(&f);
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
}
let styles = designcraft_fonts::FontDb::global().styles(&fam);
let styles = fonts.styles(&fam);
let cur = styles.iter().position(|s| *s == sty);
if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, 90.0, &styles, cur) {
let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}}));
+1 -1
View File
@@ -42,7 +42,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built
| Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading |
| Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font |
| Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog |
| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: a `Document Fonts` folder beside an opened file loads ahead of installed fonts, and Package copies the fonts used there (licence permitting). They stay loaded for the session (shared by every open document, not unloaded on close) and aren't read on the web |
| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies the fonts used there (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web |
| Everything else below | missing |
Known bugs to fix first: