mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536423298b | ||
|
|
2bf62c97e2 | ||
|
|
1bb86db54a | ||
|
|
ba56742fa9 | ||
|
|
8b160d5b03 |
@@ -27,6 +27,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
|
||||
Generated
+1
@@ -1108,6 +1108,7 @@ dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
@@ -24,6 +24,22 @@ fn ignorable(c: char) -> bool {
|
||||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
|
||||
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
|
||||
@@ -55,9 +71,28 @@ impl FontDb {
|
||||
/// bundled face for its script, else an installed face for the script, else any loaded
|
||||
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
|
||||
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
|
||||
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
|
||||
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
|
||||
if is_emoji_cluster(cluster) {
|
||||
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.color_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
}
|
||||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
let script = cluster.iter().find_map(|c| script_of(*c));
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct FontFace {
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
/// Colour glyph tables present (`is_color`), looked up on first use.
|
||||
color: std::sync::OnceLock<bool>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -158,6 +160,11 @@ impl FontFace {
|
||||
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
|
||||
/// fonts have)?
|
||||
pub fn is_color(&self) -> bool {
|
||||
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
@@ -258,6 +265,13 @@ struct SysFallback {
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Does face `index` of `data` carry colour glyph tables?
|
||||
fn has_color_tables(data: &[u8], index: u32) -> bool {
|
||||
use skrifa::raw::types::Tag;
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
|
||||
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
@@ -496,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
color: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -902,6 +917,61 @@ impl FontDb {
|
||||
false
|
||||
}
|
||||
|
||||
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
|
||||
/// `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
|
||||
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_color_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
|
||||
+20
-1
@@ -13,7 +13,7 @@ mod fontdb;
|
||||
pub mod script;
|
||||
mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
@@ -136,6 +136,25 @@ mod tests {
|
||||
FontDb::global().face(DEFAULT_FAMILY, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fonts_are_recognised() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
|
||||
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
|
||||
assert!(!fontdb::last_resort_face().is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
|
||||
@@ -19,6 +19,7 @@ vello_common = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
|
||||
//! them: layers of glyph outlines filled with solid colours or gradients.
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_fonts::FontFace;
|
||||
use kurbo::{Affine, BezPath, Point, Rect};
|
||||
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
|
||||
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
|
||||
/// False when the glyph has no colour version, so the caller draws the plain outline.
|
||||
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
|
||||
let Some(font) = face.skrifa() else { return false };
|
||||
let glyphs = font.color_glyphs();
|
||||
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
|
||||
let palette: Vec<Rgba> = font
|
||||
.color_palettes()
|
||||
.get(0)
|
||||
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
|
||||
.unwrap_or_default();
|
||||
// Font units are y up; the slide is y down.
|
||||
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
|
||||
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
|
||||
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
|
||||
let ok = glyph.paint(face.location(), &mut p).is_ok();
|
||||
// A malformed glyph may leave layers open: close them all.
|
||||
while let Some(open) = p.opened.pop() {
|
||||
if open {
|
||||
p.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
p.ctx.pop_layer();
|
||||
ok
|
||||
}
|
||||
|
||||
struct Painter<'a> {
|
||||
ctx: &'a mut RenderContext,
|
||||
face: &'a FontFace,
|
||||
base: Affine,
|
||||
/// Transforms pushed by the glyph (font units), innermost last.
|
||||
stack: Vec<Affine>,
|
||||
palette: Vec<Rgba>,
|
||||
fg: Rgba,
|
||||
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
|
||||
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
|
||||
opened: Vec<bool>,
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
|
||||
}
|
||||
|
||||
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(p.x as f64, p.y as f64)
|
||||
}
|
||||
|
||||
fn extend(e: Extend) -> peniko::Extend {
|
||||
match e {
|
||||
Extend::Repeat => peniko::Extend::Repeat,
|
||||
Extend::Reflect => peniko::Extend::Reflect,
|
||||
_ => peniko::Extend::Pad,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(m: CompositeMode) -> peniko::BlendMode {
|
||||
use peniko::{Compose, Mix};
|
||||
let (mix, compose) = match m {
|
||||
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
|
||||
CompositeMode::Src => (Mix::Normal, Compose::Copy),
|
||||
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
|
||||
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
|
||||
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
|
||||
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
|
||||
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
|
||||
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
|
||||
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
|
||||
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
|
||||
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
|
||||
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
|
||||
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
|
||||
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
|
||||
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
|
||||
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
|
||||
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
|
||||
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
|
||||
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
|
||||
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
|
||||
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
|
||||
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
|
||||
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
|
||||
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
|
||||
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
|
||||
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
|
||||
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
|
||||
_ => (Mix::Normal, Compose::SrcOver),
|
||||
};
|
||||
peniko::BlendMode::new(mix, compose)
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn current(&self) -> Affine {
|
||||
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
|
||||
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
|
||||
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
|
||||
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
|
||||
}
|
||||
|
||||
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
|
||||
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
|
||||
}
|
||||
|
||||
/// The glyph outline in font units, y up.
|
||||
fn outline(&self, gid: GlyphId) -> BezPath {
|
||||
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
|
||||
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
|
||||
}
|
||||
|
||||
fn push_clip(&mut self, path: &BezPath) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.push_clip_layer(path);
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop(&mut self) {
|
||||
if self.opened.pop() == Some(true) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the open clip area (a large box in the current transform) with `paint`.
|
||||
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
|
||||
self.ctx.set_transform(self.current());
|
||||
self.ctx.set_paint(paint);
|
||||
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPainter for Painter<'_> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
|
||||
self.stack.push(next);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, gid: GlyphId) {
|
||||
let path = self.outline(gid);
|
||||
self.push_clip(&path);
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let c = self.color(palette_index, alpha);
|
||||
self.cover(c);
|
||||
}
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
|
||||
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
|
||||
.with_stops(self.stops(color_stops).as_slice())
|
||||
.with_extend(extend(e));
|
||||
self.cover(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
let open = self.opened.len() < MAX_DEPTH;
|
||||
if open {
|
||||
self.ctx.push_blend_layer(blend(mode));
|
||||
}
|
||||
self.opened.push(open);
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
+130
-4
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart;
|
||||
mod colr;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
@@ -247,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
return;
|
||||
}
|
||||
let (e, _) = resolve::effects(&rctx, s);
|
||||
if e.is_some_and(|e| !e.is_empty()) {
|
||||
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
@@ -260,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
|
||||
/// text styles it inherits)? Such text is blurred, which needs filters.
|
||||
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
||||
use deckcraft_model::text::{ListStyle, RunProps};
|
||||
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
|
||||
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
|
||||
let Some(t) = &s.text else { return false };
|
||||
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|
||||
|| styled(&t.list_style)
|
||||
|| styled(&rctx.master.title_style)
|
||||
|| styled(&rctx.master.body_style)
|
||||
|| styled(&rctx.master.other_style)
|
||||
}
|
||||
|
||||
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||
/// blank image (logged), never a crash.
|
||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
||||
@@ -1123,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
}
|
||||
// Text shadows, then glows, under all the text.
|
||||
for pass in [TextEffect::Shadow, TextEffect::Glow] {
|
||||
for run in &l.runs {
|
||||
let ps = para(run.para);
|
||||
if ps.visible && ps.opacity > 0.001 {
|
||||
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in &l.runs {
|
||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
continue;
|
||||
@@ -1132,8 +1156,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
continue;
|
||||
}
|
||||
let m = m * Affine::translate(ps.offset);
|
||||
// A gradient fill starts over on every line and spans that line's text (from its top to
|
||||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
};
|
||||
let li = line_at(l, run.para, *y);
|
||||
if !grads.iter().any(|(i, _)| *i == li) {
|
||||
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
|
||||
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
|
||||
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
|
||||
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
|
||||
}
|
||||
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
|
||||
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
|
||||
}
|
||||
}
|
||||
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||
@@ -1141,20 +1181,106 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, li)| li.para == para)
|
||||
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The box a line's text covers: its top and bottom, its first and last caret.
|
||||
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
|
||||
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
let r = Rect::new(x0, li.top, x1, li.bottom);
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum TextEffect {
|
||||
Shadow,
|
||||
Glow,
|
||||
}
|
||||
|
||||
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
|
||||
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
|
||||
let Some(fx) = run.effects else { return };
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let px = scale_of(m);
|
||||
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
|
||||
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
|
||||
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
|
||||
// past the glyph's edge and fades out by about two thirds of r.
|
||||
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
|
||||
_ => return,
|
||||
};
|
||||
let alpha = (c.a as f64 / 255.0) * opacity;
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let path = glyph(&run.face, *gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
|
||||
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
|
||||
draw_glyph_with(ctx, run, gid, at, opacity, None);
|
||||
}
|
||||
|
||||
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
|
||||
/// the glyph's origin to that space) instead of the run's colour.
|
||||
fn draw_glyph_with(
|
||||
ctx: &mut RenderContext,
|
||||
run: &deckcraft_text::GlyphRun,
|
||||
gid: u32,
|
||||
at: Affine,
|
||||
opacity: f64,
|
||||
fill: Option<(&peniko::Gradient, Affine)>,
|
||||
) {
|
||||
let alpha = run.alpha * opacity;
|
||||
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
|
||||
// strokes the plain glyph below.
|
||||
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
|
||||
return;
|
||||
}
|
||||
let path = glyph(&run.face, gid);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
ctx.set_transform(at * skew * Affine::scale(k));
|
||||
if alpha > 0.0 && run.color.a > 0 {
|
||||
if let Some((g, to_text)) = fill
|
||||
&& alpha > 0.0
|
||||
{
|
||||
ctx.set_paint(g.clone());
|
||||
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
} else if alpha > 0.0 && run.color.a > 0 {
|
||||
ctx.set_paint(color(run.color, alpha));
|
||||
ctx.fill_path(&path);
|
||||
if run.fake_bold {
|
||||
|
||||
+34
-27
@@ -35,33 +35,8 @@ pub fn fill_path(
|
||||
if g.stops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
let grad = match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
let a = angle.to_radians();
|
||||
let (dx, dy) = (a.cos(), a.sin());
|
||||
let c = bounds.center();
|
||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
||||
.with_stops(stops.as_slice())
|
||||
}
|
||||
GradientShape::Path { focus, .. } => {
|
||||
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
|
||||
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
|
||||
// Reach the farthest corner, so a gradient from a corner covers the whole box.
|
||||
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
|
||||
.iter()
|
||||
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
|
||||
.fold(1e-6, f64::max);
|
||||
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
};
|
||||
ctx.set_paint(grad);
|
||||
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
|
||||
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
Fill::Picture(pf) => {
|
||||
@@ -131,6 +106,38 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
|
||||
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
|
||||
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
|
||||
for s in &mut stops {
|
||||
s.0 = s.0.clamp(0.0, 1.0);
|
||||
}
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
|
||||
match shape {
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||
let a = angle.to_radians();
|
||||
let (dx, dy) = (a.cos(), a.sin());
|
||||
let c = bounds.center();
|
||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
||||
.with_stops(stops.as_slice())
|
||||
}
|
||||
GradientShape::Path { focus, .. } => {
|
||||
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
|
||||
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
|
||||
// Reach the farthest corner, so a gradient from a corner covers the whole box.
|
||||
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
|
||||
.iter()
|
||||
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
|
||||
.fold(1e-6, f64::max);
|
||||
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||
/// GIF shows its frame at `gif_time` seconds.
|
||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
||||
|
||||
@@ -632,3 +632,121 @@ fn a_background_is_laid_over_white() {
|
||||
let g = img.pixel(20, 20);
|
||||
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyphs_paint_their_layers() {
|
||||
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
|
||||
// square in its middle, U+E001 a square in the text colour.
|
||||
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
|
||||
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("\u{E000} \u{E001}");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(72.0);
|
||||
props.font = Some("DeckCraft Colr Squares".into());
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||||
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
|
||||
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
|
||||
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
|
||||
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
|
||||
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
|
||||
let share = blue as f64 / (red + blue) as f64;
|
||||
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
|
||||
// The text-colour glyph is as big as the other square.
|
||||
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_gradient_starts_over_on_every_line() {
|
||||
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
|
||||
// (black at the top of every line to white at its bottom), not the text box.
|
||||
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
|
||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("HHHH\nHHHH");
|
||||
for p in &mut t.paragraphs {
|
||||
let props = &mut p.runs[0].props;
|
||||
props.size = Some(80.0);
|
||||
props.fill = Some(Fill::Gradient(Gradient {
|
||||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
}));
|
||||
}
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Ink rows (any pixel not white), grouped into the two lines.
|
||||
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
|
||||
let mut bands: Vec<(u32, u32)> = vec![];
|
||||
for y in 0..500 {
|
||||
if ink(y) {
|
||||
match bands.last_mut() {
|
||||
Some(b) if b.1 + 1 == y => b.1 = y,
|
||||
_ => bands.push((y, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(bands.len(), 2, "{bands:?}");
|
||||
// The darkest ink in the first rows of each line.
|
||||
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||||
let (a, b) = (top(bands[0]), top(bands[1]));
|
||||
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
|
||||
// ...and each line ends light.
|
||||
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
|
||||
for band in bands {
|
||||
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
|
||||
}
|
||||
}
|
||||
|
||||
/// A text box of big black "IIII" with `set` applied to its run.
|
||||
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
|
||||
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("IIII");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(100.0);
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||||
set(props);
|
||||
sh.text = Some(t);
|
||||
deck_with(vec![sh])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_glow_and_shadow_are_drawn() {
|
||||
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
|
||||
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
|
||||
let plain = effect_text(|_| {});
|
||||
assert!(!needs_filters(&plain, &plain.slides[0]));
|
||||
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(count(&img, &green), 0);
|
||||
// Glow: a green halo hugging the letters, needing the blur filter.
|
||||
let glow =
|
||||
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
|
||||
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
|
||||
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||||
let halo = count(&img, &green);
|
||||
assert!(halo > 300, "green halo pixels {halo}");
|
||||
// Shadow: grey ink below and right of the letters, where the plain text has none.
|
||||
let shadow = effect_text(|p| {
|
||||
p.shadow = Some(deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
|
||||
blur: 2.0,
|
||||
dist: 12.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
})
|
||||
});
|
||||
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
|
||||
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
|
||||
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+51
@@ -0,0 +1,51 @@
|
||||
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
|
||||
|
||||
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
|
||||
|
||||
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
|
||||
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
|
||||
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
|
||||
without colour support.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from fontTools.colorLib.builder import buildCOLR, buildCPAL
|
||||
|
||||
|
||||
def square(x0, y0, x1, y1):
|
||||
pen = TTGlyphPen(None)
|
||||
pen.moveTo((x0, y0))
|
||||
pen.lineTo((x0, y1))
|
||||
pen.lineTo((x1, y1))
|
||||
pen.lineTo((x1, y0))
|
||||
pen.closePath()
|
||||
return pen.glyph()
|
||||
|
||||
|
||||
def empty():
|
||||
return TTGlyphPen(None).glyph()
|
||||
|
||||
|
||||
order = [".notdef", "space", "redblue", "ink", "big", "small"]
|
||||
fb = FontBuilder(1000, isTTF=True)
|
||||
fb.setupGlyphOrder(order)
|
||||
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
|
||||
fb.setupGlyf({
|
||||
".notdef": empty(),
|
||||
"space": empty(),
|
||||
"redblue": square(0, 0, 1000, 1000),
|
||||
"ink": square(0, 0, 1000, 1000),
|
||||
"big": square(0, 0, 1000, 1000),
|
||||
"small": square(300, 300, 700, 700),
|
||||
})
|
||||
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
|
||||
fb.setupHorizontalHeader(ascent=1000, descent=0)
|
||||
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
|
||||
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
|
||||
fb.setupPost()
|
||||
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
|
||||
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
|
||||
fb.save(sys.argv[1])
|
||||
print("saved", sys.argv[1])
|
||||
+58
-7
@@ -57,6 +57,25 @@ pub struct GlyphRun {
|
||||
/// Alpha of the fill (0–1).
|
||||
pub alpha: f64,
|
||||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
||||
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
|
||||
pub effects: Option<RunEffects>,
|
||||
}
|
||||
|
||||
/// A text run's shadow and glow, colours resolved.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RunEffects {
|
||||
pub shadow: Option<TextShadow>,
|
||||
/// Glow colour (with its alpha) and radius in points.
|
||||
pub glow: Option<(Rgba, f64)>,
|
||||
}
|
||||
|
||||
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextShadow {
|
||||
pub color: Rgba,
|
||||
pub blur: f64,
|
||||
pub dx: f64,
|
||||
pub dy: f64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GlyphRun {
|
||||
@@ -174,6 +193,7 @@ struct Style {
|
||||
outline: Option<(Rgba, f64)>,
|
||||
link: bool,
|
||||
gradient: Option<deckcraft_model::style::Gradient>,
|
||||
effects: Option<RunEffects>,
|
||||
/// "Regular", "Bold"… for the slot and fallback faces.
|
||||
style_name: &'static str,
|
||||
/// BCP 47 language (`a:rPr/@lang`), for shaping.
|
||||
@@ -245,7 +265,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||||
let (mut color, alpha, gradient) = match &r.fill {
|
||||
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
|
||||
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
|
||||
let mut g = g.clone();
|
||||
for s in &mut g.stops {
|
||||
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
|
||||
}
|
||||
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
|
||||
}
|
||||
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
|
||||
_ => (resolve::text_color(ctx, r), 1.0, None),
|
||||
};
|
||||
@@ -281,6 +308,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
outline,
|
||||
link: r.link.is_some(),
|
||||
gradient,
|
||||
effects: run_effects(ctx, r, scale),
|
||||
style_name: style_name(bold, italic),
|
||||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||||
ea,
|
||||
@@ -464,9 +492,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
bullet_face(db, &mut st, &ch);
|
||||
Some((ch, st))
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
@@ -476,8 +502,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -485,8 +513,27 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
|
||||
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
||||
/// and other symbols missing from the text font), by the usual fallback.
|
||||
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
|
||||
let chars: Vec<char> = label.chars().collect();
|
||||
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
|
||||
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
|
||||
/// script, face and shaping style, each shaped with the whole paragraph as context.
|
||||
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
|
||||
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
|
||||
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
|
||||
let a = s.dir.to_radians();
|
||||
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
|
||||
});
|
||||
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
|
||||
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
|
||||
}
|
||||
|
||||
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
|
||||
let db = FontDb::global();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
@@ -524,7 +571,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
|
||||
// Spaces, digits and punctuation stay in the face of the text before them when it can
|
||||
// draw them, so a phrase isn't cut into items at every space.
|
||||
let neutral = cluster.iter().all(|c| script::script_of(*c).is_none());
|
||||
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
|
||||
let neutral =
|
||||
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
|
||||
let face = match prev.as_ref() {
|
||||
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
_ => {
|
||||
@@ -1222,6 +1271,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
link: false,
|
||||
alpha: st.alpha,
|
||||
gradient: None,
|
||||
effects: None,
|
||||
});
|
||||
}
|
||||
// Group visually consecutive cells by (style, face).
|
||||
@@ -1255,6 +1305,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
link: st.link,
|
||||
alpha: st.alpha,
|
||||
gradient: st.gradient.clone(),
|
||||
effects: st.effects,
|
||||
});
|
||||
cur_key = key;
|
||||
}
|
||||
|
||||
@@ -583,3 +583,44 @@ fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
|
||||
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_take_a_colour_face_when_one_is_installed() {
|
||||
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
|
||||
// even when the run's font has a plain glyph for some of them. Skipped without an installed
|
||||
// colour emoji font.
|
||||
let db = FontDb::global();
|
||||
let probe = db.cascade(&['😀'], &[], "Regular");
|
||||
if !probe.is_color() {
|
||||
return;
|
||||
}
|
||||
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
|
||||
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
|
||||
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
|
||||
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
|
||||
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
|
||||
// bundled face has ①.
|
||||
let db = FontDb::global();
|
||||
if !db.cascade(&['①'], &[], "Regular").covers('①') {
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
|
||||
}
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
|
||||
assert_eq!(bullets.len(), 2);
|
||||
for b in bullets {
|
||||
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
|
||||
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user