mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Text: colour emoji draw from their own colour tables (#268)
Colour emoji came out as the placeholder rectangles their outlines carry (Apple Color Emoji) or as .notdef when the face has no outline at all: nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for colour ink before its outline: an embedded bitmap strike decoded to premultiplied RGBA and placed by the strike's bearings the way Skia (and CoreText) places it, scaled to the size the run asks for, or a COLR v0 glyph's layers filled from the font's palette. Faces that carry only colour tables are indexed again: Apple Color Emoji is no longer skipped for being 190 MB (the scan still reads headers and name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" as colour fallbacks, read only for a text that nothing else covers. The png crate decodes the strikes; skrifa keeps shaping, metrics and outlines. Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and PDF exports (text is outlines there). Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding, scaling, COLR layers and bitmaps of the machine's own colour font) and a render case gated on that font; the font-free ones run everywhere. Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji (CBDT) read from a downloaded file; the sbix path runs through the same code and needs a macOS run to be seen. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
Generated
+1
@@ -1680,6 +1680,7 @@ dependencies = [
|
||||
"figcraft-geom",
|
||||
"harfrust 0.13.3",
|
||||
"log",
|
||||
"png",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (colour emoji drawn from the system colour fonts (#268): bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers draw instead of the placeholder outlines; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -312,6 +312,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph that has colour ink draws that instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT: decoded to premultiplied RGBA, placed by the strike's bearings the way Skia places it and scaled to the size the run asks for) or a COLR v0 glyph's layers filled from the palette; fonts that carry only colour tables (Apple Color Emoji's placeholder outlines, Noto Color Emoji's none at all) are indexed again, "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" and all three are read only for a text that nothing else covers; left for later: COLR v1 paint graphs, alpha-only strikes, emoji in the SVG and PDF exports (text is outlines there). 9 tests (8 in the text crate: placement, PNG and raw-BGRA decoding, scaling, the machine's own colour font; one in the renderer), the font-free ones run everywhere. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers.
|
||||
//!
|
||||
//! Colour emoji fonts don't draw like other glyphs: their outlines are placeholder rectangles
|
||||
//! (Apple Color Emoji) or missing altogether (Noto Color Emoji), and the picture itself lives in a
|
||||
//! bitmap strike or in a stack of coloured layers. 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 the placeholders 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;
|
||||
/// 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 formula is the one Skia (and CoreText behind it)
|
||||
/// uses: bearings are in font units and scale with the em, the origin offsets and the image
|
||||
/// with the strike's ppem, and an sbix image is measured from its lower-left corner.
|
||||
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()?;
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut buf).ok()?;
|
||||
if info.width == 0 || info.height == 0 || info.width > 4096 || info.height > 4096 {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = (info.width, info.height);
|
||||
let data = &buf[..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)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! 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 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 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());
|
||||
}
|
||||
@@ -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 ~190 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 ~190 MB, and its faces
|
||||
// are the colour emoji themselves): `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 has placeholder outlines, Noto Color Emoji has none 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())))
|
||||
|
||||
@@ -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,17 @@ 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 ~190 MB): they are read only
|
||||
// for a text whose 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));
|
||||
if chars.iter().any(|c| !covered(*c)) {
|
||||
fallbacks.extend(dbw.emoji_fallbacks());
|
||||
}
|
||||
drop(dbw);
|
||||
|
||||
// OpenType features the user toggled (global for the node).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
|
||||
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
|
||||
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
|
||||
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed; colour emoji draw from the system colour fonts — bitmap strikes (sbix, CBDT/EBDT) or COLR v0 layers — where they used to draw the placeholder outlines, COLR v1 still falling back to outlines) | 3–7 |
|
||||
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
|
||||
| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
|
||||
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
|
||||
@@ -153,7 +153,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to
|
||||
| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool |
|
||||
| Frames with device presets | 8 | 80% | |
|
||||
| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | |
|
||||
| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check |
|
||||
| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check; colour emoji from COLR v0 layers or bitmap strikes only (COLR v1 fonts fall back to their outlines) |
|
||||
| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images |
|
||||
| Layers panel: rename, hide, lock, reorder | 7 | 80% | |
|
||||
| Basic auto layout (add, gap, padding) | 6 | 70% | |
|
||||
@@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Colour emoji drawn (#268): a glyph with colour ink draws it instead of its outline — an embedded bitmap strike (sbix, CBDT/EBDT) decoded to premultiplied RGBA and placed the way Skia places it, or a COLR v0 glyph's layers filled from the palette; faces that carry only colour tables (Apple Color Emoji, Noto Color Emoji) are indexed again and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" in the fallbacks. Left: COLR v1 paint graphs, alpha-only strikes, the SVG and PDF exports. See [ui-parity](ui-parity.md); numbers unchanged |
|
||||
| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) |
|
||||
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
|
||||
+2
-1
@@ -80,7 +80,7 @@ interaction feel not yet compared.
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check; colour emoji draw from the system colour fonts (bitmap strikes or COLR v0 layers; COLR v1 falls back to outlines) |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
|
||||
@@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Colour emoji draw from the system colour fonts (#268): a glyph with colour ink (an sbix, CBDT or EBDT bitmap strike, or COLR v0 layers) draws that instead of its outline, scaled to the size the run asks for; COLR v1 fonts still draw their outlines |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu |
|
||||
| 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |
|
||||
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
|
||||
|
||||
Reference in New Issue
Block a user