Merge #300: Text: colour emoji draw from their own colour tables (#268) (@fede9234)

This commit is contained in:
bflatastic
2026-10-15 19:32:34 +07:00
9 changed files with 708 additions and 13 deletions
Generated
+1
View File
@@ -1681,6 +1681,7 @@ dependencies = [
"figcraft-geom",
"harfrust 0.13.3",
"log",
"png",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
+51 -5
View File
@@ -531,21 +531,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
NodeKind::Text(t) => {
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy));
// Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a
// bitmap the font ships, or COLR layers with a colour each.
let gs = gxf.determinant().abs().sqrt();
let mut ink: Vec<(usize, Ink)> = Vec::new();
// Group glyphs by style so runs with their own fills paint with them.
let mut by_style: HashMap<usize, BezPath> = HashMap::new();
for g in &layout.glyphs {
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
match g.color(gs) {
Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))),
Some(figcraft_text::ColorInk::Layers(layers)) => {
for layer in layers.iter() {
ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba)));
}
}
None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()),
}
}
for d in &layout.decorations {
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
}
let mut keys: Vec<usize> = by_style.keys().copied().collect();
// A run can have ink and no outline at all (bitmap-only fonts).
let mut keys: Vec<usize> = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect();
keys.sort_unstable();
// Every run's fills, then what paints over the fills, then the strokes on top.
keys.dedup();
// Every run's fills (its ink included), then what paints over the fills, then the
// strokes on top.
for k in &keys {
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
if let Some(tp) = by_style.get(k).and_then(ts_path) {
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
}
for (_, item) in ink.iter().filter(|(style, _)| style == k) {
match item {
Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)),
Ink::Layer(path, rgba) => {
if let Some(tp) = ts_path(path) {
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
paint.anti_alias = true;
pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None);
}
}
}
}
}
if let Some(f) = over_fills {
f(pix);
@@ -687,6 +716,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0))));
}
/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a
/// bitmap-strike glyph has no outline to stroke.
enum Ink {
/// An embedded bitmap and the transform from its pixels into layout coordinates.
Bitmap(Arc<figcraft_text::ColorBitmap>, Affine),
/// One COLR layer: its outline in layout coordinates and the colour to fill it with.
Layer(BezPath, [u8; 4]),
}
/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the
/// transform scales it.
fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) {
let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return };
let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
pix.draw_pixmap(0, 0, image, &paint, transform, None);
}
fn is_axis_aligned(xf: Affine) -> bool {
let c = xf.as_coeffs();
c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5])
+31
View File
@@ -217,6 +217,37 @@ fn text_renders_something() {
}
}
/// The machine's colour emoji face, if it has one (this test skips without: no font ships here).
fn emoji_face() -> Option<(String, String)> {
figcraft_text::scan_system_fonts();
let mut db = figcraft_text::db().write().unwrap();
db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone()))
}
#[test]
fn colour_emoji_draw_their_pixels() {
let Some((family, style)) = emoji_face() else { return };
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let mut td = TextData::new("\u{1F600}");
(td.style.font_family, td.style.font_style) = (family.clone(), style.clone());
td.style.font_size = 48.0;
d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap();
let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
// The emoji is the only thing in this document that paints saturated colour.
let coloured = pix
.data()
.as_chunks::<4>()
.0
.iter()
.filter(|c| {
let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2]));
c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60
})
.count();
assert!(coloured > 200, "{family} drew {coloured} coloured pixels");
}
#[test]
fn export_png_roundtrip() {
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
+2
View File
@@ -13,6 +13,8 @@ figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
# Colour emoji: sbix/CBDT strikes are PNG images the text layer decodes for the renderer.
png = { workspace = true }
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
log = { workspace = true }
+313
View File
@@ -0,0 +1,313 @@
//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers.
//!
//! Colour emoji fonts don't draw like other glyphs: the picture itself lives in a bitmap strike
//! (sbix, CBDT/EBDT) or in a stack of coloured layers, not in the glyph's outline — in Apple Color
//! Emoji most emoji are `sbix` bitmaps while the flags are drawn from outlines, and Noto Color
//! Emoji has no outlines at all. This module gives the renderer either a
//! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours
//! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is
//! left to the outline pipeline, which draws whatever outlines the face has, as before.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::PoisonError;
use figcraft_geom::Affine;
use skrifa::bitmap::{BitmapData, BitmapGlyph, BitmapStrikes, Origin};
use skrifa::color::ColorPalettes;
use skrifa::instance::Size;
use skrifa::raw::TableProvider;
use skrifa::{FontRef, GlyphId};
use crate::fontdb::{Coords, Face};
/// Colour rasters kept per face before the cache starts over, in bytes: they are pixels, and a
/// canvas zoomed far in makes each one large.
const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024;
/// Rasters kept per face (misses count too: a face whose strikes cover a few glyphs builds one
/// per glyph and size, and the zoom changes the size).
const MAX_RASTER_ENTRIES: usize = 8192;
/// COLR layer lists kept per face (24 bytes a layer, so a count is enough).
const MAX_LAYERS: usize = 4096;
/// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels).
const MAX_PNG_SIDE: u32 = 4096;
/// Largest colour raster per em (device pixels). Past this the renderer scales one up instead.
pub const MAX_PX_PER_EM: f64 = 512.0;
/// A colour bitmap glyph: premultiplied RGBA at `size` px per em, and the offset of its top-left
/// corner from the glyph's pen, in px at `size` (y down).
#[derive(Debug)]
pub struct ColorBitmap {
pub width: u32,
pub height: u32,
pub left: f64,
pub top: f64,
pub rgba: Arc<[u8]>,
}
/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills
/// with (straight RGBA: the renderer paints it).
#[derive(Debug)]
pub struct ColorLayer {
pub gid: u32,
pub rgba: [u8; 4],
}
/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller
/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline).
pub enum ColorInk {
Bitmap(Arc<ColorBitmap>, Affine),
Layers(Arc<Vec<ColorLayer>>),
}
/// Colour rasters cached per face, keyed by glyph, variations and size.
#[derive(Default)]
pub(crate) struct Rasters {
entries: HashMap<(u32, Coords, u64), Option<Arc<ColorBitmap>>>,
bytes: usize,
}
impl Rasters {
fn get(&self, key: &(u32, Coords, u64)) -> Option<Arc<ColorBitmap>> {
self.entries.get(key).and_then(Option::clone)
}
fn known(&self, key: &(u32, Coords, u64)) -> bool {
self.entries.contains_key(key)
}
fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option<Arc<ColorBitmap>>) {
if let Some(b) = &bitmap {
self.bytes += b.rgba.len();
}
if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES {
self.entries.clear();
self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0);
}
self.entries.insert(key, bitmap);
}
}
impl Face {
/// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers.
pub fn has_color(&self) -> bool {
self.has_bitmaps() || self.has_colr()
}
/// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`)
pub fn has_bitmaps(&self) -> bool {
self.bitmap_strikes
}
/// The face has a `COLR` table.
pub fn has_colr(&self) -> bool {
self.colr
}
/// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset
/// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no
/// bitmap for the glyph.
pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option<Arc<ColorBitmap>> {
if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 {
return None;
}
let size = size.clamp(1.0, MAX_PX_PER_EM);
let key = (gid, coords.clone(), size.to_bits());
let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner);
if cache.known(&key) {
return cache.get(&key);
}
let made = self.read_bitmap(gid, size).map(Arc::new);
cache.insert(key, made.clone());
made
}
/// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data).
pub fn color_layers(&self, gid: u32) -> Option<Arc<Vec<ColorLayer>>> {
if !self.colr {
return None;
}
let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(hit) = cache.get(&gid) {
return hit.clone();
}
let made = self.read_layers(gid).map(Arc::new);
if cache.len() >= MAX_LAYERS {
cache.clear();
}
cache.insert(gid, made.clone());
made
}
/// Read a strike bitmap and place it the way Skia's fontations port does
/// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with
/// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from
/// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin).
fn read_bitmap(&self, gid: u32, size: f64) -> Option<ColorBitmap> {
let gid = GlyphId::new(gid);
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?;
let (rgba, width, height) = decode(&strike)?;
if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 {
return None;
}
let (left, top) = placement(&strike, size, self.upem);
// Scale the strike's own pixels to the requested size (the caller blits them 1:1).
let scale = size / f64::from(strike.ppem_y.max(1.0));
let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? };
Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() })
}
/// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is
/// every glyph of a COLR v1-only font).
fn read_layers(&self, gid: u32) -> Option<Vec<ColorLayer>> {
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
let colr = font.colr().ok()?;
let range = colr.v0_base_glyph(GlyphId::new(gid))?;
let palettes = ColorPalettes::new(&font);
let palette = palettes.get(0);
let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]);
let mut out = Vec::new();
for index in range {
let (layer, color_index) = colr.v0_layer(index)?;
let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]);
out.push(ColorLayer { gid: layer.to_u32(), rgba });
}
(!out.is_empty()).then_some(out)
}
}
/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down).
fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) {
let units = size / upem.max(1.0);
let pixels = size / f64::from(glyph.ppem_y.max(1.0));
let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units;
let above = match glyph.placement_origin {
// sbix: the origin is the image's lower-left corner, so the whole image hangs above it.
Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height),
Origin::TopLeft => f64::from(glyph.inner_bearing_y),
};
(left, -above * pixels - f64::from(glyph.bearing_y) * units)
}
/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size.
fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec<u8>, u32, u32)> {
match &glyph.data {
// The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are
// premultiplied here for the renderer.
BitmapData::Png(png) => {
let (rgba, width, height) = decode_png(png)?;
Some((premultiplied(rgba), width, height))
}
// Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so).
BitmapData::Bgra(bgra) => {
let (width, height) = (glyph.width, glyph.height);
let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec();
for px in rgba.chunks_exact_mut(4) {
px.swap(0, 2);
}
Some((rgba, width, height))
}
// Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs.
BitmapData::Mask(_) => None,
}
}
#[cfg(test)]
#[path = "color_tests.rs"]
mod tests;
/// A PNG strike as straight 8-bit RGBA.
fn decode_png(png: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
// Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA.
decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
let mut reader = decoder.read_info().ok()?;
// The header's size is checked before anything is allocated for it: a font can claim any size.
let (w, h) = reader.info().size();
if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE {
return None;
}
let mut buf = vec![0u8; reader.output_buffer_size()?];
let info = reader.next_frame(&mut buf).ok()?;
let (width, height) = (info.width, info.height);
let data = buf.get(..info.buffer_size())?;
let rgba = match info.color_type {
png::ColorType::Rgba => data.to_vec(),
png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(),
png::ColorType::Indexed => return None,
};
Some((rgba, width, height))
}
/// Straight RGBA → premultiplied RGBA (in place).
fn premultiplied(mut rgba: Vec<u8>) -> Vec<u8> {
for px in rgba.chunks_exact_mut(4) {
let a = u32::from(px[3]);
for c in &mut px[..3] {
*c = ((u32::from(*c) * a + 127) / 255) as u8;
}
}
rgba
}
/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing.
fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option<Vec<u8>> {
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
return None;
}
let sample = |x: u32, y: u32| -> [u8; 4] {
let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize;
match src.get(i..i + 4) {
Some(px) => [px[0], px[1], px[2], px[3]],
None => [0, 0, 0, 0],
}
};
let mut out = vec![0u8; dw as usize * dh as usize * 4];
if dw >= sw && dh >= sh {
let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1));
for y in 0..dh {
let sy = at(f64::from(y) / f64::from(dh), sh);
let (y0, fy) = (sy.floor() as u32, sy.fract());
for x in 0..dw {
let sx = at(f64::from(x) / f64::from(dw), sw);
let (x0, fx) = (sx.floor() as u32, sx.fract());
let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1));
let o = ((y * dw + x) * 4) as usize;
for c in 0..4 {
let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx;
let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx;
out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8;
}
}
}
return Some(out);
}
let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh));
for y in 0..dh {
let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32);
for x in 0..dw {
let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32);
let mut sum = [0.0f64; 4];
let mut n = 0.0f64;
for sy in y0..y1.clamp(y0 + 1, sh) {
for sx in x0..x1.clamp(x0 + 1, sw) {
let px = sample(sx, sy);
for c in 0..4 {
sum[c] += f64::from(px[c]);
}
n += 1.0;
}
}
let o = ((y * dw + x) * 4) as usize;
for c in 0..4 {
out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8;
}
}
}
Some(out)
}
+230
View File
@@ -0,0 +1,230 @@
//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit.
//!
//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's
//! colour emoji face and skips (like the other suites) on a machine without one; which format it
//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI
//! Emoji is COLR), so a machine exercises the paths its own font has.
use super::*;
use crate::fontdb::{db, no_coords, scan_system_fonts};
use crate::layout::layout;
use figcraft_doc::TextData;
use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin};
/// A strike glyph described by hand, for the placement cases.
fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> {
BitmapGlyph {
data: BitmapData::Png(&[]),
bearing_x: bearing.0,
bearing_y: bearing.1,
inner_bearing_x: inner.0,
inner_bearing_y: inner.1,
ppem_x: ppem,
ppem_y: ppem,
advance: None,
width,
height,
placement_origin: origin,
}
}
/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral).
fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> {
BitmapGlyph {
data,
bearing_x: 0.0,
bearing_y: 0.0,
inner_bearing_x: 0.0,
inner_bearing_y: 0.0,
ppem_x: 10.0,
ppem_y: 10.0,
advance: None,
width,
height,
placement_origin: Origin::TopLeft,
}
}
#[test]
fn bitmap_placement_follows_the_strike() {
// CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the
// strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px.
let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0);
let (left, top) = placement(&cbdt, 20.0, 1000.0);
assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it");
// sbix: bearings and origin offsets are font units, and the image is placed by its lower-left
// corner, so its top is the whole image above the origin.
let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0);
let (left, top) = placement(&sbix, 20.0, 1000.0);
assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}");
assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}");
// Doubling the size doubles the offset with it.
let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0);
assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0);
assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0);
}
/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then
/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51).
const PNG_STRAIGHT: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02,
0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0,
0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn png_strikes_decode_to_premultiplied_rgba() {
let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap();
assert_eq!((width, height), (2, 2));
assert_eq!(
rgba,
vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51],
"straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied"
);
}
#[test]
fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() {
// PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA).
let mut png = PNG_STRAIGHT.to_vec();
assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum");
png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]);
let crc = crc32(&png[12..29]);
png[29..33].copy_from_slice(&crc.to_be_bytes());
assert!(decode_png(&png).is_none());
}
/// The CRC-32 a PNG chunk ends with.
fn crc32(bytes: &[u8]) -> u32 {
!bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }))
}
#[test]
fn raw_bgra_strikes_are_already_premultiplied() {
// BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only
// moves the red and blue channels.
let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap();
assert_eq!((width, height), (2, 1));
assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]);
}
#[test]
fn strikes_that_are_not_colour_decode_to_nothing() {
let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] };
assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour");
assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn");
assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped");
}
#[test]
fn scaling_keeps_the_pixels_premultiplied() {
// A 4×4 face: one straight colour over a falling alpha, premultiplied by hand.
let straight = [220u32, 180, 60];
let ramp = [255u32, 190, 128, 64, 0];
let mut src = Vec::new();
for y in 0..4 {
for x in 0..4 {
let a = ramp[(x + y) % ramp.len()];
src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8));
src.push(a as u8);
}
}
for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] {
let out = scaled(&src, 4, 4, dw, dh).unwrap();
assert_eq!(out.len(), (dw * dh * 4) as usize);
for px in out.chunks_exact(4) {
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}");
}
}
assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing");
}
/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo).
fn emoji_face() -> Option<std::sync::Arc<Face>> {
scan_system_fonts();
let mut db = db().write().unwrap();
db.emoji_fallbacks().into_iter().find(|f| f.has_color())
}
#[test]
fn colour_emoji_carry_their_own_pixels() {
let Some(face) = emoji_face() else { return };
// A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it.
let gid = face.glyph_for('\u{1F600}');
assert_ne!(gid, 0, "U+1F600 is not in {}", face.family);
if let Some(layers) = face.color_layers(gid) {
assert!(!layers.is_empty());
assert!(layers.len() < 64, "{} layers", layers.len());
assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}");
return;
}
let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else {
panic!("{} has colour tables but draws nothing for U+1F600", face.family);
};
// The image is a sane size for 32 px of text and its pixels are premultiplied (what the
// renderer's surfaces hold), so no channel is brighter than its alpha.
assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width);
assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height);
assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4);
let mut opaque = 0;
for px in bitmap.rgba.chunks_exact(4) {
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}");
opaque += usize::from(px[3] == 255);
}
assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels");
// A bigger strike is scaled to a bigger image, and a smaller one to a smaller image.
let small = face.color_bitmap(gid, &no_coords(), 16.0);
let large = face.color_bitmap(gid, &no_coords(), 64.0);
if let (Some(small), Some(large)) = (small, large) {
assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width);
}
}
#[test]
fn emoji_in_plain_text_draw_from_a_colour_face() {
// A symbol font among the fallbacks (Windows has one) draws U+1F600 in one colour: the emoji
// face comes first for it.
if emoji_face().is_none() {
return;
}
let l = layout(&TextData::new("Hi \u{1F600}"), None, None);
assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink");
}
#[test]
fn glyphs_place_their_ink_at_a_scale() {
let Some(face) = emoji_face() else { return };
let mut t = TextData::new("\u{1F600}");
(t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone());
t.style.font_size = 32.0;
let l = layout(&t, None, None);
let Some(g) = l.glyphs.first() else { return };
// Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates.
let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else {
assert!(g.color(2.0).is_some(), "the emoji glyph has ink");
return;
};
assert!(bitmap.width >= 32 && bitmap.width <= 65, "{} px at 2:1", bitmap.width);
let corner = place * figcraft_geom::Point::new(0.0, 0.0);
assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x);
assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y);
// Past the raster cap the image stops growing (a canvas zoomed far in).
let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return };
assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width);
}
#[test]
fn text_without_colour_ink_keeps_its_outlines() {
// Faces without colour tables answer "no ink" without touching the font again, so plain text
// (the whole canvas, most of the time) pays one flag per glyph.
let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return };
if face.has_color() {
return;
}
let gid = face.glyph_for('A');
assert!(gid != 0, "Inter draws A");
assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none());
}
+37 -5
View File
@@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider};
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
use crate::color::{ColorLayer, Rasters};
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
@@ -107,6 +109,11 @@ pub struct Face {
charmap: HashMap<u32, u32>,
outlines: Mutex<HashMap<(u32, Coords), Arc<BezPath>>>,
advances: Mutex<HashMap<(u32, Coords), f64>>,
/// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers.
pub(crate) bitmap_strikes: bool,
pub(crate) colr: bool,
pub(crate) rasters: Mutex<Rasters>,
pub(crate) layers: Mutex<HashMap<u32, Option<Arc<Vec<ColorLayer>>>>>,
}
impl std::fmt::Debug for Face {
@@ -139,6 +146,9 @@ impl Face {
gsub_features.dedup();
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
let (axes, instances) = variation_info(&f);
let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some();
let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT");
let colr = has_table(b"COLR");
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
let weight = {
@@ -169,6 +179,10 @@ impl Face {
bytes,
outlines: Mutex::new(HashMap::new()),
advances: Mutex::new(HashMap::new()),
bitmap_strikes,
colr,
rasters: Mutex::new(Rasters::default()),
layers: Mutex::new(HashMap::new()),
})
}
@@ -613,7 +627,6 @@ impl FontDb {
"PingFang SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Apple Color Emoji",
"Noto Sans Arabic",
"Apple Symbols",
"Segoe UI Symbol",
@@ -625,6 +638,19 @@ impl FontDb {
out
}
/// Colour emoji faces, for characters no other fallback covers. Kept out of
/// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB)
/// and only texts that actually show emoji need them; each family reads one face.
pub fn emoji_fallbacks(&mut self) -> Vec<Arc<Face>> {
let mut out = Vec::new();
for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] {
if let Some(f) = self.find_one(fam, "Regular") {
out.push(f);
}
}
out
}
pub fn face_count(&self) -> usize {
self.faces.len()
}
@@ -699,8 +725,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
continue;
}
// Skip very large files (colour emoji); they are not outline fonts we can draw anyway.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 64 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
@@ -743,7 +771,8 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
Some(buf)
}
/// (index, family, style) of each outline face (one we can draw) in a font file.
/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color
/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all).
fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
let count = match FileRef::new(bytes) {
Ok(FileRef::Collection(c)) => c.len(),
@@ -753,7 +782,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
(0..count.min(64))
.filter_map(|i| {
let f = skrifa::FontRef::from_index(bytes, i).ok()?;
[b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?;
[b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"]
.iter()
.any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some())
.then_some(())?;
let (family, style) = names(&f);
let family = family.filter(|f| !f.starts_with('.'))?;
Some((i, family, style.unwrap_or_else(|| "Regular".into())))
+41 -3
View File
@@ -10,6 +10,7 @@ use figcraft_doc::{
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
use crate::color::{ColorInk, MAX_PX_PER_EM};
use crate::fontdb::{Coords, Face, db, no_coords};
/// A positioned glyph (pen position on the baseline, layout coordinates, y down).
@@ -37,11 +38,34 @@ pub struct Glyph {
impl Glyph {
/// The glyph outline placed in layout coordinates.
pub fn path(&self) -> BezPath {
self.layer_path(self.gid)
}
/// The outline of one of the face's glyphs (a COLR layer), placed like this glyph.
pub fn layer_path(&self, gid: u32) -> BezPath {
let s = self.size / self.face.upem;
let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone();
let mut p = (*self.face.outline_at(gid, &self.coords)).clone();
p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
p
}
/// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout
/// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the
/// outline). `None` — the usual case — means the outline is what draws.
pub fn color(&self, scale: f64) -> Option<ColorInk> {
if !scale.is_finite() || scale <= 0.0 {
return None;
}
if let Some(layers) = self.face.color_layers(self.gid) {
return Some(ColorInk::Layers(layers));
}
// Whole device pixels per em: rasters stay on the pixel grid and the cache stays small.
let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM);
let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?;
let s = px / self.size;
let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s);
Some(ColorInk::Bitmap(bitmap, place))
}
}
#[derive(Clone, Debug)]
@@ -299,11 +323,16 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
span_style.push(idx);
}
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let fallbacks = dbw.fallbacks();
let mut fallbacks = dbw.fallbacks();
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
for s in &styles {
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
}
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
// for a text with emoji, or with characters nothing above covers.
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
fallbacks.extend(emoji.iter().cloned());
drop(dbw);
// OpenType features the user toggled (global for the node).
@@ -334,7 +363,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if c == '\n' || primary.has_char(c) {
return primary.clone();
}
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
// Emoji take a colour face before the symbol fonts, which draw many of them in one colour.
let colour = if is_emoji(c) { emoji.iter().find(|f| f.has_char(c)) } else { None };
colour.or_else(|| fallbacks.iter().find(|f| f.has_char(c))).cloned().unwrap_or_else(|| primary.clone())
};
let first = chars.get(seg_start).copied().unwrap_or(' ');
let face = face_for(first);
@@ -958,6 +989,13 @@ fn sanitize_size(s: f64) -> f64 {
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
}
/// A character drawn as a colour emoji by default: flags and the pictograph blocks.
/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the
/// emoji presentation data if they should turn colour too.
fn is_emoji(c: char) -> bool {
matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}')
}
/// One run of chars shaped with a single face and style.
struct Segment<'a> {
start: usize,
+2
View File
@@ -7,12 +7,14 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod breaks;
pub mod color;
pub mod display;
pub mod fontdb;
mod layout;
mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords,
scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,