Files
photocraft/crates/text/src/lib.rs
T
66b022cddd Web: fonts served next to the app, loaded on demand (#1875)
A host lists fonts in fonts/manifest.txt beside index.html (craft-fonts' manifest format, paths
kept inside the site); the web shell lists those families in the font menus and fetches a family
when it is picked or a layout needs it, then re-renders the affected type layers without a history
step. No manifest changes nothing; package.sh copies one in with PHOTOCRAFT_WEB_FONTS_DIR. The
style menu also lists the weights of a variable font's wght axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:14:47 -04:00

153 lines
6.2 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 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
}
}
/// 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.
pub fn shared() -> &'static std::sync::Mutex<TextEngine> {
static ENGINE: std::sync::OnceLock<std::sync::Mutex<TextEngine>> = std::sync::OnceLock::new();
ENGINE.get_or_init(|| {
let use_system = cfg!(not(target_arch = "wasm32")) && !cfg!(test) && std::env::var_os("PHOTOCRAFT_NO_SYSTEM_FONTS").is_none();
std::sync::Mutex::new(if use_system { TextEngine::with_system_fonts() } else { TextEngine::new() })
})
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vertical_tests;