mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 19:36:56 -06:00
* Type: scan system fonts off the UI thread (#3021) The first `photocraft_text::shared()` call scans every installed font, and nothing made that call before the Type tool's options bar drew its font menu, so the scan ran inside a UI frame (over 60 s on some Macs, #2518). - photocraft-text: `warm_shared()` starts the scan on a background thread; `try_shared()` returns the engine without blocking (None while the scan runs, started there if nobody warmed it; built in place on the web, in tests, if the thread can't start or the worker died). `shared()` keeps its blocking behaviour for engine commands. - The desktop app warms the fonts at start-up. - Font menus that draw every frame (options bar, Character, Glyphs and the Character/Paragraph Styles family menus) show a disabled "Loading fonts…" box and repaint until the scan is done. "Loading fonts…" is in all 16 catalogs. Complements #2644 (CoreText discovery on macOS): this changes where the scan runs, not how fonts are discovered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type: axis sliders wait for the background font scan too #2996 (landed on main) added axis_sliders, which called the blocking photocraft_text::shared() every frame; use try_shared() so the Character section doesn't stall while the scan runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
271 lines
11 KiB
Rust
271 lines
11 KiB
Rust
//! Photocraft's type engine.
|
|
//!
|
|
//! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the
|
|
//! web), optional system fonts from a directory scan (no fontconfig), user font data, TrueType
|
|
//! collections, PostScript-name lookup. Builds made with the optional craft-fonts input
|
|
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks.
|
|
//! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking),
|
|
//! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules.
|
|
//! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a
|
|
//! [`photocraft_raster::Surface`] at any depth and colour model.
|
|
//! * [`psd`]: the PSD `TySh` block and its `EngineData` (text, fonts, runs, paragraphs, box).
|
|
//!
|
|
//! The usual entry point is [`TextEngine::render_layer`], which refreshes a
|
|
//! [`photocraft_doc::TextLayer`]'s cache from its style model.
|
|
#![forbid(unsafe_code)]
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod cjk;
|
|
pub mod craft_fonts;
|
|
pub mod engine_data;
|
|
pub mod fonts;
|
|
pub mod glyphs;
|
|
pub mod layout;
|
|
pub mod optical;
|
|
pub mod psd;
|
|
pub mod psd_styles;
|
|
pub mod raster;
|
|
pub mod render;
|
|
pub mod scripts;
|
|
pub mod served;
|
|
pub mod spell;
|
|
pub mod warp;
|
|
|
|
use photocraft_color::PixelFormat;
|
|
use photocraft_doc::TextLayer;
|
|
|
|
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
|
pub use fonts::{FaceInfo, FontDb, ResolvedFont};
|
|
pub use layout::{
|
|
ClusterInfo, LineInfo, PlacedGlyph, TextLayout, byte_index, char_index, hit_char, line_edge, line_index, line_step, text_point_inside, word_boundary,
|
|
};
|
|
pub use render::Rendered;
|
|
|
|
/// Font database + layout context. Create once and reuse (font loading and shaping caches).
|
|
pub struct TextEngine {
|
|
pub fonts: FontDb,
|
|
layouter: layout::Layouter,
|
|
}
|
|
|
|
impl Default for TextEngine {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl TextEngine {
|
|
/// Bundled fonts only: deterministic output (tests, web).
|
|
pub fn new() -> Self {
|
|
Self { fonts: FontDb::new(), layouter: layout::Layouter::new() }
|
|
}
|
|
|
|
/// Bundled plus installed system fonts (native desktop).
|
|
pub fn with_system_fonts() -> Self {
|
|
Self { fonts: FontDb::with_system_fonts(), layouter: layout::Layouter::new() }
|
|
}
|
|
|
|
/// Lays out a text layer (text space: pixels, before `layer.transform`).
|
|
pub fn layout(&mut self, layer: &TextLayer, dpi: f32) -> TextLayout {
|
|
self.layouter.layout(&mut self.fonts, layer, dpi)
|
|
}
|
|
|
|
/// Kerning (1/1000 em) between the character starting at byte `at` and the next one, as
|
|
/// laid out: the manual kern, or the automatic (metrics / optical) kerning of the pair.
|
|
/// `None` when there is no such pair on one line.
|
|
pub fn pair_kerning(&mut self, layer: &TextLayer, dpi: f32, at: usize) -> Option<f32> {
|
|
let runs = layer.char_runs();
|
|
let ch = layer.text.get(at..)?.chars().next()?;
|
|
let next = at + ch.len_utf8();
|
|
if ch == '\n' || ch == '\r' || layer.text.get(next..)?.chars().next().is_none_or(|c| c == '\n' || c == '\r') {
|
|
return None;
|
|
}
|
|
let gap = |l: &TextLayout| {
|
|
let a = l.clusters.iter().find(|c| c.range.start == at)?;
|
|
let b = l.clusters.iter().find(|c| c.range.start == next)?;
|
|
(a.line == b.line && !a.rtl).then_some(b.x - a.x)
|
|
};
|
|
let with = gap(&self.layout(layer, dpi))?;
|
|
// Em of the pair's first character (px).
|
|
let mut acc = 0;
|
|
let style_run = runs.iter().find(|r| {
|
|
acc += r.len;
|
|
at < acc
|
|
})?;
|
|
let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
|
if em.is_nan() || em <= 0.0 {
|
|
return None;
|
|
}
|
|
// The same layer with the pair's first character unkerned.
|
|
let mut plain = layer.clone();
|
|
plain.runs = Vec::with_capacity(runs.len() + 2);
|
|
let mut start = 0;
|
|
for r in runs {
|
|
let end = start + r.len;
|
|
let mut push = |s: usize, e: usize, unkern: bool| {
|
|
if e > s {
|
|
let mut style = r.style.clone();
|
|
if unkern {
|
|
style.kerning = photocraft_doc::text::Kerning::Off;
|
|
style.kern = 0.0;
|
|
}
|
|
plain.runs.push(photocraft_doc::text::TextRun { len: e - s, style });
|
|
}
|
|
};
|
|
push(start, at.clamp(start, end), false);
|
|
push(at.clamp(start, end), next.clamp(start, end), true);
|
|
push(next.clamp(start, end), end, false);
|
|
start = end;
|
|
}
|
|
let without = gap(&self.layout(&plain, dpi))?;
|
|
let k = (with - without) / em * 1000.0;
|
|
k.is_finite().then_some(k)
|
|
}
|
|
|
|
/// Lays out and rasterizes a text layer into document space.
|
|
pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) {
|
|
let l = self.layout(layer, dpi);
|
|
let warp = render::layout_warp(&l, layer.warp.as_ref());
|
|
let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref());
|
|
(l, r)
|
|
}
|
|
|
|
/// Re-renders `layer.cache` from its model. Returns the document-space rectangle drawn.
|
|
pub fn render_layer(&mut self, layer: &mut TextLayer, dpi: f32, format: PixelFormat) -> photocraft_geom::Rect {
|
|
let (_, r) = self.render(layer, dpi, format);
|
|
layer.cache = Some(r.surface);
|
|
r.rect
|
|
}
|
|
}
|
|
|
|
static SHARED: warm::Warm<std::sync::Mutex<TextEngine>> = warm::Warm::new();
|
|
|
|
/// Whether [`shared`] scans the installed fonts (native builds, outside tests, unless
|
|
/// `PHOTOCRAFT_NO_SYSTEM_FONTS` is set). Without the scan it is cheap to build.
|
|
fn uses_system_fonts() -> bool {
|
|
cfg!(not(target_arch = "wasm32")) && !cfg!(test) && std::env::var_os("PHOTOCRAFT_NO_SYSTEM_FONTS").is_none()
|
|
}
|
|
|
|
fn new_shared() -> std::sync::Mutex<TextEngine> {
|
|
std::sync::Mutex::new(if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() })
|
|
}
|
|
|
|
/// A process-wide engine for callers without their own (bundled fonts only on the web; bundled
|
|
/// plus system fonts elsewhere). Locking is cheap relative to layout. The first call scans the
|
|
/// installed fonts and blocks until that is done (or until [`warm_shared`]'s scan finishes), so
|
|
/// code that runs every UI frame uses [`try_shared`] instead.
|
|
pub fn shared() -> &'static std::sync::Mutex<TextEngine> {
|
|
SHARED.get_or_init(new_shared)
|
|
}
|
|
|
|
/// [`shared`] without blocking: `None` while the system font scan is still running on its
|
|
/// background thread (started here if [`warm_shared`] has not started it yet). Without system
|
|
/// fonts (web, tests) the engine is cheap and this always returns it.
|
|
pub fn try_shared() -> Option<&'static std::sync::Mutex<TextEngine>> {
|
|
SHARED.try_get(new_shared, uses_system_fonts())
|
|
}
|
|
|
|
/// Starts building [`shared`] (the system font scan) on a background thread, so the Type tool's
|
|
/// font menus don't stall the UI when they first draw. Call once at startup; calling again does
|
|
/// nothing.
|
|
pub fn warm_shared() {
|
|
let _ = try_shared();
|
|
}
|
|
|
|
mod warm {
|
|
use std::sync::{Mutex, OnceLock, PoisonError};
|
|
use std::thread::JoinHandle;
|
|
|
|
/// A lazily built value whose (slow) construction can run on a background thread.
|
|
pub(crate) struct Warm<T: 'static> {
|
|
cell: OnceLock<T>,
|
|
scan: Mutex<Option<JoinHandle<()>>>,
|
|
}
|
|
|
|
impl<T: Send + Sync + 'static> Warm<T> {
|
|
pub(crate) const fn new() -> Self {
|
|
Self { cell: OnceLock::new(), scan: Mutex::new(None) }
|
|
}
|
|
|
|
/// The value, building it on this thread (or waiting for the background build).
|
|
pub(crate) fn get_or_init(&'static self, init: fn() -> T) -> &'static T {
|
|
self.cell.get_or_init(init)
|
|
}
|
|
|
|
/// The value if it is built. With `background`, the first call starts building it on a
|
|
/// worker thread and returns `None` until that finishes; otherwise (or when no thread can
|
|
/// be started, or the worker died) it is built here.
|
|
pub(crate) fn try_get(&'static self, init: fn() -> T, background: bool) -> Option<&'static T> {
|
|
if let Some(v) = self.cell.get() {
|
|
return Some(v);
|
|
}
|
|
if !background {
|
|
return Some(self.get_or_init(init));
|
|
}
|
|
let mut scan = self.scan.lock().unwrap_or_else(PoisonError::into_inner);
|
|
match scan.as_ref() {
|
|
Some(worker) if !worker.is_finished() => None,
|
|
// Finished: the value is set, or the worker panicked and this thread retries.
|
|
Some(_) => {
|
|
drop(scan);
|
|
Some(self.get_or_init(init))
|
|
}
|
|
None => match std::thread::Builder::new().name("photocraft-font-scan".into()).spawn(move || {
|
|
self.get_or_init(init);
|
|
}) {
|
|
Ok(worker) => {
|
|
*scan = Some(worker);
|
|
self.cell.get()
|
|
}
|
|
Err(_) => {
|
|
drop(scan);
|
|
Some(self.get_or_init(init))
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Warm;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
static OPEN: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// A slow build: waits until the test opens the gate (at most 2 s).
|
|
fn slow() -> u32 {
|
|
let start = Instant::now();
|
|
while !OPEN.load(Ordering::SeqCst) && start.elapsed() < Duration::from_secs(2) {
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
7
|
|
}
|
|
|
|
#[test]
|
|
fn try_get_does_not_block_while_the_background_build_runs() {
|
|
static W: Warm<u32> = Warm::new();
|
|
let start = Instant::now();
|
|
assert_eq!(W.try_get(slow, true), None, "the first call starts the build and returns at once");
|
|
assert_eq!(W.try_get(slow, true), None, "still building");
|
|
assert!(start.elapsed() < Duration::from_secs(1), "try_get blocked for {:?}", start.elapsed());
|
|
OPEN.store(true, Ordering::SeqCst);
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let v = loop {
|
|
if let Some(v) = W.try_get(slow, true) {
|
|
break *v;
|
|
}
|
|
assert!(Instant::now() < deadline, "the background build never finished");
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
};
|
|
assert_eq!(v, 7);
|
|
assert_eq!(W.get_or_init(slow), &7);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
mod vertical_tests;
|