mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 18:52:15 +01:00
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>
153 lines
6.2 KiB
Rust
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;
|