Files
lightcraft/crates/engine/src/text.rs
T
Jason Baldwin 12dc533396 LRC-SS-OVERLAYS: slideshow overlays: identity plate, rating stars, text with tokens, watermark
- Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays
  command): identity plate (text, or a PNG/JPEG file with its transparency; size,
  colour, opacity; render behind image), rating stars (own vector stars), up to 16
  text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus
  every rename token; unknown tags show as typed and are reported) and a drop
  shadow, each at one of nine anchors of the slide or photo plus an offset, kept on
  the slide; the export watermark on the photo. Sizes relative to the slide's
  shorter side; validated and clamped.
- Drawn by the engine as a second pass over lightcraft_slideshow::compose_with
  (new behind hook for "render behind image"); the watermark's text renderer moved
  to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs
  off the image are skipped so huge text stays cheap.
- rename::expand_tokens_with lets overlays add tokens without changing rename.
- Playback composes slides when overlays are on; slideshow.preview returns each
  overlay's box. Settings > Slideshow > Overlays section with live preview.
- Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs.
- Tests: tokens, nine anchors, star count, plate image opacity and missing file,
  text shadow, resolution independence, hostile input, big-slide cost, CJK with
  craft-fonts, prefs persistence, preview and headless playback pixel checks.
2026-10-10 22:54:40 +02:00

208 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter
//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines
//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out
//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor
//! it before drawing; drawing hands out per-pixel coverage, and the caller blends.
//!
//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers
//! draw as Inter's missing-glyph box: wrong-looking, never a crash.
use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point};
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// Most characters one layout draws (longer text is cut there), so hostile text stays cheap.
pub const MAX_CHARS: usize = 4000;
/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str, vertical: bool, px: f32, left: f32, top: f32) -> Option<Vec<Glyph>> {
let face = harfrust::FontRef::new(font.font_data()).ok()?;
let mut buf = harfrust::UnicodeBuffer::new();
buf.push_str(text);
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
if shaped.glyph_infos().is_empty() {
return None;
}
let sf = font.as_scaled(px);
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
let mut glyphs = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
let pen = point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
x += pos.x_advance as f32 * sx;
y -= pos.y_advance as f32 * sy;
}
Some(glyphs)
}
/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's
/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left).
pub struct TextLayout {
fonts: Vec<FontRef<'static>>,
/// (face, glyph, rotation centre for glyphs turned 90° in vertical text).
glyphs: Vec<(usize, Glyph, Option<(f32, f32)>)>,
pub width: f32,
pub height: f32,
}
impl TextLayout {
/// `text` (cut at [`MAX_CHARS`]) at `px` pixels a line; `None` when there is nothing to draw
/// (empty text, a non-finite or non-positive size).
pub fn new(text: &str, px: f32, vertical: bool, craft: &'static [crate::fonts::CraftFont]) -> Option<TextLayout> {
use unicode_segmentation::UnicodeSegmentation;
if text.is_empty() || !px.is_finite() || px <= 0.0 {
return None;
}
let text = match text.char_indices().nth(MAX_CHARS) {
Some((i, _)) => text.get(..i).unwrap_or(text),
None => text,
};
let fonts = faces(craft);
let latin = fonts.first()?.clone();
let vertical_data: Vec<_> = if vertical {
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
} else {
Vec::new()
};
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut tw = 0.0f32;
let mut prev = None;
let columns = text.split('\n').count();
let mut column = 0usize;
// Horizontal text keeps scalar layout and kerning; only vertical cells use graphemes.
let cells: Box<dyn Iterator<Item = &str>> =
if vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
for cell in cells {
let Some(original) = cell.chars().next() else { continue };
if cell == "\n" || (vertical && cell == "\r\n") {
if vertical {
column = column.saturating_add(1);
tw = tw.max(y);
y = 0.0;
} else {
tw = tw.max(x);
x = 0.0;
y += px;
}
prev = None;
continue;
}
if original.is_control() {
continue;
}
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
if vertical && (cjk || cell.chars().count() > 1) {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
let data = data.as_ref()?;
let cluster = cell_glyphs(font, data, cell, cjk, px, left, y)?;
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】')
{
return None;
}
Some((face, cluster))
});
if let Some((face, cluster)) = shaped {
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
y += px;
continue;
}
}
let vertical_form = match original {
'、' => '︑',
'。' => '︒',
'(' => '︵',
')' => '︶',
'「' => '﹁',
'」' => '﹂',
'『' => '﹃',
'』' => '﹄',
'【' => '︻',
'】' => '︼',
'…' => '︙',
other => other,
};
let has = |font: &FontRef<'_>, c: char| font.glyph_id(c).0 != 0;
let ch = if vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original };
// The first face with the glyph; Inter (its missing-glyph box) when none has it.
let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0);
let sf = fonts.get(face).unwrap_or(&latin).as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent());
let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0));
glyphs.push((face, id.with_scale_and_position(px, pen), rotation));
y += px;
} else {
if let Some((previous_face, previous)) = prev
&& previous_face == face
{
x += sf.kern(previous, id);
}
glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None));
x += sf.h_advance(id);
prev = Some((face, id));
}
}
let (width, height) = if vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
Some(TextLayout { fonts, glyphs, width, height })
}
/// Call `f(x, y, coverage)` for every pixel the text covers with its top-left corner at
/// (`ox`, `oy`); coordinates may be negative or past the image (the caller clips).
pub fn draw(&self, ox: f32, oy: f32, f: impl FnMut(i32, i32, f32)) {
self.draw_in(ox, oy, f32::INFINITY, f32::INFINITY, f);
}
/// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h`
/// (so huge text costs what shows, not what is laid out).
pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) {
for (face, g, rotation) in &self.glyphs {
if rotation.is_none() {
// a glyph lies within one line height below its pen's top and an em or so after it
let (px, py) = (ox + g.position.x, oy + g.position.y);
let em = g.scale.y.max(g.scale.x);
if px + 2.0 * em < 0.0 || px - em > clip_w || py + em < 0.0 || py - 2.0 * em > clip_h {
continue;
}
}
if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
if rotation.is_none() && (ox + b.max.x < 0.0 || ox + b.min.x > clip_w || oy + b.max.y < 0.0 || oy + b.min.y > clip_h) {
continue;
}
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
f((ox + sx) as i32, (oy + sy) as i32, cov);
});
}
}
}
}