mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442be148ae | ||
|
|
d7fbbaeab1 | ||
|
|
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();
|
||||
|
||||
@@ -223,6 +223,10 @@ pub struct ParaProps {
|
||||
pub rtl: Option<bool>,
|
||||
pub font_align: Option<String>,
|
||||
pub east_asian_line_break: Option<bool>,
|
||||
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
|
||||
pub latin_line_break: Option<bool>,
|
||||
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
|
||||
pub hanging_punct: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -253,6 +257,8 @@ impl ParaProps {
|
||||
self.font_align.clone_from(&p.font_align);
|
||||
}
|
||||
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
|
||||
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
|
||||
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
rtl: p.bool("rtl"),
|
||||
font_align: p.attr("fontAlgn").map(String::from),
|
||||
east_asian_line_break: p.bool("eaLnBrk"),
|
||||
latin_line_break: p.bool("latinLnBrk"),
|
||||
hanging_punct: p.bool("hangingPunct"),
|
||||
default_tab: pt(p, "defTabSz"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
|
||||
.b("rtl", p.rtl)
|
||||
.b("eaLnBrk", p.east_asian_line_break)
|
||||
.b("latinLnBrk", p.latin_line_break)
|
||||
.b("hangingPunct", p.hanging_punct)
|
||||
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
|
||||
let has_kids = p.line_spacing.is_some()
|
||||
|| p.space_before.is_some()
|
||||
|
||||
@@ -766,3 +766,17 @@ fn a_picture_duotone_round_trips() {
|
||||
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
|
||||
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_options_round_trip() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let first = p.slides[0].id;
|
||||
let s = p.slide_mut(first).expect("slide");
|
||||
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
|
||||
t.paragraphs[0].props.latin_line_break = Some(true);
|
||||
t.paragraphs[0].props.hanging_punct = Some(false);
|
||||
let q = round(&p);
|
||||
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.latin_line_break, Some(true));
|
||||
assert_eq!(props.hanging_punct, Some(false));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+131
-4
@@ -8,10 +8,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart;
|
||||
mod colr;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
mod placed;
|
||||
mod ramp;
|
||||
mod table;
|
||||
@@ -247,7 +249,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 +262,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 +1139,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 +1157,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 +1182,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 {
|
||||
|
||||
+35
-72
@@ -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) => {
|
||||
@@ -72,55 +47,11 @@ pub fn fill_path(
|
||||
Fill::Pattern(p) => {
|
||||
let fg = rctx.color(&p.fg, ph);
|
||||
let bg = rctx.color(&p.bg, ph);
|
||||
ctx.set_paint(color(bg, alpha));
|
||||
ctx.fill_path(path);
|
||||
ctx.push_clip_layer(path);
|
||||
ctx.set_paint(color(fg, alpha));
|
||||
pattern(ctx, &p.preset, bounds, m);
|
||||
ctx.pop_layer();
|
||||
crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a pattern's foreground marks over `bounds` (8 pt cells).
|
||||
fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) {
|
||||
let k = crate::scale_of(m);
|
||||
let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 };
|
||||
let cell = cell.max(2.0);
|
||||
let mut p = BezPath::new();
|
||||
let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000));
|
||||
let lw = cell * 0.18;
|
||||
let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90");
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
let x = b.x0 + i as f64 * cell;
|
||||
let y = b.y0 + j as f64 * cell;
|
||||
let r = match preset {
|
||||
p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw),
|
||||
p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell),
|
||||
p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => {
|
||||
p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new());
|
||||
Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell))
|
||||
}
|
||||
_ => {
|
||||
let d = if dense { cell * 0.35 } else { cell * 0.2 };
|
||||
Rect::new(x, y, x + d, y + d)
|
||||
}
|
||||
};
|
||||
p.extend(r.to_path(0.1).iter());
|
||||
if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") {
|
||||
p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.fill_path(&p);
|
||||
}
|
||||
|
||||
fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
|
||||
Rect::new(x, y, x + w, y + h).to_path(0.1)
|
||||
}
|
||||
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
|
||||
|
||||
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
|
||||
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
|
||||
/// cache can apply and key them without a colour scheme.
|
||||
@@ -131,6 +62,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>) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
|
||||
//! (0.75 pt) of the slide, as PowerPoint draws them.
|
||||
//!
|
||||
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
|
||||
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
|
||||
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use kurbo::{Affine, BezPath};
|
||||
use vello_cpu::{Pixmap, RenderContext, peniko};
|
||||
|
||||
/// Side of one pattern cell, in points.
|
||||
pub const CELL: f64 = 0.75;
|
||||
|
||||
const TILES: &[(&str, [u8; 8])] = &[
|
||||
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
|
||||
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
|
||||
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
|
||||
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
|
||||
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
|
||||
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
|
||||
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
|
||||
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
|
||||
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
|
||||
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
|
||||
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
|
||||
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
|
||||
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
|
||||
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
|
||||
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
|
||||
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
|
||||
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
|
||||
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
|
||||
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
|
||||
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
|
||||
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
|
||||
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
|
||||
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
|
||||
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
|
||||
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
|
||||
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
|
||||
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
|
||||
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
|
||||
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
|
||||
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
|
||||
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
|
||||
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
|
||||
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
|
||||
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
|
||||
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
|
||||
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
|
||||
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
|
||||
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
|
||||
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
|
||||
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
|
||||
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
|
||||
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
|
||||
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
|
||||
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
|
||||
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
|
||||
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
|
||||
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
|
||||
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
|
||||
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
|
||||
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
|
||||
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
|
||||
];
|
||||
|
||||
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
|
||||
pub fn tile(name: &str) -> [u8; 8] {
|
||||
TILES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
|
||||
.map(|(_, t)| *t)
|
||||
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
|
||||
}
|
||||
|
||||
/// Is cell (`x`, `y`) of the tile the foreground?
|
||||
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
|
||||
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
|
||||
}
|
||||
|
||||
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
|
||||
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
|
||||
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
|
||||
}
|
||||
|
||||
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
|
||||
/// `alpha`.
|
||||
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
|
||||
let det = m.determinant();
|
||||
if !det.is_finite() || det.abs() <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let t = tile(name);
|
||||
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
|
||||
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler {
|
||||
x_extend: peniko::Extend::Repeat,
|
||||
y_extend: peniko::Extend::Repeat,
|
||||
quality: peniko::ImageQuality::Low,
|
||||
alpha: 1.0,
|
||||
},
|
||||
});
|
||||
// Cells are laid out on the output from its origin (the slide's top-left corner), so
|
||||
// neighbouring shapes' patterns line up, as in PowerPoint.
|
||||
let k = crate::scale_of(m);
|
||||
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
|
||||
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
|
||||
ctx.fill_path(path);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_drawingml_preset_has_a_tile() {
|
||||
// ECMA-376 ST_PresetPatternVal.
|
||||
let all = [
|
||||
"pct5",
|
||||
"pct10",
|
||||
"pct20",
|
||||
"pct25",
|
||||
"pct30",
|
||||
"pct40",
|
||||
"pct50",
|
||||
"pct60",
|
||||
"pct70",
|
||||
"pct75",
|
||||
"pct80",
|
||||
"pct90",
|
||||
"horz",
|
||||
"vert",
|
||||
"ltHorz",
|
||||
"ltVert",
|
||||
"dkHorz",
|
||||
"dkVert",
|
||||
"narHorz",
|
||||
"narVert",
|
||||
"dashHorz",
|
||||
"dashVert",
|
||||
"cross",
|
||||
"dnDiag",
|
||||
"upDiag",
|
||||
"ltDnDiag",
|
||||
"ltUpDiag",
|
||||
"dkDnDiag",
|
||||
"dkUpDiag",
|
||||
"wdDnDiag",
|
||||
"wdUpDiag",
|
||||
"dashDnDiag",
|
||||
"dashUpDiag",
|
||||
"diagCross",
|
||||
"smCheck",
|
||||
"lgCheck",
|
||||
"smGrid",
|
||||
"lgGrid",
|
||||
"dotGrid",
|
||||
"smConfetti",
|
||||
"lgConfetti",
|
||||
"horzBrick",
|
||||
"diagBrick",
|
||||
"solidDmnd",
|
||||
"openDmnd",
|
||||
"dotDmnd",
|
||||
"plaid",
|
||||
"sphere",
|
||||
"weave",
|
||||
"divot",
|
||||
"shingle",
|
||||
"wave",
|
||||
"trellis",
|
||||
"zigZag",
|
||||
];
|
||||
assert_eq!(all.len(), 54);
|
||||
for n in all {
|
||||
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
|
||||
}
|
||||
assert_eq!(TILES.len(), 54);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_presets_get_denser() {
|
||||
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
|
||||
// half, pct90 all but 2).
|
||||
let share = |n: &str| {
|
||||
let t = tile(n);
|
||||
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
|
||||
};
|
||||
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
|
||||
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
|
||||
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
|
||||
assert_eq!(tile("nonsense"), tile("pct50"));
|
||||
}
|
||||
}
|
||||
@@ -632,3 +632,147 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fills_draw_powerpoints_tiles() {
|
||||
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
|
||||
// PowerPoint's export draws it; pct50 is a checkerboard.
|
||||
use deckcraft_model::style::{Fill, PatternFill};
|
||||
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
|
||||
let shape = |id, x: f64, preset: &str| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
|
||||
fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() });
|
||||
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
|
||||
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
|
||||
assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns");
|
||||
assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical");
|
||||
// pct50 starts at slide x = 120 pt = 160 px.
|
||||
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
|
||||
assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}");
|
||||
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
+116
-10
@@ -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(),
|
||||
_ => {
|
||||
@@ -718,8 +767,44 @@ fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
|
||||
(h * face.line_split, h * (1.0 - face.line_split))
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i.
|
||||
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
|
||||
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
|
||||
/// syllables.
|
||||
fn is_hangul(c: char) -> bool {
|
||||
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
|
||||
}
|
||||
|
||||
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
|
||||
fn is_hanging_punct(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'、' | '。'
|
||||
| ','
|
||||
| '.'
|
||||
| ':'
|
||||
| ';'
|
||||
| '!'
|
||||
| '?'
|
||||
| ')'
|
||||
| '」'
|
||||
| '』'
|
||||
| '】'
|
||||
| '〕'
|
||||
| '〉'
|
||||
| '》'
|
||||
| '〙'
|
||||
| '〛'
|
||||
| '}'
|
||||
| ']'
|
||||
| '」'
|
||||
| '、'
|
||||
| '。'
|
||||
)
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
|
||||
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
|
||||
/// Korean breaks only between words.
|
||||
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
|
||||
let n = text.chars().count();
|
||||
let mut allowed = vec![false; n + 1];
|
||||
let mut mandatory = vec![false; n + 1];
|
||||
@@ -740,6 +825,17 @@ fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, w) in chars.windows(2).enumerate() {
|
||||
let (prev, next) = (w[0], w[1]);
|
||||
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
|
||||
if !keep
|
||||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||||
&& let Some(a) = allowed.get_mut(i + 1)
|
||||
{
|
||||
*a = false;
|
||||
}
|
||||
}
|
||||
(allowed, mandatory)
|
||||
}
|
||||
|
||||
@@ -761,7 +857,8 @@ fn break_lines(
|
||||
x0_rest: f64,
|
||||
) -> Vec<RawLine> {
|
||||
let text: String = p.cells.iter().map(|c| c.ch).collect();
|
||||
let (allowed, _) = break_opportunities(&text);
|
||||
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
|
||||
let hang = p.props.hanging_punct.unwrap_or(false);
|
||||
let n = p.cells.len();
|
||||
let mut lines = vec![];
|
||||
let mut start = 0;
|
||||
@@ -788,6 +885,13 @@ fn break_lines(
|
||||
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
|
||||
last_break = Some(i);
|
||||
}
|
||||
// Closing East Asian punctuation hangs past the end instead of wrapping
|
||||
// (`hangingPunct`); the line breaks after it.
|
||||
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
|
||||
end = i + 1;
|
||||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
|
||||
// Break at the last opportunity, or force mid-word (between clusters).
|
||||
let b = last_break
|
||||
@@ -1167,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).
|
||||
@@ -1200,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;
|
||||
}
|
||||
|
||||
@@ -520,3 +520,107 @@ fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
|
||||
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
|
||||
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.inset_l = Some(0.0);
|
||||
t.body.inset_r = Some(0.0);
|
||||
set(&mut t.paragraphs[0].props);
|
||||
let l = lay(&p, &sh, w, 600.0);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Width of `text` on one line.
|
||||
fn one_line(text: &str) -> f64 {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 5000.0, 300.0);
|
||||
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_does_not_break_after_a_slash_or_backslash() {
|
||||
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
|
||||
// where it no longer fits; Unicode line breaking would allow a break after each slash.
|
||||
for sep in ["/", "\\"] {
|
||||
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
|
||||
let w = one_line(&text) * 0.6;
|
||||
let lines = wrapped(&text, w, |_| {});
|
||||
assert!(lines.len() >= 2, "{lines:?}");
|
||||
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
|
||||
}
|
||||
// latinLnBrk on: Unicode's break after a slash is allowed again.
|
||||
let text = "alpha/beta/gamma/delta/epsilon";
|
||||
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
|
||||
assert!(lines[0].ends_with('/'), "{lines:?}");
|
||||
// A slash followed by a space still breaks, and hyphens still break.
|
||||
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
|
||||
assert_eq!(lines[0], "alpha-beta-gamma-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_wraps_between_words() {
|
||||
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
|
||||
let w = one_line("한국어 안녕하세요 반") + 0.5;
|
||||
let lines = wrapped(text, w, |_| {});
|
||||
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
|
||||
// Japanese and Chinese still break between characters.
|
||||
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
|
||||
assert_eq!(lines[0], "日本語の文章");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
|
||||
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
|
||||
// edge instead of pulling the character before it onto the next line.
|
||||
let text = "白なしで続きます。句読点も含めて。";
|
||||
let w = one_line("白なしで続きます") + 0.5;
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
|
||||
// Without it, "。" can't start a line, so "す。" moves down together.
|
||||
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