Merge pull request #3561 from lehni/feature/emoji-bitmaps

Type: colour emoji fonts with bitmap glyphs draw and export
This commit is contained in:
BFlat
2026-10-23 00:59:15 +07:00
committed by GitHub
25 changed files with 940 additions and 61 deletions
+4 -2
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -128,8 +128,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
@@ -192,6 +193,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
+15 -1
View File
@@ -4,7 +4,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
use vectorcraft_doc::{AreaFit, Document, ImageObject, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
@@ -559,6 +559,20 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
continue;
}
// Colour bitmap glyphs (emoji) become embedded images, in place; glyphs that show
// the same image share its pixels, under a key of their own if another image has
// taken theirs.
if let Some((image, to_text)) = g.placed_image() {
let mut key = format!("glyph-{}", image.blob.content_key());
if d.images.get(&key).is_some_and(|b| b.bytes != image.blob.bytes) {
key = super::menucmds::unique_key(d, "glyph");
}
d.images.entry(key.clone()).or_insert_with(|| image.blob.clone());
let im =
ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() };
children.push(Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))));
continue;
}
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
+51
View File
@@ -850,6 +850,57 @@ fn create_outlines_matches_text_bounds() {
assert!(s.doc().unwrap().doc.node(t).is_some());
}
/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image
/// share its pixels.
#[test]
fn create_outlines_turns_bitmap_glyphs_into_images() {
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut s = session();
let r = s
.execute(
"text.create",
&json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F{COLOUR_CHAR}"), "size": 32, "font": colour_bitmap_family(false)}),
)
.unwrap();
let t = NodeId(r["id"].as_u64().unwrap());
let tb = bounds(&s, t);
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
let g = node(&s, r[0]);
let ch = g.children().unwrap();
let images: Vec<_> = ch.iter().filter_map(|c| if let NodeKind::Image(im) = &c.kind { Some(im) } else { None }).collect();
assert_eq!(images.len(), 2, "{:?}", ch.iter().map(|c| c.kind_label()).collect::<Vec<_>>());
assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the outline between them");
assert_eq!(images[0].key, images[1].key, "one image");
let d = &s.doc().unwrap().doc;
assert!(d.images.contains_key(&images[0].key));
// The first one em (32 pt) square, from 2 px (4 pt) right of the origin and 12 px (24 pt)
// above the baseline; inside the type's bounds.
let b = ch[0].geometric_bounds().unwrap();
assert!((b.x0 - 14.0).abs() < 1e-6 && (b.y0 - 26.0).abs() < 1e-6 && (b.width() - 32.0).abs() < 1e-6, "{b:?}");
assert!(b.x0 >= tb.x0 && b.y0 >= tb.y0 - 1e-6 && b.y1 <= tb.y1 + 1e-6, "{b:?} in {tb:?}");
}
/// A glyph image whose key another image of the document holds gets a key of its own.
#[test]
fn create_outlines_keeps_an_image_already_under_a_glyph_images_key() {
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut s = session();
let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE));
let taken = format!("glyph-{}", glyph.content_key());
let other = vectorcraft_doc::ImageBlob::png(solid_png(4, [0, 255, 0, 255]));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(taken.clone(), other.clone());
s.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})).unwrap();
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
let g = node(&s, r[0]);
let Some(NodeKind::Image(im)) = g.children().unwrap().first().map(|c| &c.kind) else { panic!("an image") };
let d = &s.doc().unwrap().doc;
assert_ne!(im.key, taken);
assert_eq!(d.images[&im.key].bytes, glyph.bytes, "the glyph's own pixels");
assert_eq!(d.images[&taken].bytes, other.bytes, "the other image is kept");
}
#[test]
fn set_style_size_changes_bounds() {
let mut s = session();
+1
View File
@@ -32,5 +32,6 @@ serde_json = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
# Tests: a Group 4 encoder and the T.4 run codes, to write CCITT test images.
fax = "0.2"
+15 -9
View File
@@ -64,8 +64,8 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageObject, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin,
Node, NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node,
NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
@@ -678,7 +678,7 @@ impl<'a> Writer<'a> {
}
NodeKind::Image(im) => {
self.lock(locked);
self.image(im);
self.image(self.doc.images.get(&im.key), im.width, im.height, im.xf);
}
NodeKind::SymbolInstance { symbol, xf } => {
self.warn(LIVE);
@@ -1288,6 +1288,12 @@ impl<'a> Writer<'a> {
n.appearance = vectorcraft_doc::Appearance { items, ..vectorcraft_doc::Appearance::default() }.into();
self.shape(&n, &path, FillRule::NonZero);
}
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
for g in &layout.glyphs {
if let Some((image, to_text)) = g.placed_image() {
self.image(Some(&image.blob), image.width, image.height, t.xf * to_text);
}
}
self.line("U");
}
@@ -1298,14 +1304,14 @@ impl<'a> Writer<'a> {
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
/// can leave out an image in a clipping group and the art after it. No path is open there, so
/// the `F` paints nothing.
fn image(&mut self, im: &ImageObject) {
/// the `F` paints nothing. `blob` holds the pixels, placed as a grid of `width` × `height`
/// by `xf`.
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * im.xf * flip;
let m = self.to_art * xf * flip;
if !invertible(m) {
return;
}
let blob = self.doc.images.get(&im.key);
if blob.is_some_and(|b| b.bytes.is_empty()) {
self.warn(LARGE_IMAGE);
return;
@@ -1320,8 +1326,8 @@ impl<'a> Writer<'a> {
return;
}
// The object places the image's own pixel grid (its width × height) wherever the file's is.
let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h));
let m = self.to_art * im.xf * fit * flip;
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
let m = self.to_art * xf * fit * flip;
let rgba = img.to_rgba8();
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
hex.push('%');
+17 -12
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use kurbo::Shape;
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject,
AppearanceItem, ColorMode, Document, Effect, ImageBlob, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject,
};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
@@ -288,7 +288,7 @@ impl<'a> Scene<'a> {
self.shape(n, &bp, *rule);
}
NodeKind::Text(t) => self.text(n, t),
NodeKind::Image(im) => self.image(im),
NodeKind::Image(im) => self.image(&im.key, self.doc.images.get(&im.key), im.width, im.height, im.xf),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
@@ -586,6 +586,11 @@ impl<'a> Scene<'a> {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
// Colour bitmap glyphs (emoji): their images, which the run's paint doesn't paint.
if let Some((image, to_text)) = g.placed_image() {
let key = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
self.image(&key, Some(&image.blob), image.width, image.height, to_text);
}
}
if bp.elements().is_empty() {
continue;
@@ -840,18 +845,18 @@ impl<'a> Scene<'a> {
}
}
/// A placed image: its pixel grid stretched over its `width` × `height` (each image file
/// encoded once).
fn image(&mut self, im: &ImageObject) {
let place = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)), f64::from(im.height.max(1)));
/// A placed image (`blob`, cached under `key`): its pixel grid of `width` × `height` placed
/// by `xf` (each image file encoded once).
fn image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) {
let place = xf * Affine::scale_non_uniform(f64::from(width.max(1)), f64::from(height.max(1)));
if !invertible(place) {
return;
}
let encoded = match self.images.get(&im.key) {
let encoded = match self.images.get(key) {
Some(e) => e.clone(),
None => {
let e = self.decode_image(&im.key).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new);
self.images.insert(im.key.clone(), e.clone());
let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new);
self.images.insert(key.to_string(), e.clone());
e
}
};
@@ -860,9 +865,9 @@ impl<'a> Scene<'a> {
}
}
/// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written.
fn decode_image(&mut self, key: &str) -> Option<image::RgbaImage> {
let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
/// Image `blob` as RGBA pixels; `None` (with a warning) when it can't be written.
fn decode_image(&mut self, blob: Option<&ImageBlob>) -> Option<image::RgbaImage> {
let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
self.warn(UNDECODED);
return None;
};
+19
View File
@@ -499,3 +499,22 @@ fn a_rich_document_is_well_formed_at_both_levels() {
}
}
}
/// A colour bitmap glyph (emoji) is written as an image where it sits; the type's fill doesn't
/// paint it.
#[test]
fn colour_bitmap_glyphs_are_images() {
use vectorcraft_doc::{CharStyle, TextObject};
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let d = doc_with(|d| {
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() };
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 100.0), &COLOUR_CHAR.to_string(), st))))]
});
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
checked_page(&ps);
assert!(ps.contains("/Width 16 /Height 16"), "{ps}");
// One em (32 pt) square, its top 24 pt above the baseline (12 of 16 px), 4 pt right of the
// origin (2 px), in text space.
assert!(ps.contains("[32 0 0 32 4 -24] cm"), "{ps}");
}
+49 -15
View File
@@ -92,20 +92,33 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
/// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted
/// in their first stop's color.
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type), and
// its colour bitmap glyphs as images (their pixels added to `images`).
fn outlined(t: &vectorcraft_doc::TextObject, images: &mut std::collections::BTreeMap<String, ImageBlob>) -> Node {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let runs = t.runs.iter().enumerate().map(|(i, run)| {
let mut bp = kurbo::BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let path = PathData::from_bezpath(&bp).transformed(t.xf);
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
});
Node::group(vectorcraft_doc::NodeId(0), runs.collect())
let mut nodes: Vec<Arc<Node>> = t
.runs
.iter()
.enumerate()
.map(|(i, run)| {
let mut bp = kurbo::BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let path = PathData::from_bezpath(&bp).transformed(t.xf);
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
})
.collect();
for g in &layout.glyphs {
let Some((image, to_text)) = g.placed_image() else { continue };
let key = image.blob.content_key();
images.insert(key.clone(), image.blob.clone());
let im = ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() };
nodes.push(Arc::new(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im)))));
}
Node::group(vectorcraft_doc::NodeId(0), nodes)
}
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions) {
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap<String, ImageBlob>) {
nodes.retain(|n| match &n.kind {
NodeKind::Text(t) => o.outline_text || !writable(t),
NodeKind::Layer { printable, .. } => *printable,
@@ -113,7 +126,7 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
});
for n in nodes {
if let NodeKind::Text(t) = &n.kind {
*n = Arc::new(outlined(t));
*n = Arc::new(outlined(t, images));
}
let n = Arc::make_mut(n);
if o.hidden == HiddenObjects::Visible && !matches!(n.kind, NodeKind::Layer { .. }) {
@@ -129,12 +142,14 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
}
}
if let Some(c) = n.children_mut() {
plain(c, o);
plain(c, o, images);
}
}
}
let mut d = doc.clone();
plain(&mut d.layers, o);
let mut images = std::mem::take(&mut d.images);
plain(&mut d.layers, o, &mut images);
d.images = images;
d
}
@@ -1141,3 +1156,22 @@ fn layer_names_outside_latin_1_are_replaced_and_warned() {
// A name Latin-1 can hold is written as it is, with no warning.
assert!(!layered("Caf\u{e9}").warnings.iter().any(|w| w == LAYER_NAME));
}
/// Type written as outlines writes its colour bitmap glyphs (emoji) as images where they sit.
#[test]
fn colour_bitmap_glyphs_of_outlined_type_are_images() {
use vectorcraft_doc::{CharStyle, TextObject};
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut doc = Document::new(200.0, 100.0);
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() };
// Cyrillic, which 8-bit text can't hold: the type is written as outlines.
let t = TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), &format!("{COLOUR_CHAR}\u{416}"), st);
let l = doc.layers[0].id;
let id = doc.alloc_id();
doc.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
let images = rasters(&text);
assert_eq!(images.len(), 1, "{text}");
assert!(images[0][2].ends_with(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI"), "{:?}", images[0]);
}
+48 -9
View File
@@ -1571,12 +1571,15 @@ impl Exporter<'_> {
}
bp.extend(run_bars.iter());
let fill = &run.style.fill;
// Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves.
let mut images: Option<Vec<usize>> = None;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
s.push_transform(&text_space);
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) {
Some(text) => {
let mut left = ex.glyph_text(s, &layout, i, text, fill);
let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true);
images = Some(unwritten);
left.extend(run_bars.iter());
Cow::Owned(left)
}
@@ -1593,6 +1596,13 @@ impl Exporter<'_> {
}
s.pop();
});
// They paint themselves, whatever the fill.
let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect());
s.push_transform(&text_space);
for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) {
self.glyph_image(s, g);
}
s.pop();
// Characters with neither fill nor stroke (a scanned page's recognized text), or whose
// fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text),
// are still text: written invisible (rendering mode 3), so they can still be selected
@@ -1603,8 +1613,8 @@ impl Exporter<'_> {
crate::forms::form(s, Mark::Invisible, |s| {
s.push_transform(&text_space);
// The paint only lets the writer write the text; the glyphs it can't write
// as text draw nothing.
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)));
// as text draw nothing, and colour bitmap glyphs are drawn already.
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false);
s.pop();
});
}
@@ -1669,16 +1679,21 @@ impl Exporter<'_> {
/// text space; `text` is the plain text, whose characters the glyphs stand for, which makes
/// the text selectable and searchable) → the outlines of the glyphs left to draw as paths:
/// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame
/// (size, scale, rotation) and a baseline are written together.
fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint) -> BezPath {
let mut rest = BezPath::new();
/// (size, scale, rotation) and a baseline are written together. Colour bitmap glyphs (emoji),
/// with `images`, are written as text too, which draws their images; the indices of those it
/// can't write are returned with the outlines.
fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint, images: bool) -> (BezPath, Vec<usize>) {
let (mut rest, mut unwritten) = (BezPath::new(), vec![]);
let mut line: Option<GlyphLine> = None;
for g in layout.glyphs.iter().filter(|g| g.run == run) {
for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
if g.image.is_some() && !images {
continue;
}
let range = g.byte..g.byte + g.len;
let ch = text.get(range.clone()).and_then(|t| t.chars().next());
// Spaces are written too (text extraction needs them); control characters and soft
// hyphens draw nothing.
let blank = g.outline.elements().is_empty();
let blank = g.outline.elements().is_empty() && g.image.is_none();
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
continue;
}
@@ -1690,6 +1705,9 @@ impl Exporter<'_> {
let det = g.xf.determinant();
let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else {
rest.extend(g.outline.iter());
if g.image.is_some() {
unwritten.push(gi);
}
continue;
};
let size = upem * sy;
@@ -1719,7 +1737,28 @@ impl Exporter<'_> {
if let Some(l) = line {
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
rest
(rest, unwritten)
}
/// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once.
fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) {
let Some((image, to_text)) = g.placed_image() else { return };
if !writable(s, to_text, image.rect()) {
return;
}
let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
let img = match self.images.get(&cache) {
Some(img) => img.clone(),
None => {
let img = embed(&image.blob, self.interpolate, self.sixteen_bit);
self.images.insert(cache, img.clone());
img
}
};
let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return };
s.push_transform(&xf(to_text));
s.draw_image(img, size);
s.pop();
}
/// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where
+24
View File
@@ -509,6 +509,30 @@ fn text_exported_as_outlines() {
assert!(b.x0 >= 9.0 && b.x1 < 120.0 && b.y1 <= 56.0 && b.y0 > 25.0, "{b:?}");
}
/// Colour bitmap glyphs (emoji) are images in the PDF: in their font's Type 3 glyphs with real
/// text, else drawn where the glyphs are.
#[test]
fn colour_bitmap_glyphs_export_as_images() {
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
for sbix in [true, false] {
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap());
let mut d = doc(300.0, 100.0);
let st = CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 24.0, ..CharStyle::default() };
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), st)))));
let text = uncompressed(&d).replace(' ', "");
assert!(text.contains("/Type3") && text.contains("/Subtype/Image"), "an image in a Type 3 glyph (sbix {sbix})");
let mut outlined = PdfOptions::uncompressed();
outlined.settings.advanced.outline_text = true;
let r = import_with_report(&export(&d, &outlined).unwrap(), &ImportOptions::default()).unwrap();
let l = leaves(&r.document);
let images: Vec<_> = l.iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).collect();
assert_eq!(images.len(), 1, "outlined, the glyph is an image (sbix {sbix})");
let b = images[0].geometric_bounds().unwrap();
assert!((b.width() - 24.0).abs() < 0.1 && (b.height() - 24.0).abs() < 0.1, "one em: {b:?}");
assert!(l.iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "the other glyph stays an outline");
}
}
fn tiny_png() -> Vec<u8> {
let mut img = image::RgbaImage::new(4, 2);
for (x, _, p) in img.enumerate_pixels_mut() {
+41
View File
@@ -1811,6 +1811,12 @@ impl Renderer {
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p);
// Colour bitmap glyphs: their boxes, as images show in outline mode.
for (_, image, to_text) in &g.images {
let mut b = image.rect().to_path(0.1);
b.apply_affine(t.xf * *to_text);
self.hairline(ctx, f, &b, [0, 0, 0, 255]);
}
self.draw_inlines(ctx, f, t, g);
return;
}
@@ -1852,6 +1858,11 @@ impl Renderer {
}
}
for (i, run) in t.runs.iter().enumerate() {
// Colour bitmap glyphs paint themselves: the run's fill and stroke don't.
for (_, image, to_text) in g.images.iter().filter(|(r, ..)| *r == i) {
overprint(ctx, false);
self.draw_glyph_image(ctx, f, image, t.xf * *to_text);
}
let path = g.run(i);
let path = path.as_ref();
if path.elements().is_empty() {
@@ -1908,6 +1919,26 @@ impl Renderer {
}
}
/// Draw colour bitmap glyph `image` through `xf` (its pixels → document space), its pixels
/// decoded once and kept with the images' pixels.
fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) {
let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return };
let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true);
let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64);
let pm = image_mip(&key, pm, unit);
let sx = image.width as f64 / pm.width().max(1) as f64;
let sy = image.height as f64 / pm.height().max(1) as f64;
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler { quality, ..Default::default() },
});
ctx.set_transform(f.view * xf);
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&image.rect());
ctx.reset_paint_transform();
}
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
self.draw_image_in(ctx, f, im, None);
}
@@ -2336,6 +2367,8 @@ struct TextGeom {
alternate_glyphs: std::sync::OnceLock<BezPath>,
/// Inline graphics placed by the layout.
inlines: Vec<vectorcraft_text::InlineGlyph>,
/// Colour bitmap glyphs: (run, image, its pixels → text space), in text order.
images: Vec<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>,
}
impl TextGeom {
@@ -2384,6 +2417,7 @@ impl TextGeom {
+ self.alternate_glyphs.get().map_or(0, lru::path_bytes)
+ self.spans.len() * size_of::<(usize, usize, usize)>()
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
+ self.images.len() * size_of::<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>()
}
}
@@ -2464,8 +2498,12 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
.collect();
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
let plain = t.plain_text();
let mut images = vec![];
for g in &layout.glyphs {
push(&mut all, g.run, &g.outline);
if let Some((image, xf)) = g.placed_image() {
images.push((g.run, image.clone(), xf));
}
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
@@ -2504,6 +2542,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
alternate_candidates,
alternate_glyphs: std::sync::OnceLock::new(),
inlines: layout.inlines,
images,
}
}
@@ -2565,6 +2604,8 @@ mod tests_adjust;
#[cfg(test)]
mod tests_alternate;
#[cfg(test)]
mod tests_bitmapglyphs;
#[cfg(test)]
mod tests_blend;
#[cfg(test)]
mod tests_changed;
+47
View File
@@ -0,0 +1,47 @@
//! Colour bitmap glyphs (emoji): drawn as their images, which the type's fill doesn't paint.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, colour_bitmap_family, colour_bitmap_font};
use super::*;
/// 100 pt [`COLOUR_CHAR`] in the colour bitmap test font (`sbix` or `CBDT`), filled blue, its
/// baseline at (20, 120), on a white 160×160 page.
fn render(sbix: bool, opts: RenderOptions) -> Rendered {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle {
font_family: colour_bitmap_family(sbix).into(),
size: 100.0,
fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)),
..Default::default()
};
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st))));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts })
}
#[test]
fn a_bitmap_glyph_draws_its_largest_strike_not_the_fill() {
for sbix in [true, false] {
let img = render(sbix, RenderOptions::default());
// Inside the em square the image covers (the CBDT one from 75 above the baseline to 25
// below, the sbix one from 87.2 above to 12.8 below), away from its edges.
assert_eq!(img.pixel(70, 80), COLOUR_LARGE, "sbix {sbix}");
let blue = (0..160).flat_map(|y| (0..160).map(move |x| (x, y))).any(|(x, y)| img.pixel(x, y)[2] > 200 && img.pixel(x, y)[0] < 50);
assert!(!blue, "the fill doesn't paint the glyph (sbix {sbix})");
}
}
#[test]
fn outline_mode_draws_a_bitmap_glyph_as_its_box() {
let img = render(false, RenderOptions { outline: true, ..Default::default() });
assert_eq!(img.pixel(70, 80), [255, 255, 255, 255], "no pixels inside");
// The box's left edge at x 32.5 (the bearing of 2 px of 6.25 pt), from y 45 to 145.
assert!((31..=34).any(|x| img.pixel(x, 100)[0] < 128), "the box's edge shows");
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) fn fonts_steady() -> RwLockReadGuard<'static, ()> {
}
/// No test holding [`fonts_steady`] runs until the guard is dropped.
fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
FONTS.write().unwrap_or_else(PoisonError::into_inner)
}
+16
View File
@@ -1897,6 +1897,17 @@ impl Writer<'_> {
let d = self.path_d(&pd, self.xf);
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
}
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
for (image, to_text) in lay.glyphs.iter().filter_map(|g| g.placed_image()) {
let m = self.matrix(self.xf * to_text);
let href = self.blob_href(&image.blob);
self.line(&format!(
"<image width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
image.width,
image.height,
xml_escape(&href)
));
}
self.xf = saved;
self.depth -= 1;
self.line("</g>");
@@ -2090,6 +2101,11 @@ impl Writer<'_> {
let mut face: Option<Arc<FontFace>> = None;
for g in &lay.glyphs {
let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue };
// A colour bitmap font's subset would hold no bitmaps: its emoji are left to the viewer.
if g.image.is_some() {
self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts");
continue;
}
if face.as_ref().is_none_or(|f| f.id() != g.font_id) {
face = db.face_by_id(g.font_id);
}
+44
View File
@@ -0,0 +1,44 @@
//! Colour bitmap glyphs (emoji): outlined text writes their images; live text leaves them to the
//! viewer, and embedded fonts don't carry them.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use skrifa::raw::TableProvider;
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject};
use vectorcraft_geom::Point;
use vectorcraft_svg::{ExportOptions, export_full};
use vectorcraft_testkit::format::base64_decode;
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
/// [`COLOUR_CHAR`] and an outline glyph in the `CBDT` test font, 24 pt.
fn doc() -> Document {
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut d = Document::new(200.0, 100.0);
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 24.0, ..CharStyle::default() };
let mut n =
Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), st))));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn outlined_text_writes_bitmap_glyphs_as_images() {
let svg = export_full(&doc(), &ExportOptions { outline_text: true, ..Default::default() }, None).svg;
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(svg.contains("width=\"16\" height=\"16\"") && svg.contains("xlink:href=\"data:image/png;base64,"), "{svg}");
assert!(svg.contains("<path"), "the other glyph is an outline");
}
#[test]
fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() {
let out = export_full(&doc(), &ExportOptions { embed_fonts: true, ..Default::default() }, None);
let data = out.svg.split("base64,").nth(1).unwrap().split(')').next().unwrap();
let font = base64_decode(data).unwrap();
let cmap = skrifa::FontRef::new(&font).unwrap().cmap().unwrap();
assert!(cmap.map_codepoint('F').is_some());
assert!(cmap.map_codepoint(COLOUR_CHAR).is_none(), "its subset would hold no bitmap");
assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings);
assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text");
}
+88
View File
@@ -0,0 +1,88 @@
//! Colour bitmap glyphs (`sbix`, as in Apple Color Emoji, and `CBDT`/`CBLC`, as in Noto Color
//! Emoji): the PNG of a glyph's largest strike, placed in font units. Other graphic types (`sbix`
//! `dupe`, `flip`, `jpg `, `tiff`; `CBDT` BGRA and mask data) and `EBDT` (hinting bitmaps of
//! outline fonts) draw nothing.
use kurbo::{Affine, Rect};
use skrifa::GlyphId;
use skrifa::bitmap::{BitmapData, BitmapFormat, BitmapStrikes, Origin};
use skrifa::instance::Size;
use vectorcraft_doc::ImageBlob;
/// The most pixels along either side of a glyph image read from a font, as its PNG header gives
/// them. Emoji strikes are at most a few hundred pixels (Apple Color Emoji's largest is 160): a
/// font is untrusted input, and each glyph image is decoded and kept apart (at most 4 MB of pixels).
pub(crate) const MAX_SIDE: u32 = 1024;
/// The most bytes of a glyph image's PNG: twice its largest pixels' raw size, which a PNG
/// (compressed, or stored uncompressed) stays well within.
pub(crate) const MAX_PNG_BYTES: usize = 2 * 4 * (MAX_SIDE as usize) * (MAX_SIDE as usize);
/// A colour bitmap glyph: a PNG drawn in place of an outline. Fill and stroke don't paint it.
#[derive(Debug)]
pub struct GlyphImage {
/// The PNG, as image objects hold their pixels.
pub blob: ImageBlob,
pub width: u32,
pub height: u32,
/// Pixel space (0..width, 0..height, y down) → font units (y down, as outlines are).
pub xf: Affine,
}
impl GlyphImage {
/// The image's pixel rectangle (0..width, 0..height).
pub fn rect(&self) -> Rect {
Rect::new(0.0, 0.0, self.width as f64, self.height as f64)
}
}
/// Does `font` have colour bitmap strikes?
pub(crate) fn has_colour_bitmaps(font: &skrifa::FontRef<'_>) -> bool {
strikes(font).is_some_and(|s| !s.is_empty())
}
fn strikes<'a>(font: &skrifa::FontRef<'a>) -> Option<BitmapStrikes<'a>> {
BitmapStrikes::with_format(font, BitmapFormat::Sbix).or_else(|| BitmapStrikes::with_format(font, BitmapFormat::Cbdt))
}
/// Glyph `gid`'s image in its largest strike, if it is a PNG.
pub(crate) fn glyph_image(font: &skrifa::FontRef<'_>, upem: f64, gid: u32) -> Option<GlyphImage> {
let strikes = strikes(font)?;
let sbix = strikes.format() == Some(BitmapFormat::Sbix);
let g = strikes.glyph_for_size(Size::unscaled(), GlyphId::new(gid))?;
let BitmapData::Png(png) = g.data else { return None };
// The size the PNG decodes to is its header's (`CBDT`'s own metrics may say otherwise).
let (w, h) = png_size(png)?;
let (ppem_x, ppem_y) = (f64::from(g.ppem_x), f64::from(g.ppem_y));
if png.len() > MAX_PNG_BYTES || w > MAX_SIDE || h > MAX_SIDE || !(ppem_x >= 1.0 && ppem_y >= 1.0 && ppem_x.is_finite() && ppem_y.is_finite()) {
return None;
}
let (sx, sy) = (upem / ppem_x, upem / ppem_y);
// The image's top left in font units (y up): the outer bearings in font units, the inner ones
// in pixels, from the bottom left corner for `sbix`.
let left = f64::from(g.bearing_x) + f64::from(g.inner_bearing_x) * sx;
let mut top = f64::from(g.bearing_y) + f64::from(g.inner_bearing_y) * sy;
if g.placement_origin == Origin::BottomLeft {
top += h as f64 * sy;
}
// Apple Color Emoji sits lower than its `sbix` table says (CoreText's look, which the PDF
// writer's Type 3 glyphs imitate too).
if sbix {
top -= 0.128 * upem;
}
if !(left.is_finite() && top.is_finite()) {
return None;
}
Some(GlyphImage { blob: ImageBlob::png(png.to_vec()), width: w, height: h, xf: Affine::new([sx, 0.0, 0.0, sy, left, -top]) })
}
/// The width and height in PNG `bytes`' header (its signature, then the IHDR chunk); `None` when
/// it doesn't read or a side is 0.
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.get(..8)? != b"\x89PNG\r\n\x1a\n" || bytes.get(12..16)? != b"IHDR" {
return None;
}
let be = |at: usize| Some(u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?));
let (w, h) = (be(16)?, be(20)?);
(w > 0 && h > 0).then_some((w, h))
}
+82 -3
View File
@@ -13,6 +13,8 @@ use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider, Tag};
use crate::GlyphImage;
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
@@ -132,6 +134,8 @@ pub struct FontFace {
ideographic_centre: std::sync::OnceLock<f64>,
/// [`Self::icf_margins`], read once.
icf_margins: std::sync::OnceLock<IcfMargins>,
/// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]).
colour_bitmaps: bool,
}
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
@@ -410,10 +414,37 @@ type PostScriptNames = HashMap<String, (String, String)>;
/// are neither a loaded nor an installed family.
type CanonicalNames = HashMap<(String, String), (String, String)>;
/// The glyph images [`FontDb::glyph_image`] keeps by face and glyph, and their PNGs' bytes in all.
#[derive(Default)]
struct GlyphImages {
images: HashMap<(u32, u32), Option<Arc<GlyphImage>>>,
bytes: usize,
}
impl GlyphImages {
/// Keeps `image` (or that there is none), first letting go of all it keeps when that would
/// take more than [`IMAGE_CACHE_MAX`] images or [`IMAGE_CACHE_BYTES`]. A glyph it keeps
/// already, which another thread looked up meanwhile, stays as it is.
fn keep(&mut self, key: (u32, u32), image: Option<Arc<GlyphImage>>) {
if self.images.contains_key(&key) {
return;
}
let bytes = image.as_ref().map_or(0, |i| i.blob.bytes.len());
if self.images.len() >= IMAGE_CACHE_MAX || self.bytes.saturating_add(bytes) > IMAGE_CACHE_BYTES {
self.images.clear();
self.bytes = 0;
}
self.bytes += bytes;
self.images.insert(key, image);
}
}
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// [`FontDb::glyph_image`]'s answers per (face, glyph), misses too.
images: Mutex<GlyphImages>,
catalog: RwLock<Catalog>,
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
postscript: RwLock<PostScriptNames>,
@@ -550,6 +581,10 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// The most glyph images [`FontDb::glyph_image`] keeps (each a PNG of some 10–60 KB).
const IMAGE_CACHE_MAX: usize = 4_096;
/// The most PNG bytes it keeps: a font can make each one up to [`crate::bitmap::MAX_PNG_BYTES`].
const IMAGE_CACHE_BYTES: usize = 64 << 20;
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
/// them before it keeps the next one.
const CANONICAL_CACHE_MAX: usize = 4_096;
@@ -1026,10 +1061,11 @@ fn font_files(path: &Path) -> Vec<Vec<u8>> {
std::fs::read(path).into_iter().collect()
}
/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with
/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing.
/// The tables holding glyphs the font engine draws: TrueType, CFF, CFF2 and VARC outlines, and
/// `sbix` and `CBDT` colour bitmaps. A face with none of them (outlines in Apple's `hvgl` table,
/// monochrome bitmaps only) draws nothing.
#[cfg(not(target_arch = "wasm32"))]
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
const OUTLINE_TABLES: [&[u8; 4]; 6] = [b"glyf", b"CFF ", b"CFF2", b"VARC", b"sbix", b"CBDT"];
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of
@@ -1517,6 +1553,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
};
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
let colour_bitmaps = crate::bitmap::has_colour_bitmaps(&f);
Some(FontFace {
keys,
traits,
@@ -1543,6 +1580,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
path,
ideographic_centre: std::sync::OnceLock::new(),
icf_margins: std::sync::OnceLock::new(),
colour_bitmaps,
})
}
@@ -1646,6 +1684,7 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
images: Mutex::new(GlyphImages::default()),
catalog: RwLock::new(Catalog::new()),
postscript: RwLock::new(PostScriptNames::new()),
font_paths,
@@ -2414,6 +2453,20 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph `gid`'s colour bitmap (`sbix`, `CBDT`) in `face`, cached per (face, glyph).
pub fn glyph_image(&self, face: &FontFace, gid: u32) -> Option<Arc<GlyphImage>> {
if !face.colour_bitmaps {
return None;
}
let key = (face.id, gid);
if let Some(i) = self.images.lock().unwrap_or_else(|e| e.into_inner()).images.get(&key) {
return i.clone();
}
let image = face.skrifa().and_then(|f| crate::bitmap::glyph_image(&f, face.upem, gid)).map(Arc::new);
self.images.lock().unwrap_or_else(|e| e.into_inner()).keep(key, image.clone());
image
}
}
struct FlipPen(BezPath);
@@ -2575,3 +2628,29 @@ mod race_tests {
}
}
}
#[cfg(test)]
mod image_cache_tests {
use super::*;
#[test]
fn the_glyph_image_cache_keeps_its_bytes_within_its_budget() {
// Images as large as a font may make them: no test font has enough of those to fill the
// budget, so they're made here.
let image = || {
let blob = vectorcraft_doc::ImageBlob::png(vec![0; crate::bitmap::MAX_PNG_BYTES]);
Some(Arc::new(GlyphImage { blob, width: 1, height: 1, xf: kurbo::Affine::IDENTITY }))
};
let mut cache = GlyphImages::default();
for gid in 0..20 {
cache.keep((1, gid), image());
assert!(cache.bytes <= IMAGE_CACHE_BYTES, "{} bytes after {} images", cache.bytes, gid + 1);
}
// The same glyph again, as when two threads look it up at once, counts once.
cache.keep((1, 19), image());
assert_eq!(cache.bytes, cache.images.values().flatten().map(|i| i.blob.bytes.len()).sum::<usize>());
// A glyph without an image costs nothing, and is still kept.
cache.keep((1, 99), None);
assert!(cache.images.contains_key(&(1, 99)));
}
}
+12 -3
View File
@@ -104,7 +104,11 @@ impl Ctx<'_> {
}
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = if g.inline.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) };
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let blank = g.is_soft_hyphen() || g.ch.is_control();
// A colour bitmap glyph draws its image, not its outline.
let image = if g.inline.is_some() || blank { None } else { self.db.glyph_image(&g.face, g.gid) };
let src = if g.inline.is_some() || image.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) };
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by moving it up the column once it stands upright.
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
@@ -140,8 +144,7 @@ impl Ctx<'_> {
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)))
* m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
let outline = if src.elements().is_empty() || blank {
BezPath::new()
} else {
let mut p = BezPath::with_capacity(src.elements().len());
@@ -169,6 +172,7 @@ impl Ctx<'_> {
// Vertical type keeps its logical order down the column.
rtl: g.level.is_rtl() && !self.vertical,
scale: 1.0,
image,
});
}
@@ -723,6 +727,9 @@ fn finish_bounds(out: &mut TextLayout) {
if !g.outline.elements().is_empty() {
add(g.outline.bounding_box());
}
if let Some((image, xf)) = g.placed_image() {
add(xf.transform_rect_bbox(image.rect()));
}
}
// Ruby: covered by its line's ascent, but its ink can overhang sideways past the line.
for g in &out.ruby {
@@ -2935,5 +2942,7 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
xf: Affine::IDENTITY,
rtl: false,
scale: 1.0,
// Ruby draws outlines only.
image: None,
}
}
+16
View File
@@ -10,6 +10,7 @@
//! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space.
#![forbid(unsafe_code)]
mod bitmap;
mod composer;
pub mod craft_fonts;
pub mod edit;
@@ -28,6 +29,7 @@ mod suitcase;
pub mod test_fonts;
pub mod thread;
pub use bitmap::GlyphImage;
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
@@ -35,6 +37,8 @@ pub use fontdb::{
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use std::sync::Arc;
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
@@ -118,6 +122,16 @@ pub struct PositionedGlyph {
/// (Justification…), Desired and justification together: 1 at 100%. Horizontal Scale isn't
/// included.
pub scale: f64,
/// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is
/// empty.
pub image: Option<Arc<GlyphImage>>,
}
impl PositionedGlyph {
/// The glyph's colour bitmap, and the transform from its pixels to text space.
pub fn placed_image(&self) -> Option<(&Arc<GlyphImage>, Affine)> {
self.image.as_ref().map(|i| (i, self.xf * i.xf))
}
}
/// One line of laid-out text.
@@ -596,6 +610,8 @@ mod tests_axes;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_bitmaps;
#[cfg(test)]
mod tests_combos;
#[cfg(test)]
mod tests_devanagari;
+178
View File
@@ -407,3 +407,181 @@ pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
}
Some(builder.build())
}
/// The character whose glyph [`colour_bitmap_font`] draws as a colour bitmap.
pub const COLOUR_CHAR: char = 'E';
/// The colour of [`colour_bitmap_font`]'s bitmap in its largest strike (RGBA); the `sbix` font's
/// smaller strike is [`COLOUR_SMALL`].
pub const COLOUR_LARGE: [u8; 4] = [255, 0, 0, 255];
/// The colour of the `sbix` font's smaller strike.
pub const COLOUR_SMALL: [u8; 4] = [0, 0, 255, 255];
/// The pixels per em of [`colour_bitmap_font`]'s largest strike: its square bitmap is one em wide.
pub const COLOUR_PPEM: u8 = 16;
/// Where [`colour_bitmap_font`]`(false)`'s (`CBDT`) bitmap sits, in pixels: its left edge right of
/// the glyph's origin, its top edge above the baseline.
pub const COLOUR_CBDT_BEARINGS: (i8, i8) = (2, 12);
/// The family of [`colour_bitmap_font`] (`sbix`, or `CBDT`).
pub fn colour_bitmap_family(sbix: bool) -> &'static str {
if sbix { "Sbixtest Emoji" } else { "Cbdttest Emoji" }
}
/// The bundled Source Sans 3 Regular as family [`colour_bitmap_family`], [`COLOUR_CHAR`] a solid
/// square colour bitmap one em wide: in `sbix` strikes of [`COLOUR_PPEM`] ([`COLOUR_LARGE`]) and
/// half that ([`COLOUR_SMALL`]), each with no origin offset, or (`sbix` false) in one `CBDT`
/// strike of [`COLOUR_PPEM`] ([`COLOUR_LARGE`], bearings [`COLOUR_CBDT_BEARINGS`]). Its outline
/// stays too. Tables written from the OpenType specification. `None` when it can't be built.
pub fn colour_bitmap_font(sbix: bool) -> Option<Vec<u8>> {
colour_bitmap_font_with(sbix, &solid_png(COLOUR_PPEM.into(), COLOUR_LARGE))
}
/// [`colour_bitmap_font`] with `png` as the bitmap of its largest strike (a malformed one, say).
pub fn colour_bitmap_font_with(sbix: bool, png: &[u8]) -> Option<Vec<u8>> {
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let glyphs = font.maxp().ok()?.num_glyphs();
let gid = u16::try_from(font.charmap().map(COLOUR_CHAR)?.to_u32()).ok()?;
let family = colour_bitmap_family(sbix);
let ps = family.replace(' ', "");
let name = name_table(&[(1, family), (2, "Regular"), (4, &format!("{family} Regular")), (6, &format!("{ps}-Regular"))])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
if sbix {
let small = solid_png((COLOUR_PPEM / 2).into(), COLOUR_SMALL);
builder.add_raw(Tag::new(b"sbix"), sbix_table(glyphs, gid, &[(COLOUR_PPEM / 2, &small), (COLOUR_PPEM, png)]));
} else {
let (cblc, cbdt) = cbdt_tables(gid, png);
builder.add_raw(Tag::new(b"CBLC"), cblc);
builder.add_raw(Tag::new(b"CBDT"), cbdt);
}
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// An `sbix` table of `strikes` (ppem, PNG), each holding a PNG for glyph `gid` alone.
fn sbix_table(glyphs: u16, gid: u16, strikes: &[(u8, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
out.extend(1u16.to_be_bytes()); // version
out.extend(1u16.to_be_bytes()); // flags
out.extend((strikes.len() as u32).to_be_bytes());
let header = 8 + 4 * strikes.len();
let mut data = Vec::new();
for (ppem, png) in strikes {
out.extend(((header + data.len()) as u32).to_be_bytes());
let offsets = 4 + 4 * (usize::from(glyphs) + 1);
let mut strike = Vec::new();
strike.extend(u16::from(*ppem).to_be_bytes());
strike.extend(72u16.to_be_bytes()); // ppi
// Glyph data offsets: empty but for `gid`'s (origin offsets 0, `png `, the PNG).
let record = 8 + png.len();
for g in 0..=glyphs {
let at = offsets + if g > gid { record } else { 0 };
strike.extend((at as u32).to_be_bytes());
}
strike.extend([0, 0, 0, 0]);
strike.extend(b"png ");
strike.extend(*png);
data.extend(strike);
}
out.extend(data);
out
}
/// `CBLC` and `CBDT` tables of one strike of [`COLOUR_PPEM`] holding `png` for glyph `gid`
/// (index subtable format 1, glyph data format 17).
fn cbdt_tables(gid: u16, png: &[u8]) -> (Vec<u8>, Vec<u8>) {
let ppem = COLOUR_PPEM;
let mut cbdt = Vec::new();
cbdt.extend(3u16.to_be_bytes());
cbdt.extend(0u16.to_be_bytes());
// Small glyph metrics: height, width, bearing x, bearing y, advance.
let (bx, by) = COLOUR_CBDT_BEARINGS;
cbdt.extend([ppem, ppem, bx as u8, by as u8, ppem]);
cbdt.extend((png.len() as u32).to_be_bytes());
cbdt.extend(png);
let mut cblc = Vec::new();
cblc.extend(3u16.to_be_bytes());
cblc.extend(0u16.to_be_bytes());
cblc.extend(1u32.to_be_bytes()); // numSizes
let array = 8 + 48;
let subtable = 8;
let index_size = subtable + 8 + 2 * 4;
cblc.extend((array as u32).to_be_bytes());
cblc.extend((index_size as u32).to_be_bytes());
cblc.extend(1u32.to_be_bytes()); // numberOfIndexSubTables
cblc.extend(0u32.to_be_bytes()); // colorRef
// Horizontal and vertical line metrics.
let line = [ppem as i8, 0, ppem as i8, 1, 0, 0, 0, 0, 0, 0, 0, 0].map(|v| v as u8);
cblc.extend(line);
cblc.extend(line);
cblc.extend(gid.to_be_bytes());
cblc.extend(gid.to_be_bytes());
cblc.extend([ppem, ppem, 32, 1]); // ppem x, ppem y, bit depth, flags (horizontal)
// The index subtable array, then the subtable: format 1, image format 17, its data at 4.
cblc.extend(gid.to_be_bytes());
cblc.extend(gid.to_be_bytes());
cblc.extend((subtable as u32).to_be_bytes());
cblc.extend(1u16.to_be_bytes());
cblc.extend(17u16.to_be_bytes());
cblc.extend(4u32.to_be_bytes());
cblc.extend(0u32.to_be_bytes());
cblc.extend(((cbdt.len() - 4) as u32).to_be_bytes());
(cblc, cbdt)
}
/// A `side`×`side` PNG of one colour (RGBA), written uncompressed (stored deflate blocks).
pub fn solid_png(side: u32, rgba: [u8; 4]) -> Vec<u8> {
fn crc32(bytes: &[u8]) -> u32 {
let mut c = !0u32;
for b in bytes {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend((data.len() as u32).to_be_bytes());
let mut body = kind.to_vec();
body.extend(data);
out.extend(&body);
out.extend(crc32(&body).to_be_bytes());
}
// Each row: filter type 0 (none), then its pixels.
let row: Vec<u8> = std::iter::once(0).chain(rgba.repeat(side as usize)).collect();
let raw = row.repeat(side as usize);
let (mut a, mut b) = (1u32, 0u32);
for v in &raw {
a = (a + u32::from(*v)) % 65521;
b = (b + a) % 65521;
}
let mut zlib = vec![0x78, 0x01];
let blocks: Vec<&[u8]> = raw.chunks(0xffff).collect();
for (i, block) in blocks.iter().enumerate() {
zlib.push(u8::from(i + 1 == blocks.len()));
let n = block.len() as u16;
zlib.extend(n.to_le_bytes());
zlib.extend((!n).to_le_bytes());
zlib.extend(*block);
}
zlib.extend(((b << 16) | a).to_be_bytes());
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend(side.to_be_bytes());
ihdr.extend(side.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]); // 8 bits, RGBA
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &zlib);
chunk(&mut out, b"IEND", &[]);
out
}
+1
View File
@@ -160,6 +160,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
xf: Affine::IDENTITY,
rtl,
scale: 1.0,
image: None,
});
x += advance;
}
+155
View File
@@ -0,0 +1,155 @@
//! Colour bitmap glyphs (`sbix`, `CBDT`): laid out as images in place of outlines, in the box
//! their strike gives, moving with the type.
use kurbo::{Point, Rect};
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use vectorcraft_doc::CharStyle;
use super::test_fonts::{COLOUR_CBDT_BEARINGS, COLOUR_CHAR, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font};
use super::*;
fn db(sbix: bool) -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(colour_bitmap_font(sbix).expect("the test font builds")), 1);
db
}
fn style(sbix: bool) -> CharStyle {
CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 100.0, ..CharStyle::default() }
}
/// The text-space box of glyph `g`'s image.
fn image_box(g: &PositionedGlyph) -> Rect {
let (image, xf) = g.placed_image().expect("the glyph is an image");
xf.transform_rect_bbox(image.rect())
}
fn assert_near(a: Rect, b: Rect) {
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1];
assert!(d.iter().all(|d| d.abs() < 1e-6), "{a:?} is not {b:?}");
}
/// The left side bearing of [`COLOUR_CHAR`] in the bundled Source Sans 3 (font units of 1000).
fn lsb() -> f64 {
let font = skrifa::FontRef::new(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
let gid = font.charmap().map(COLOUR_CHAR).unwrap();
f64::from(font.glyph_metrics(Size::unscaled(), LocationRef::default()).left_side_bearing(gid).unwrap())
}
#[test]
fn an_sbix_glyph_is_its_largest_strike_one_em_wide_sitting_on_the_shifted_baseline() {
let db = db(true);
let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(true)));
let g = &l.glyphs[0];
assert!(g.outline.elements().is_empty(), "the image replaces the outline");
let image = g.image.as_ref().unwrap();
assert_eq!((image.width, image.height), (u32::from(COLOUR_PPEM), u32::from(COLOUR_PPEM)), "the largest strike");
// From the glyph's left side bearing, its bottom 0.128 em below the baseline.
let x = g.origin.x + lsb() * 0.1;
assert_near(image_box(g), Rect::new(x, g.origin.y - 87.2, x + 100.0, g.origin.y + 12.8));
assert!(l.bounds.contains_rect(image_box(g)), "the bounds hold the image: {:?}", l.bounds);
}
#[test]
fn a_cbdt_glyph_sits_where_its_bearings_put_it() {
let db = db(false);
let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(false)));
let g = &l.glyphs[0];
assert!(g.outline.elements().is_empty());
let (bx, by) = COLOUR_CBDT_BEARINGS;
let px = 100.0 / f64::from(COLOUR_PPEM);
let (x, top) = (g.origin.x + f64::from(bx) * px, g.origin.y - f64::from(by) * px);
assert_near(image_box(g), Rect::new(x, top, x + 100.0, top + 100.0));
}
#[test]
fn a_bitmap_glyph_scales_with_the_type() {
let db = db(false);
let st = CharStyle { h_scale: 200.0, ..style(false) };
let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), st));
let b = image_box(&l.glyphs[0]);
assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_bitmap_glyph_moves_when_type_snaps_to_pixels() {
let db = db(false);
let mut l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(false)));
let (before, origin) = (image_box(&l.glyphs[0]), l.glyphs[0].origin);
assert!(l.snap_to_pixels(Affine::translate((0.3, 0.4))));
let g = &l.glyphs[0];
assert!(g.origin != origin);
assert_near(image_box(g), before + (g.origin - origin));
}
#[test]
fn other_glyphs_of_a_colour_font_keep_their_outlines() {
let db = db(true);
let l = layout(&db, &TextObject::point(Point::ZERO, "F", style(true)));
assert!(l.glyphs[0].image.is_none());
assert!(!l.glyphs[0].outline.elements().is_empty());
}
/// A strike PNG past the caps, or whose header doesn't read, gives the glyph no image: it is not
/// decoded (a font is untrusted input).
#[test]
fn a_bitmap_past_the_caps_or_without_a_header_is_no_image() {
use super::bitmap::{MAX_PNG_BYTES, MAX_SIDE};
use super::test_fonts::{COLOUR_LARGE, colour_bitmap_font_with, solid_png};
let good = solid_png(COLOUR_PPEM.into(), COLOUR_LARGE);
// A header claiming a side past the cap (the IHDR width).
let mut wide = good.clone();
wide[16..20].copy_from_slice(&(MAX_SIDE + 1).to_be_bytes());
// More bytes than a glyph's PNG may have.
let mut long = good.clone();
long.resize(MAX_PNG_BYTES + 1, 0);
let headless = good[8..].to_vec();
for sbix in [true, false] {
for (what, png) in [("wide", &wide), ("long", &long), ("headless", &headless), ("good", &good)] {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(colour_bitmap_font_with(sbix, png).unwrap()), 1);
let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(sbix)));
assert_eq!(l.glyphs[0].image.is_some(), what == "good", "{what} (sbix {sbix})");
}
}
}
/// A colour bitmap font without outline tables (Noto Color Emoji has `CBDT` but no `glyf`) is an
/// installed font like any other.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_bitmap_only_font_file_is_cataloged() {
use crate::fontdb::file_face_names_within;
let full = colour_bitmap_font(false).unwrap();
let font = skrifa::FontRef::new(&full).unwrap();
let mut builder = write_fonts::FontBuilder::new();
for r in font.table_directory.table_records() {
if ![*b"glyf", *b"loca"].contains(&r.tag().to_be_bytes()) {
builder.add_raw(write_fonts::types::Tag::new(&r.tag().to_be_bytes()), font.table_data(r.tag()).unwrap().as_bytes());
}
}
let dir = std::env::temp_dir().join(format!("vc-bitmaps-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Bitmaps.ttf");
std::fs::write(&file, builder.build()).unwrap();
let faces = file_face_names_within(&file, 4, u64::MAX);
assert_eq!(faces.len(), 1, "the face is cataloged");
}
/// The system's colour emoji font, when installed: an emoji draws as an image, and a ZWJ sequence
/// as one glyph.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_installed_colour_emoji_font_draws_emoji_as_images() {
let db = FontDb::with_font_dirs(system_font_dirs());
let Some(family) = ["Apple Color Emoji", "Noto Color Emoji"].into_iter().find(|f| db.has_family(f)) else {
eprintln!("no colour emoji font installed: skipped");
return;
};
let st = CharStyle { font_family: family.into(), size: 40.0, ..CharStyle::default() };
let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F600}", st.clone()));
assert!(l.glyphs[0].image.is_some(), "{family} draws 😀 as an image");
let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F469}\u{200D}\u{1F4BB}", st));
assert_eq!(l.glyphs.iter().filter(|g| g.image.is_some()).count(), 1, "the sequence is one glyph");
}
+10 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long