mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Type: COLR colour fonts draw and export as vector art
Glyphs whose paints documents have no form for (sweep gradients, compositing modes) are drawn as images of the glyph.
This commit is contained in:
Generated
+1
@@ -4990,6 +4990,7 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vello_cpu 0.2.0",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
+3
-1
@@ -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 (G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: 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,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
|
||||
- 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged.
|
||||
- 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.
|
||||
|
||||
@@ -195,6 +196,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
|
||||
| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
|
||||
| 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). |
|
||||
|
||||
@@ -573,6 +573,11 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
children.push(Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))));
|
||||
continue;
|
||||
}
|
||||
// COLR glyphs become groups of their layers, in place, with ids of the document's.
|
||||
if let Some(art) = g.placed_colour(t.xf) {
|
||||
children.push(Arc::new(d.reid(&art)));
|
||||
continue;
|
||||
}
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -1071,3 +1071,25 @@ fn offset_path_that_leaves_nothing_says_so() {
|
||||
let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string();
|
||||
assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}");
|
||||
}
|
||||
|
||||
/// COLR glyphs become groups of their layers: real paths with their colours and gradients, the
|
||||
/// composite's blend mode kept, every node with an id of the document's.
|
||||
#[test]
|
||||
fn create_outlines_turns_colr_glyphs_into_layer_groups() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(true).unwrap());
|
||||
let mut s = session();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colr_family(true)})).unwrap();
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let ch = g.children().unwrap();
|
||||
assert!(matches!(ch[0].kind, NodeKind::Group { .. }), "the glyph's group: {:?}", ch[0].kind_label());
|
||||
assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the `F` an outline");
|
||||
let mut nodes = vec![];
|
||||
ch[0].walk(&mut |n| nodes.push((n.id, n.blend, n.appearance.fill_paint())));
|
||||
assert!(nodes.iter().any(|(_, _, p)| matches!(p, vectorcraft_color::Paint::Gradient(_))), "its gradients");
|
||||
assert!(nodes.iter().any(|(_, b, _)| *b == vectorcraft_color::BlendMode::Multiply), "its multiply");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(nodes.iter().all(|(id, ..)| d.node(*id).is_some() && seen.insert(*id)), "ids of the document's, each once");
|
||||
}
|
||||
|
||||
@@ -1293,6 +1293,10 @@ impl<'a> Writer<'a> {
|
||||
if let Some((image, to_text)) = g.placed_image() {
|
||||
self.image(Some(&image.blob), image.width, image.height, t.xf * to_text);
|
||||
}
|
||||
// COLR glyphs: their layers, as art.
|
||||
if let Some(art) = g.placed_colour(t.xf) {
|
||||
self.node(&art, false);
|
||||
}
|
||||
}
|
||||
self.line("U");
|
||||
}
|
||||
|
||||
@@ -591,6 +591,10 @@ impl<'a> Scene<'a> {
|
||||
let key = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
|
||||
self.image(&key, Some(&image.blob), image.width, image.height, to_text);
|
||||
}
|
||||
// COLR glyphs: their layers, as art (in text space, as the images).
|
||||
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
|
||||
self.node(&art, true);
|
||||
}
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -518,3 +518,20 @@ fn colour_bitmap_glyphs_are_images() {
|
||||
// origin (2 px), in text space.
|
||||
assert!(ps.contains("[32 0 0 32 4 -24] cm"), "{ps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_glyphs_are_vector_art() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap());
|
||||
let d = doc_with(|d| {
|
||||
let fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
|
||||
let st = CharStyle { font_family: colr_family(false).into(), size: 32.0, fill, ..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"), "no image: {ps}");
|
||||
assert!(ps.contains("1 0 0 rg"), "the red layer: {ps}");
|
||||
assert!(ps.contains("0 0 1 rg"), "the foreground layer in the fill: {ps}");
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
|
||||
/// 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), and
|
||||
// its colour bitmap glyphs as images (their pixels added to `images`).
|
||||
// its colour bitmap glyphs as images (their pixels added to `images`), its COLR glyphs as their
|
||||
// layers.
|
||||
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 mut nodes: Vec<Arc<Node>> = t
|
||||
@@ -116,6 +117,8 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
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)))));
|
||||
}
|
||||
// COLR glyphs: their layers.
|
||||
nodes.extend(layout.glyphs.iter().filter_map(|g| g.placed_colour(t.xf)).map(Arc::new));
|
||||
Node::group(vectorcraft_doc::NodeId(0), nodes)
|
||||
}
|
||||
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap<String, ImageBlob>) {
|
||||
@@ -1175,3 +1178,20 @@ fn colour_bitmap_glyphs_of_outlined_type_are_images() {
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_glyphs_of_outlined_type_are_vector_art() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap());
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let st = CharStyle { font_family: colr_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();
|
||||
assert!(rasters(&text).is_empty(), "no images: {text}");
|
||||
assert!(text.contains("1 0 0 Xa"), "the red layer: {text}");
|
||||
}
|
||||
|
||||
@@ -1605,6 +1605,10 @@ impl Exporter<'_> {
|
||||
s.push_transform(&text_space);
|
||||
for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) {
|
||||
self.glyph_image(s, g);
|
||||
// COLR glyphs: their layers, as art.
|
||||
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
|
||||
self.node(s, &art, page, true);
|
||||
}
|
||||
}
|
||||
s.pop();
|
||||
// Characters with neither fill nor stroke (a scanned page's recognized text), or whose
|
||||
@@ -1683,21 +1687,22 @@ 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. 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.
|
||||
/// (size, scale, rotation) and a baseline are written together. Colour glyphs (bitmap emoji,
|
||||
/// COLR), with `images`, are written as text too, which draws their images or layers; 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 (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
|
||||
if g.image.is_some() && !images {
|
||||
let coloured = g.image.is_some() || g.colour.is_some();
|
||||
if coloured && !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() && g.image.is_none();
|
||||
let blank = g.outline.elements().is_empty() && !coloured;
|
||||
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
|
||||
continue;
|
||||
}
|
||||
@@ -1709,7 +1714,7 @@ 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() {
|
||||
if coloured {
|
||||
unwritten.push(gi);
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1068,3 +1068,35 @@ fn dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill() {
|
||||
assert_ne!(export_with(true, false), export_with(false, false), "the fill's pixels carry the noise");
|
||||
assert_eq!(export_with(true, true), export_with(false, true), "a stroke has no dither");
|
||||
}
|
||||
|
||||
/// COLR glyphs in the PDF: as real text, in their font's Type 3 glyphs (the foreground layer in
|
||||
/// the fill); outlined, as vector art, a sweep glyph as an image.
|
||||
#[test]
|
||||
fn colr_glyphs_export_as_vector_art() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_family, colr_font};
|
||||
for v1 in [false, true] {
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
|
||||
}
|
||||
let page = |v1: bool, ch: char, outline_text: bool| {
|
||||
let mut d = doc(300.0, 100.0);
|
||||
let fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
|
||||
let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() };
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st)))));
|
||||
let mut o = PdfOptions::uncompressed();
|
||||
o.settings.advanced.outline_text = outline_text;
|
||||
import_with_report(&export(&d, &o).unwrap(), &ImportOptions::default()).unwrap().document
|
||||
};
|
||||
let fills = |d: &Document| -> Vec<Paint> { leaves(d).iter().map(|n| n.appearance.fill_paint()).collect() };
|
||||
let near =
|
||||
|p: &Paint, rgb: [f32; 3]| matches!(p, Paint::Solid { color, .. } if color.to_rgb().iter().zip(rgb).all(|(a, b)| (a - b).abs() < 0.02));
|
||||
for outline_text in [false, true] {
|
||||
let v0 = fills(&page(false, COLOUR_CHAR, outline_text));
|
||||
assert!(v0.iter().any(|p| near(p, [1.0, 0.0, 0.0])), "the red layer (outlined {outline_text}): {v0:?}");
|
||||
assert!(v0.iter().any(|p| near(p, [0.0, 0.0, 1.0])), "the foreground layer in the fill (outlined {outline_text}): {v0:?}");
|
||||
}
|
||||
let v1 = page(true, COLOUR_CHAR, true);
|
||||
assert!(!leaves(&v1).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "vector art");
|
||||
assert!(fills(&v1).iter().any(|p| matches!(p, Paint::Gradient(_))), "with its gradients");
|
||||
let sweep = page(true, COLR_SWEEP_CHAR, true);
|
||||
assert!(leaves(&sweep).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "a sweep glyph is an image");
|
||||
}
|
||||
|
||||
@@ -1817,6 +1817,10 @@ impl Renderer {
|
||||
b.apply_affine(t.xf * *to_text);
|
||||
self.hairline(ctx, f, &b, [0, 0, 0, 255]);
|
||||
}
|
||||
// COLR glyphs: their layers' outlines.
|
||||
for (_, art) in &g.colours {
|
||||
self.draw_colour_glyph(ctx, f, art, t.xf);
|
||||
}
|
||||
self.draw_inlines(ctx, f, t, g);
|
||||
return;
|
||||
}
|
||||
@@ -1863,6 +1867,9 @@ impl Renderer {
|
||||
overprint(ctx, false);
|
||||
self.draw_glyph_image(ctx, f, image, t.xf * *to_text);
|
||||
}
|
||||
for (_, art) in g.colours.iter().filter(|(r, _)| *r == i) {
|
||||
self.draw_colour_glyph(ctx, f, art, t.xf);
|
||||
}
|
||||
let path = g.run(i);
|
||||
let path = path.as_ref();
|
||||
if path.elements().is_empty() {
|
||||
@@ -1919,6 +1926,15 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw COLR glyph `art` (its layers in text space) through `xf` (text space → document), as
|
||||
/// art: kept between frames as symbol art is ([`Self::placed_art`]).
|
||||
fn draw_colour_glyph(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc<Node>, xf: Affine) {
|
||||
let (node, kept) = self.placed_art(art, xf, true, None);
|
||||
let outer = std::mem::replace(&mut self.made_again, !kept);
|
||||
self.draw_node(ctx, f, &node, true);
|
||||
self.made_again = outer;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -2372,6 +2388,8 @@ struct TextGeom {
|
||||
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)>,
|
||||
/// COLR colour glyphs: (run, their layers placed in text space), in text order.
|
||||
colours: Vec<(usize, Arc<Node>)>,
|
||||
}
|
||||
|
||||
impl TextGeom {
|
||||
@@ -2421,6 +2439,7 @@ impl TextGeom {
|
||||
+ 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)>()
|
||||
+ self.colours.iter().map(|(_, art)| lru::node_bytes(art)).sum::<usize>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2501,12 +2520,15 @@ 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![];
|
||||
let (mut images, mut colours) = (vec![], 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));
|
||||
}
|
||||
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
|
||||
colours.push((g.run, Arc::new(art)));
|
||||
}
|
||||
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);
|
||||
@@ -2546,6 +2568,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
alternate_glyphs: std::sync::OnceLock::new(),
|
||||
inlines: layout.inlines,
|
||||
images,
|
||||
colours,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2619,6 +2642,8 @@ mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_cmykblend;
|
||||
#[cfg(test)]
|
||||
mod tests_colrglyphs;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
#[cfg(test)]
|
||||
mod tests_dither;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! COLR colour glyphs: drawn as their layers (or, for paints documents have no form for, their
|
||||
//! image), which the type's fill doesn't paint, except in the layers in the foreground colour.
|
||||
|
||||
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, COLR_SWEEP_CHAR, colr_family, colr_font};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 100 pt `text` in the COLR test font (v1 or v0), filled magenta, its baseline at (20, 120)
|
||||
/// turned by `turn` about there, on a white 160×160 page.
|
||||
fn render(v1: bool, text: char, turn: f64, opts: RenderOptions) -> Rendered {
|
||||
let _adding = crate::tests_fontchange::adding_fonts();
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
|
||||
let mut d = Document::new(160.0, 160.0);
|
||||
let st = CharStyle { font_family: colr_family(v1).into(), size: 100.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 1.0)), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::new(20.0, 120.0), &text.to_string(), st);
|
||||
t.xf = Affine::rotate_about(turn, (20.0, 120.0)) * t.xf;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
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 })
|
||||
}
|
||||
|
||||
/// The box of the pixels `like` says yes to.
|
||||
fn ink(img: &Rendered, like: impl Fn([u8; 4]) -> bool) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut b: Option<(u32, u32, u32, u32)> = None;
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
if like(img.pixel(x, y)) {
|
||||
b = Some(b.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y))));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn magenta(p: [u8; 4]) -> bool {
|
||||
p[0] > 200 && p[2] > 200 && p[1] < 60
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_layers_draw_their_colours_not_the_fill() {
|
||||
let img = render(true, COLOUR_CHAR, 0.0, RenderOptions::default());
|
||||
// The `O` with its linear gradient red → blue from x 0 to 500 units (20 to 70 pt).
|
||||
let reddish = ink(&img, |p| p[0] > 180 && p[2] < 90 && p[1] < 60).expect("red at the gradient's start");
|
||||
let bluish = ink(&img, |p| p[2] > 180 && p[0] < 90 && p[1] < 60).expect("blue at its end");
|
||||
assert!(reddish.0 < bluish.0, "red left of blue: {reddish:?} {bluish:?}");
|
||||
assert!(ink(&img, magenta).is_none(), "the fill doesn't paint the glyph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_layer_takes_the_fill() {
|
||||
let img = render(false, COLOUR_CHAR, 0.0, RenderOptions::default());
|
||||
assert!(ink(&img, magenta).is_some(), "the `I` layer is in the foreground colour");
|
||||
assert!(ink(&img, |p| p == [255, 0, 0, 255]).is_some(), "the `O` layer in red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_type_turns_its_colour_layers() {
|
||||
let size = |turn| {
|
||||
let (x0, y0, x1, y1) = ink(&render(false, COLOUR_CHAR, turn, RenderOptions::default()), magenta).unwrap();
|
||||
(x1 - x0, y1 - y0)
|
||||
};
|
||||
let (w, h) = size(0.0);
|
||||
assert!(h > w * 3, "the `I` stands: {w}×{h}");
|
||||
let (w, h) = size(std::f64::consts::FRAC_PI_2);
|
||||
assert!(w > h * 3, "turned, it lies: {w}×{h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_mode_shows_the_layers_outlines() {
|
||||
let filled = render(false, COLOUR_CHAR, 0.0, RenderOptions::default());
|
||||
let outlined = render(false, COLOUR_CHAR, 0.0, RenderOptions { outline: true, ..Default::default() });
|
||||
let (x0, y0, x1, y1) = ink(&filled, |p| p == [255, 0, 0, 255]).unwrap();
|
||||
let dark = |p: [u8; 4]| p[0] < 128 && p[1] < 128 && p[2] < 128;
|
||||
assert!(ink(&outlined, |p| p == [255, 0, 0, 255]).is_none(), "no colours in outline mode");
|
||||
let (ox0, oy0, ox1, oy1) = ink(&outlined, dark).expect("the outlines show");
|
||||
assert!(ox0 <= x0 + 2 && oy0 <= y0 + 2 && ox1 + 2 >= x1 && oy1 + 2 >= y1, "around the layers: {:?} {:?}", (ox0, oy0, ox1, oy1), (x0, y0, x1, y1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_glyph_draws_its_image() {
|
||||
let img = render(true, COLR_SWEEP_CHAR, 0.0, RenderOptions::default());
|
||||
assert!(ink(&img, |p| p[0] > 180 && p[1] < 60 && p[2] < 120).is_some(), "red where the sweep starts");
|
||||
assert!(ink(&img, magenta).is_none());
|
||||
}
|
||||
@@ -1910,6 +1910,10 @@ impl Writer<'_> {
|
||||
xml_escape(&href)
|
||||
));
|
||||
}
|
||||
// COLR glyphs: their layers, as art.
|
||||
for art in lay.glyphs.iter().filter_map(|g| g.placed_colour(Affine::IDENTITY)) {
|
||||
self.node(&art);
|
||||
}
|
||||
self.xf = saved;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
@@ -2103,8 +2107,9 @@ 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() {
|
||||
// A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the
|
||||
// viewer.
|
||||
if g.image.is_some() || g.colour.is_some() {
|
||||
self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -42,3 +42,43 @@ fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() {
|
||||
assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings);
|
||||
assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text");
|
||||
}
|
||||
|
||||
/// `ch` in the COLR test font (v1 or v0), 24 pt, filled blue.
|
||||
fn colr_doc(v1: bool, ch: char) -> Document {
|
||||
use vectorcraft_text::test_fonts::{colr_family, colr_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(0, 0, 255));
|
||||
let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st))));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlined_text_writes_colr_glyphs_as_vector_art() {
|
||||
let svg = export_full(&colr_doc(true, COLOUR_CHAR), &ExportOptions { outline_text: true, ..Default::default() }, None).svg;
|
||||
assert!(!svg.contains("<image "), "{svg}");
|
||||
assert!(svg.contains("<linearGradient") && svg.contains("<radialGradient"), "{svg}");
|
||||
assert!(svg.contains("<clipPath"), "the clip box: {svg}");
|
||||
assert!(svg.contains("mix-blend-mode:multiply") || svg.contains("mix-blend-mode: multiply"), "{svg}");
|
||||
let v0 = export_full(&colr_doc(false, COLOUR_CHAR), &ExportOptions { outline_text: true, ..Default::default() }, None).svg;
|
||||
assert!(v0.contains("#f00") || v0.contains("#ff0000"), "the red layer: {v0}");
|
||||
assert!(v0.contains("#00f") || v0.contains("#0000ff"), "the foreground layer in the fill: {v0}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlined_text_writes_a_sweep_glyph_as_its_image() {
|
||||
use vectorcraft_text::test_fonts::COLR_SWEEP_CHAR;
|
||||
let svg = export_full(&colr_doc(true, COLR_SWEEP_CHAR), &ExportOptions { outline_text: true, ..Default::default() }, None).svg;
|
||||
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_leave_colr_glyphs_to_the_viewer() {
|
||||
let out = export_full(&colr_doc(false, COLOUR_CHAR), &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||||
assert!(!out.svg.contains("base64,"), "no subset without its colour layers: {}", out.svg);
|
||||
assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings);
|
||||
assert!(out.svg.contains(COLOUR_CHAR), "the character stays text");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ icu_segmenter = "2.3"
|
||||
# Grapheme clusters (a Thai consonant with its vowel and tone marks, an accented letter, an emoji
|
||||
# sequence) for caret movement; MIT OR Apache-2.0, already in the build.
|
||||
unicode-segmentation = "1.13"
|
||||
# Draws COLR colour glyphs whose paints documents have no form for (sweep gradients, compositing
|
||||
# modes) into images: the canvas's renderer, already in the build.
|
||||
vello_cpu = { workspace = true }
|
||||
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
|
||||
# Apache-2.0, already in the build through subsetter.
|
||||
write-fonts = { version = "0.48", optional = true }
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
//! COLR colour glyphs (v0 layers, v1 paint graphs): read into a tree of document nodes in font
|
||||
//! units (y down, as outlines are), which the renderer, the exporters and Create Outlines draw and
|
||||
//! keep as they do any art. A glyph whose paints VectorCraft has no form for is drawn into an image
|
||||
//! instead, as bitmap glyphs are ([`GlyphImage`]); [`Tree::unsupported`] names what made it one.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
use skrifa::color::{Brush, ColorGlyph, ColorGlyphCollection, ColorPainter, ColorPalettes, ColorStop, CompositeMode, Extend, Transform};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::DrawSettings;
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::types::BoundingBox;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, FillLayer, ImageBlob, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::GlyphImage;
|
||||
use crate::fontdb::FlipPen;
|
||||
|
||||
/// The most nodes a colour glyph's tree may have: past it, a font's glyph draws its outline.
|
||||
const MAX_COLR_NODES: usize = 4096;
|
||||
/// The deepest the groups of a colour glyph may nest.
|
||||
const MAX_COLR_DEPTH: usize = 64;
|
||||
/// The pixels per em an image of a colour glyph is drawn at.
|
||||
const FALLBACK_PPEM: f64 = 256.0;
|
||||
/// The largest side of such an image, in pixels.
|
||||
const MAX_FALLBACK_SIDE: f64 = 1024.0;
|
||||
/// The id of the layers' nodes: none, as the art exporters make on the fly has (Create Outlines
|
||||
/// gives them ids of the document's).
|
||||
const NO_ID: NodeId = NodeId(u64::MAX);
|
||||
/// Font units y up → y down.
|
||||
const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
|
||||
/// A colour glyph as VectorCraft draws it.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum ColourContent {
|
||||
/// Its layers as document nodes in font units (y down): a group.
|
||||
Art(Arc<Node>),
|
||||
/// Its picture, for paints VectorCraft has no form for.
|
||||
Image(Arc<GlyphImage>),
|
||||
}
|
||||
|
||||
/// Does `font` have COLR colour glyphs?
|
||||
pub(crate) fn has_colour_layers(font: &skrifa::FontRef<'_>) -> bool {
|
||||
font.colr().is_ok()
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s colour content in `font` at `location`, palette 0's colours and `foreground` for
|
||||
/// palette index 0xFFFF, and whether it used `foreground`; `None` when it isn't a colour glyph, or
|
||||
/// its graph doesn't read (a cycle, too deep, too many layers).
|
||||
pub(crate) fn colour_glyph(
|
||||
font: &skrifa::FontRef<'_>,
|
||||
location: LocationRef<'_>,
|
||||
upem: f64,
|
||||
gid: u32,
|
||||
foreground: Color,
|
||||
) -> (Option<ColourContent>, bool) {
|
||||
let Some(glyph) = ColorGlyphCollection::new(font).get(GlyphId::new(gid)) else { return (None, false) };
|
||||
let mut tree = Tree::new(font, location, foreground);
|
||||
if glyph.paint(location, &mut tree).is_err() {
|
||||
return (None, false);
|
||||
}
|
||||
let used = tree.foreground_used;
|
||||
if tree.unsupported.is_some() {
|
||||
// What it paints, y up, for a glyph without a clip box.
|
||||
let painted = tree.painted.map(|b| FLIP.transform_rect_bbox(b));
|
||||
return (raster(&glyph, font, location, upem, foreground, painted).map(|i| ColourContent::Image(Arc::new(i))), used);
|
||||
}
|
||||
(tree.finish().map(ColourContent::Art), used)
|
||||
}
|
||||
|
||||
/// Palette 0's colours with their alpha.
|
||||
fn palette(font: &skrifa::FontRef<'_>) -> Vec<(Color, f32)> {
|
||||
let palettes = ColorPalettes::new(font);
|
||||
let Some(palette) = palettes.get(0) else { return vec![] };
|
||||
palette.colors().iter().map(|c| (Color::rgb8(c.red, c.green, c.blue), f32::from(c.alpha) / 255.0)).collect()
|
||||
}
|
||||
|
||||
/// The colour and alpha of palette entry `index` (0xFFFF: `foreground`), times `alpha`; `None`
|
||||
/// for an entry the palette doesn't have.
|
||||
fn colour(palette: &[(Color, f32)], foreground: Color, index: u16, alpha: f32) -> Option<(Color, f32)> {
|
||||
let (c, a) = if index == 0xFFFF { (foreground, 1.0) } else { *palette.get(usize::from(index))? };
|
||||
Some((c, (a * alpha).clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
fn affine(t: &Transform) -> Affine {
|
||||
Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(f64::from))
|
||||
}
|
||||
|
||||
fn point(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(f64::from(p.x), f64::from(p.y))
|
||||
}
|
||||
|
||||
fn box_path(b: &BoundingBox<f32>) -> BezPath {
|
||||
Rect::new(f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max)).to_path(0.1)
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s outline in font units, y up (the font database's pen draws y down, as text
|
||||
/// space has it).
|
||||
fn outline(font: &skrifa::FontRef<'_>, location: LocationRef<'_>, gid: GlyphId) -> BezPath {
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(g) = font.outline_glyphs().get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen);
|
||||
}
|
||||
let mut path = pen.0;
|
||||
path.apply_affine(FLIP);
|
||||
path
|
||||
}
|
||||
|
||||
/// A group being built: its clipping path, or the blend mode of a composite's layer.
|
||||
struct Open {
|
||||
clip: Option<BezPath>,
|
||||
blend: Option<BlendMode>,
|
||||
children: Vec<Arc<Node>>,
|
||||
/// A fill filled the clip's whole area: alone, it is the clip filled.
|
||||
flooded: bool,
|
||||
}
|
||||
|
||||
/// A [`ColorPainter`] building a colour glyph's tree.
|
||||
struct Tree<'a> {
|
||||
font: &'a skrifa::FontRef<'a>,
|
||||
location: LocationRef<'a>,
|
||||
palette: Vec<(Color, f32)>,
|
||||
foreground: Color,
|
||||
/// Font units y up → the tree's space (y down), with the paint graph's transforms.
|
||||
transforms: Vec<Affine>,
|
||||
groups: Vec<Open>,
|
||||
nodes: usize,
|
||||
/// What VectorCraft has no form for, the first one met: the glyph becomes an image.
|
||||
unsupported: Option<&'static str>,
|
||||
/// The graph is past [`MAX_COLR_NODES`] or [`MAX_COLR_DEPTH`].
|
||||
too_large: bool,
|
||||
/// The box of what it paints, in the tree's space.
|
||||
painted: Option<Rect>,
|
||||
/// It paints with the foreground colour (palette index 0xFFFF).
|
||||
foreground_used: bool,
|
||||
}
|
||||
|
||||
impl<'a> Tree<'a> {
|
||||
fn new(font: &'a skrifa::FontRef<'a>, location: LocationRef<'a>, foreground: Color) -> Self {
|
||||
let root = Open { clip: None, blend: Some(BlendMode::Normal), children: vec![], flooded: false };
|
||||
Tree {
|
||||
font,
|
||||
location,
|
||||
palette: palette(font),
|
||||
foreground,
|
||||
transforms: vec![FLIP],
|
||||
groups: vec![root],
|
||||
nodes: 0,
|
||||
unsupported: None,
|
||||
too_large: false,
|
||||
painted: None,
|
||||
foreground_used: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn transform(&self) -> Affine {
|
||||
self.transforms.last().copied().unwrap_or(FLIP)
|
||||
}
|
||||
|
||||
fn add(&mut self, node: Node) {
|
||||
self.nodes += 1;
|
||||
self.too_large |= self.nodes > MAX_COLR_NODES;
|
||||
if let Some(g) = self.groups.last_mut().filter(|_| !self.too_large) {
|
||||
g.children.push(Arc::new(node));
|
||||
}
|
||||
}
|
||||
|
||||
fn open(&mut self, group: Open) {
|
||||
self.too_large |= self.groups.len() >= MAX_COLR_DEPTH;
|
||||
self.groups.push(group);
|
||||
}
|
||||
|
||||
/// Closes the innermost group into its parent: a clip with only the fill it clips is that
|
||||
/// filled path; an empty group goes.
|
||||
fn close(&mut self) {
|
||||
// The root stays open until `finish`.
|
||||
if self.groups.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(g) = self.groups.pop() else { return };
|
||||
if g.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut children = g.children;
|
||||
if let Some(clip) = g.clip {
|
||||
if let ([only], true) = (children.as_slice(), g.flooded) {
|
||||
let only = only.clone();
|
||||
return self.add_arc(only);
|
||||
}
|
||||
let mut path = Node::path(NO_ID, PathData::from_bezpath(&clip), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
children.insert(0, Arc::new(path));
|
||||
let mut group = Node::group(NO_ID, children);
|
||||
if let NodeKind::Group { clip, .. } = &mut group.kind {
|
||||
*clip = true;
|
||||
}
|
||||
return self.add(group);
|
||||
}
|
||||
let mut group = Node::group(NO_ID, children);
|
||||
group.isolate = true;
|
||||
group.blend = g.blend.unwrap_or(BlendMode::Normal);
|
||||
self.add(group);
|
||||
}
|
||||
|
||||
fn add_arc(&mut self, node: Arc<Node>) {
|
||||
if let Some(g) = self.groups.last_mut() {
|
||||
g.children.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree: the glyph as a group. It needs no isolation of its own: blend modes only come
|
||||
/// with composites, whose groups are isolated, so they never reach the art behind the type.
|
||||
fn finish(mut self) -> Option<Arc<Node>> {
|
||||
if self.too_large {
|
||||
return None;
|
||||
}
|
||||
while self.groups.len() > 1 {
|
||||
self.close();
|
||||
}
|
||||
let root = self.groups.pop()?;
|
||||
if root.children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Arc::new(Node::group(NO_ID, root.children)))
|
||||
}
|
||||
|
||||
/// `brush` as a VectorCraft paint and opacity, in the tree's space; `None` with
|
||||
/// [`Self::unsupported`] set when it has no form there.
|
||||
fn paint(&mut self, brush: &Brush<'_>) -> Option<(Paint, f32)> {
|
||||
self.foreground_used |= match brush {
|
||||
Brush::Solid { palette_index, .. } => *palette_index == 0xFFFF,
|
||||
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
|
||||
color_stops.iter().any(|s| s.palette_index == 0xFFFF)
|
||||
}
|
||||
};
|
||||
let xf = self.transform();
|
||||
let stops = |stops: &[ColorStop], tree: &Self| -> Vec<GradientStop> {
|
||||
stops
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let (color, opacity) = colour(&tree.palette, tree.foreground, s.palette_index, s.alpha)?;
|
||||
Some(GradientStop { opacity, ..GradientStop::new(s.offset, color) })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let gradient = |kind, stops: Vec<GradientStop>, geom| {
|
||||
let mut paint = GradientPaint::new(Gradient { kind, stops, ..Gradient::default() });
|
||||
paint.geom = Some(geom);
|
||||
Paint::Gradient(Box::new(paint))
|
||||
};
|
||||
match brush {
|
||||
Brush::Solid { palette_index, alpha } => {
|
||||
let (c, a) = colour(&self.palette, self.foreground, *palette_index, *alpha)?;
|
||||
Some((Paint::solid(c), a))
|
||||
}
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend } => {
|
||||
if *extend != Extend::Pad {
|
||||
self.unsupported.get_or_insert("a repeating or reflecting gradient");
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = (point(*p0), point(*p1));
|
||||
let (stops, first, last) = normalized(stops(color_stops, self))?;
|
||||
// Stops past 0 and 1 move the ends instead.
|
||||
let mut geom = GradientGeom { start: p0.lerp(p1, f64::from(first)), end: p0.lerp(p1, f64::from(last)), aspect: 1.0, focal: None };
|
||||
geom.transform(xf, GradientKind::Linear);
|
||||
Some((gradient(GradientKind::Linear, stops, geom), 1.0))
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => {
|
||||
if *extend != Extend::Pad {
|
||||
self.unsupported.get_or_insert("a repeating or reflecting gradient");
|
||||
return None;
|
||||
}
|
||||
let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1));
|
||||
let concentric = (c0 - c1).hypot() < 1e-6;
|
||||
if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 {
|
||||
self.unsupported.get_or_insert("a radial gradient from a circle");
|
||||
return None;
|
||||
}
|
||||
let mut stops = stops(color_stops, self);
|
||||
if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) {
|
||||
self.unsupported.get_or_insert("a radial gradient with stops past its circles");
|
||||
return None;
|
||||
}
|
||||
// A start circle around the centre starts the stops further out.
|
||||
for s in &mut stops {
|
||||
s.offset = ((r0 + f64::from(s.offset) * (r1 - r0)) / r1) as f32;
|
||||
}
|
||||
let mut geom = GradientGeom { start: c1, end: c1 + Vec2::new(r1, 0.0), aspect: 1.0, focal: (!concentric).then_some(c0) };
|
||||
geom.transform(xf, GradientKind::Radial);
|
||||
Some((gradient(GradientKind::Radial, stops, geom), 1.0))
|
||||
}
|
||||
Brush::SweepGradient { .. } => {
|
||||
self.unsupported.get_or_insert("a sweep gradient");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (in the tree's space) filled with `brush` (in the current transform's).
|
||||
fn fill_path(&mut self, path: &BezPath, brush: &Brush<'_>) {
|
||||
let b = path.bounding_box();
|
||||
self.painted = Some(self.painted.map_or(b, |p| p.union(b)));
|
||||
let Some((paint, opacity)) = self.paint(brush) else { return };
|
||||
let fill = vectorcraft_doc::AppearanceItem::Fill(FillLayer { opacity, ..FillLayer::new(paint) });
|
||||
self.add(Node::path(NO_ID, PathData::from_bezpath(path), Appearance { items: vec![fill], ..Appearance::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// `stops` sorted, from 0 to 1 (as the gradient's ends then move), and where the first and last
|
||||
/// were; `None` without stops.
|
||||
fn normalized(mut stops: Vec<GradientStop>) -> Option<(Vec<GradientStop>, f32, f32)> {
|
||||
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
let (first, last) = (stops.first()?.offset, stops.last()?.offset);
|
||||
let span = last - first;
|
||||
for s in &mut stops {
|
||||
s.offset = if span > 0.0 { (s.offset - first) / span } else { 0.0 };
|
||||
}
|
||||
Some((stops, first, if span > 0.0 { last } else { first + 1.0 }))
|
||||
}
|
||||
|
||||
impl ColorPainter for Tree<'_> {
|
||||
fn push_transform(&mut self, transform: Transform) {
|
||||
let t = self.transform() * affine(&transform);
|
||||
self.transforms.push(t);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.transforms.len() > 1 {
|
||||
self.transforms.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, glyph_id: GlyphId) {
|
||||
let mut clip = outline(self.font, self.location, glyph_id);
|
||||
clip.apply_affine(self.transform());
|
||||
self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false });
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, clip_box: BoundingBox<f32>) {
|
||||
let mut clip = box_path(&clip_box);
|
||||
clip.apply_affine(self.transform());
|
||||
self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false });
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
|
||||
/// A fill fills the innermost clip's shape (a layer between them stays around it).
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let Some(at) = self.groups.iter().rposition(|g| g.clip.is_some()) else {
|
||||
self.unsupported.get_or_insert("a fill outside any glyph");
|
||||
return;
|
||||
};
|
||||
let Some(clip) = self.groups.get(at).and_then(|g| g.clip.clone()) else { return };
|
||||
if at + 1 == self.groups.len()
|
||||
&& let Some(g) = self.groups.last_mut()
|
||||
{
|
||||
g.flooded = g.children.is_empty();
|
||||
}
|
||||
self.fill_path(&clip, &brush);
|
||||
}
|
||||
|
||||
/// The glyph's outline filled, the brush moved by `brush_transform` (the glyph stays).
|
||||
fn fill_glyph(&mut self, glyph_id: GlyphId, brush_transform: Option<Transform>, brush: Brush<'_>) {
|
||||
let mut path = outline(self.font, self.location, glyph_id);
|
||||
path.apply_affine(self.transform());
|
||||
let brush_xf = self.transform() * brush_transform.as_ref().map_or(Affine::IDENTITY, affine);
|
||||
self.transforms.push(brush_xf);
|
||||
self.fill_path(&path, &brush);
|
||||
self.transforms.pop();
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
let blend = blend_mode(mode);
|
||||
if blend.is_none() {
|
||||
self.unsupported.get_or_insert("a compositing mode other than blending");
|
||||
}
|
||||
self.open(Open { clip: None, blend, children: vec![], flooded: false });
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// COLRv1's blend composite modes as VectorCraft's (`SrcOver` is Normal); `None` for the
|
||||
/// Porter-Duff modes, which it has no form for.
|
||||
fn blend_mode(mode: CompositeMode) -> Option<BlendMode> {
|
||||
Some(match mode {
|
||||
CompositeMode::SrcOver => BlendMode::Normal,
|
||||
CompositeMode::Screen => BlendMode::Screen,
|
||||
CompositeMode::Overlay => BlendMode::Overlay,
|
||||
CompositeMode::Darken => BlendMode::Darken,
|
||||
CompositeMode::Lighten => BlendMode::Lighten,
|
||||
CompositeMode::ColorDodge => BlendMode::ColorDodge,
|
||||
CompositeMode::ColorBurn => BlendMode::ColorBurn,
|
||||
CompositeMode::HardLight => BlendMode::HardLight,
|
||||
CompositeMode::SoftLight => BlendMode::SoftLight,
|
||||
CompositeMode::Difference => BlendMode::Difference,
|
||||
CompositeMode::Exclusion => BlendMode::Exclusion,
|
||||
CompositeMode::Multiply => BlendMode::Multiply,
|
||||
CompositeMode::HslHue => BlendMode::Hue,
|
||||
CompositeMode::HslSaturation => BlendMode::Saturation,
|
||||
CompositeMode::HslColor => BlendMode::Color,
|
||||
CompositeMode::HslLuminosity => BlendMode::Luminosity,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Colour glyph `glyph` drawn into an image at [`FALLBACK_PPEM`], placed in font units (y down)
|
||||
/// as bitmap glyphs are, in its clip box or else the box of what it `painted` (y up); `None` when
|
||||
/// it paints nothing or doesn't draw.
|
||||
fn raster(
|
||||
glyph: &ColorGlyph<'_>,
|
||||
font: &skrifa::FontRef<'_>,
|
||||
location: LocationRef<'_>,
|
||||
upem: f64,
|
||||
foreground: Color,
|
||||
painted: Option<Rect>,
|
||||
) -> Option<GlyphImage> {
|
||||
let b = glyph.bounding_box(location, Size::unscaled()).map(|b| Rect::new(b.x_min.into(), b.y_min.into(), b.x_max.into(), b.y_max.into()));
|
||||
// Without a clip box, the box of what it paints (`painted`, y up).
|
||||
let b = b.or(painted).filter(|b| b.area() > 0.0)?;
|
||||
let s = (FALLBACK_PPEM / upem.max(1.0)).min(MAX_FALLBACK_SIDE / b.width().max(b.height()));
|
||||
if !(s.is_finite() && s > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((b.width() * s).ceil() as u16, (b.height() * s).ceil() as u16);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// Font units y up → pixels.
|
||||
let to_pixels = Affine::new([s, 0.0, 0.0, -s, -b.x0 * s, b.y1 * s]);
|
||||
let mut ctx = vello_cpu::RenderContext::new(w, h);
|
||||
let mut painter = Pixels { ctx: &mut ctx, font, location, palette: palette(font), foreground, transforms: vec![to_pixels], width: w, height: h };
|
||||
glyph.paint(location, &mut painter).ok()?;
|
||||
ctx.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut vello_cpu::Resources::new());
|
||||
let png = pm.into_png().ok()?;
|
||||
let xf = Affine::new([1.0 / s, 0.0, 0.0, 1.0 / s, b.x0, -b.y1]);
|
||||
Some(GlyphImage { blob: ImageBlob::png(png), width: u32::from(w), height: u32::from(h), xf })
|
||||
}
|
||||
|
||||
/// A [`ColorPainter`] drawing a colour glyph with vello_cpu.
|
||||
struct Pixels<'a, 'c> {
|
||||
ctx: &'c mut vello_cpu::RenderContext,
|
||||
font: &'a skrifa::FontRef<'a>,
|
||||
location: LocationRef<'a>,
|
||||
palette: Vec<(Color, f32)>,
|
||||
foreground: Color,
|
||||
transforms: Vec<Affine>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl Pixels<'_, '_> {
|
||||
fn transform(&self) -> Affine {
|
||||
self.transforms.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn peniko_colour(&self, index: u16, alpha: f32) -> peniko::Color {
|
||||
let (c, a) = colour(&self.palette, self.foreground, index, alpha).unwrap_or((Color::BLACK, 0.0));
|
||||
let [r, g, b, a] = c.to_rgba8(a);
|
||||
peniko::Color::from_rgba8(r, g, b, a)
|
||||
}
|
||||
|
||||
fn clip(&mut self, path: &BezPath) {
|
||||
self.ctx.set_transform(self.transform());
|
||||
self.ctx.push_clip_layer(path);
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPainter for Pixels<'_, '_> {
|
||||
fn push_transform(&mut self, transform: Transform) {
|
||||
let t = self.transform() * affine(&transform);
|
||||
self.transforms.push(t);
|
||||
}
|
||||
|
||||
fn pop_transform(&mut self) {
|
||||
if self.transforms.len() > 1 {
|
||||
self.transforms.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_glyph(&mut self, glyph_id: GlyphId) {
|
||||
let path = outline(self.font, self.location, glyph_id);
|
||||
self.clip(&path);
|
||||
}
|
||||
|
||||
fn push_clip_box(&mut self, clip_box: BoundingBox<f32>) {
|
||||
self.clip(&box_path(&clip_box));
|
||||
}
|
||||
|
||||
fn pop_clip(&mut self) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let stops = |stops: &[ColorStop]| -> Vec<peniko::ColorStop> {
|
||||
stops.iter().map(|s| peniko::ColorStop::from((s.offset, self.peniko_colour(s.palette_index, s.alpha)))).collect()
|
||||
};
|
||||
let extend = |e: Extend| match e {
|
||||
Extend::Repeat => peniko::Extend::Repeat,
|
||||
Extend::Reflect => peniko::Extend::Reflect,
|
||||
_ => peniko::Extend::Pad,
|
||||
};
|
||||
let paint: peniko::Brush = match brush {
|
||||
Brush::Solid { palette_index, alpha } => peniko::Brush::Solid(self.peniko_colour(palette_index, alpha)),
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
|
||||
peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into()
|
||||
}
|
||||
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1)
|
||||
.with_stops(stops(color_stops).as_slice())
|
||||
.with_extend(extend(e))
|
||||
.into(),
|
||||
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
|
||||
peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians())
|
||||
.with_stops(stops(color_stops).as_slice())
|
||||
.with_extend(extend(e))
|
||||
.into()
|
||||
}
|
||||
};
|
||||
// Everything the image shows, in the paint's space.
|
||||
let xf = self.transform();
|
||||
let all = xf.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height)));
|
||||
self.ctx.set_transform(xf);
|
||||
match paint {
|
||||
peniko::Brush::Solid(c) => self.ctx.set_paint(c),
|
||||
peniko::Brush::Gradient(g) => self.ctx.set_paint(g),
|
||||
peniko::Brush::Image(_) => return,
|
||||
}
|
||||
self.ctx.fill_rect(&all);
|
||||
}
|
||||
|
||||
fn push_layer(&mut self, mode: CompositeMode) {
|
||||
use peniko::{BlendMode as Mode, Compose, Mix};
|
||||
let (mix, compose) = match mode {
|
||||
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
|
||||
CompositeMode::Src => (Mix::Normal, Compose::Copy),
|
||||
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
|
||||
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
|
||||
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
|
||||
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
|
||||
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
|
||||
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
|
||||
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
|
||||
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
|
||||
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
|
||||
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
|
||||
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
|
||||
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
|
||||
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
|
||||
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
|
||||
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
|
||||
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
|
||||
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
|
||||
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
|
||||
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
|
||||
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
|
||||
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
|
||||
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
|
||||
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
|
||||
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
|
||||
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
|
||||
_ => (Mix::Normal, Compose::SrcOver),
|
||||
};
|
||||
self.ctx.push_layer(None, Some(Mode::new(mix, compose)), None, None, None);
|
||||
}
|
||||
|
||||
fn pop_layer(&mut self) {
|
||||
self.ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
+84
-19
@@ -14,6 +14,8 @@ use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider, Tag};
|
||||
|
||||
use crate::GlyphImage;
|
||||
use crate::colr::ColourContent;
|
||||
use vectorcraft_doc::color::Color;
|
||||
|
||||
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
|
||||
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
||||
@@ -136,6 +138,8 @@ pub struct FontFace {
|
||||
icf_margins: std::sync::OnceLock<IcfMargins>,
|
||||
/// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]).
|
||||
colour_bitmaps: bool,
|
||||
/// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]).
|
||||
colour_layers: bool,
|
||||
}
|
||||
|
||||
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
|
||||
@@ -414,28 +418,65 @@ 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>>>,
|
||||
/// [`FontDb::glyph_image`]'s answers by face and glyph.
|
||||
type GlyphImages = GlyphCache<(u32, u32), Arc<GlyphImage>>;
|
||||
/// [`FontDb::glyph_colour`]'s answers by face and glyph, and the foreground colour for glyphs that
|
||||
/// paint with it.
|
||||
type GlyphColours = GlyphCache<(u32, u32, Option<[u8; 4]>), ColourContent>;
|
||||
|
||||
/// What a glyph's colour content (an image, layers) takes of memory, in bytes.
|
||||
trait Weight {
|
||||
fn bytes(&self) -> usize;
|
||||
}
|
||||
|
||||
impl Weight for Arc<GlyphImage> {
|
||||
fn bytes(&self) -> usize {
|
||||
self.blob.bytes.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Weight for ColourContent {
|
||||
fn bytes(&self) -> usize {
|
||||
match self {
|
||||
ColourContent::Image(i) => i.bytes(),
|
||||
// Roughly what a path node with its outline and fill takes.
|
||||
ColourContent::Art(art) => {
|
||||
let mut nodes = 0;
|
||||
art.walk(&mut |_| nodes += 1);
|
||||
nodes * 512
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour content of glyphs ([`FontDb::glyph_image`], [`FontDb::glyph_colour`]) kept by
|
||||
/// face, glyph and what else it depends on, and its bytes in all.
|
||||
struct GlyphCache<K, V> {
|
||||
kept: HashMap<K, Option<V>>,
|
||||
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
|
||||
impl<K, V> Default for GlyphCache<K, V> {
|
||||
fn default() -> Self {
|
||||
Self { kept: HashMap::new(), bytes: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: std::hash::Hash + Eq, V: Weight> GlyphCache<K, V> {
|
||||
/// Keeps `value` (or that there is none), first letting go of all it keeps when that would
|
||||
/// take more than [`IMAGE_CACHE_MAX`] glyphs 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) {
|
||||
fn keep(&mut self, key: K, value: Option<V>) {
|
||||
if self.kept.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();
|
||||
let bytes = value.as_ref().map_or(0, Weight::bytes);
|
||||
if self.kept.len() >= IMAGE_CACHE_MAX || self.bytes.saturating_add(bytes) > IMAGE_CACHE_BYTES {
|
||||
self.kept.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
self.bytes += bytes;
|
||||
self.images.insert(key, image);
|
||||
self.kept.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +486,7 @@ pub struct FontDb {
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
/// [`FontDb::glyph_image`]'s answers per (face, glyph), misses too.
|
||||
images: Mutex<GlyphImages>,
|
||||
colours: Mutex<GlyphColours>,
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
@@ -1554,6 +1596,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);
|
||||
let colour_layers = crate::colr::has_colour_layers(&f);
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
@@ -1581,6 +1624,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
ideographic_centre: std::sync::OnceLock::new(),
|
||||
icf_margins: std::sync::OnceLock::new(),
|
||||
colour_bitmaps,
|
||||
colour_layers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1684,7 +1728,8 @@ impl FontDb {
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
images: Mutex::new(GlyphImages::default()),
|
||||
images: Mutex::new(GlyphCache::default()),
|
||||
colours: Mutex::new(GlyphCache::default()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_paths,
|
||||
@@ -2460,16 +2505,36 @@ impl FontDb {
|
||||
return None;
|
||||
}
|
||||
let key = (face.id, gid);
|
||||
if let Some(i) = self.images.lock().unwrap_or_else(|e| e.into_inner()).images.get(&key) {
|
||||
if let Some(i) = self.images.lock().unwrap_or_else(|e| e.into_inner()).kept.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
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s COLR colour content in `face`, its foreground layers in `foreground`, cached
|
||||
/// per (face, glyph), and per foreground colour for the glyphs that paint with it.
|
||||
pub(crate) fn glyph_colour(&self, face: &FontFace, gid: u32, foreground: Color) -> Option<ColourContent> {
|
||||
if !face.colour_layers {
|
||||
return None;
|
||||
}
|
||||
let keyed = Some(foreground.to_rgba8(1.0));
|
||||
{
|
||||
let cache = self.colours.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(c) = cache.kept.get(&(face.id, gid, None)).or_else(|| cache.kept.get(&(face.id, gid, keyed))) {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
let (content, used) = face.skrifa().map_or((None, false), |f| crate::colr::colour_glyph(&f, face.location(), face.upem, gid, foreground));
|
||||
let key = (face.id, gid, if used { keyed } else { None });
|
||||
self.colours.lock().unwrap_or_else(|e| e.into_inner()).keep(key, content.clone());
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
/// A pen drawing outlines y down, as text space has them.
|
||||
pub(crate) struct FlipPen(pub(crate) BezPath);
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
@@ -2641,16 +2706,16 @@ mod image_cache_tests {
|
||||
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();
|
||||
let mut cache = GlyphCache::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>());
|
||||
assert_eq!(cache.bytes, cache.kept.values().flatten().map(|i: &Arc<GlyphImage>| 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)));
|
||||
assert!(cache.kept.contains_key(&(1, 99)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,24 @@ impl Ctx<'_> {
|
||||
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
|
||||
// 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 colour glyph draws its layers (COLR) or its image (a bitmap, or COLR paints documents
|
||||
// have no form for), not its outline; COLR first. Layers in the foreground colour take
|
||||
// the run's fill, or black when it isn't a colour.
|
||||
let plain = g.inline.is_some() || blank;
|
||||
let foreground = match &self.style_at(g.byte).fill {
|
||||
vectorcraft_doc::color::Paint::Solid { color, .. } => *color,
|
||||
_ => vectorcraft_doc::color::Color::BLACK,
|
||||
};
|
||||
let (colour, image) = match (!plain).then(|| self.db.glyph_colour(&g.face, g.gid, foreground)).flatten() {
|
||||
Some(crate::colr::ColourContent::Art(art)) => (Some(art), None),
|
||||
Some(crate::colr::ColourContent::Image(image)) => (None, Some(image)),
|
||||
None => (None, (!plain).then(|| self.db.glyph_image(&g.face, g.gid)).flatten()),
|
||||
};
|
||||
let src = if g.inline.is_some() || image.is_some() || colour.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);
|
||||
@@ -173,6 +188,7 @@ impl Ctx<'_> {
|
||||
rtl: g.level.is_rtl() && !self.vertical,
|
||||
scale: 1.0,
|
||||
image,
|
||||
colour,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -730,6 +746,9 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
if let Some((image, xf)) = g.placed_image() {
|
||||
add(xf.transform_rect_bbox(image.rect()));
|
||||
}
|
||||
if let Some(b) = g.colour.as_ref().and_then(|c| c.geometric_bounds()) {
|
||||
add(g.xf.transform_rect_bbox(b));
|
||||
}
|
||||
}
|
||||
// Ruby: covered by its line's ascent, but its ink can overhang sideways past the line.
|
||||
for g in &out.ruby {
|
||||
@@ -2944,5 +2963,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
|
||||
scale: 1.0,
|
||||
// Ruby draws outlines only.
|
||||
image: None,
|
||||
colour: None,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -11,6 +11,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bitmap;
|
||||
mod colr;
|
||||
mod composer;
|
||||
pub mod craft_fonts;
|
||||
pub mod edit;
|
||||
@@ -123,8 +124,11 @@ pub struct PositionedGlyph {
|
||||
/// included.
|
||||
pub scale: f64,
|
||||
/// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is
|
||||
/// empty.
|
||||
/// empty. Also a COLR glyph whose paints documents have no form for, drawn as an image.
|
||||
pub image: Option<Arc<GlyphImage>>,
|
||||
/// A COLR colour glyph's layers, drawn through `xf` instead of an outline: art in font units
|
||||
/// (y down), an isolated group. Its `outline` is empty.
|
||||
pub colour: Option<Arc<vectorcraft_doc::Node>>,
|
||||
}
|
||||
|
||||
impl PositionedGlyph {
|
||||
@@ -132,6 +136,13 @@ impl PositionedGlyph {
|
||||
pub fn placed_image(&self) -> Option<(&Arc<GlyphImage>, Affine)> {
|
||||
self.image.as_ref().map(|i| (i, self.xf * i.xf))
|
||||
}
|
||||
/// The glyph's COLR layers placed through `to_doc` (text space → where they go): a copy of
|
||||
/// [`Self::colour`], transformed, its nodes without ids.
|
||||
pub fn placed_colour(&self, to_doc: Affine) -> Option<vectorcraft_doc::Node> {
|
||||
let mut art = (**self.colour.as_ref()?).clone();
|
||||
art.transform(to_doc * self.xf, true);
|
||||
Some(art)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of laid-out text.
|
||||
@@ -612,6 +623,8 @@ mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_bitmaps;
|
||||
#[cfg(test)]
|
||||
mod tests_colr;
|
||||
#[cfg(test)]
|
||||
mod tests_combos;
|
||||
#[cfg(test)]
|
||||
mod tests_devanagari;
|
||||
|
||||
@@ -585,3 +585,94 @@ pub fn solid_png(side: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The palette of [`colr_font`] (palette 0, RGBA): its indices 0, 1 and 2.
|
||||
pub const COLR_RED: [u8; 4] = [255, 0, 0, 255];
|
||||
pub const COLR_BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
pub const COLR_GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
|
||||
/// The character whose glyph [`colr_font`]`(true)` paints with a sweep gradient.
|
||||
pub const COLR_SWEEP_CHAR: char = 'S';
|
||||
|
||||
/// The family of [`colr_font`] (COLRv1, or v0).
|
||||
pub fn colr_family(v1: bool) -> &'static str {
|
||||
if v1 { "Colrtest Paints" } else { "Colrtest Layers" }
|
||||
}
|
||||
|
||||
/// The glyph ids of `chars` in the bundled Source Sans 3.
|
||||
fn source_sans_gids<const N: usize>(chars: [char; N]) -> Option<[u16; N]> {
|
||||
let font = skrifa::FontRef::new(SOURCE_SANS).ok()?;
|
||||
let mut gids = [0; N];
|
||||
for (gid, ch) in gids.iter_mut().zip(chars) {
|
||||
*gid = u16::try_from(font.charmap().map(ch)?.to_u32()).ok()?;
|
||||
}
|
||||
Some(gids)
|
||||
}
|
||||
|
||||
const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// The bundled Source Sans 3 Regular as family [`colr_family`], with `COLR` and `CPAL` tables
|
||||
/// written from the OpenType specification, palette 0 [`COLR_RED`], [`COLR_BLUE`], [`COLR_GREEN`].
|
||||
/// v0: [`COLOUR_CHAR`] is the outline of `O` in red, then that of `I` in the foreground colour
|
||||
/// (palette index 0xFFFF). v1: [`COLOUR_CHAR`] is, in a clip box, `O` filled with a linear
|
||||
/// gradient red → blue, `I` scaled by a half about the origin with a radial gradient blue → green,
|
||||
/// and `-` in green composited with Multiply over `-` in red; [`COLR_SWEEP_CHAR`] is `O` with a
|
||||
/// sweep gradient red → blue. Their outlines stay. `None` when it can't be built.
|
||||
pub fn colr_font(v1: bool) -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::colr::{
|
||||
BaseGlyph, BaseGlyphList, BaseGlyphPaint, Clip, ClipBox, ClipList, ColorLine, ColorStop, Colr, CompositeMode, Extend, Layer, LayerList, Paint,
|
||||
};
|
||||
use write_fonts::types::{FWord, GlyphId16, UfWord};
|
||||
let [base, sweep, o, i, dash] = source_sans_gids([COLOUR_CHAR, COLR_SWEEP_CHAR, 'O', 'I', '-'])?;
|
||||
let (base, sweep, o, i, dash) = (GlyphId16::new(base), GlyphId16::new(sweep), GlyphId16::new(o), GlyphId16::new(i), GlyphId16::new(dash));
|
||||
let colr = if v1 {
|
||||
let one = F2Dot14::from_f32(1.0);
|
||||
let line = |from: u16, to: u16| {
|
||||
ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), from, one), ColorStop::new(one, to, one)])
|
||||
};
|
||||
let w = FWord::new;
|
||||
let layers = vec![
|
||||
Paint::glyph(Paint::linear_gradient(line(0, 1), w(0), w(0), w(500), w(0), w(0), w(500)), o),
|
||||
Paint::scale_uniform(
|
||||
Paint::glyph(Paint::radial_gradient(line(1, 2), w(250), w(300), UfWord::new(0), w(250), w(300), UfWord::new(300)), i),
|
||||
F2Dot14::from_f32(0.5),
|
||||
),
|
||||
Paint::composite(Paint::glyph(Paint::solid(2, one), dash), CompositeMode::Multiply, Paint::glyph(Paint::solid(0, one), dash)),
|
||||
];
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
colr.layer_list = Some(LayerList::new(layers.len() as u32, layers)).into();
|
||||
let mut glyphs = vec![
|
||||
BaseGlyphPaint::new(base, Paint::colr_layers(3, 0)),
|
||||
BaseGlyphPaint::new(sweep, Paint::glyph(Paint::sweep_gradient(line(0, 1), w(250), w(300), F2Dot14::from_f32(0.0), one), o)),
|
||||
];
|
||||
glyphs.sort_by_key(|g| g.glyph_id);
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(glyphs.len() as u32, glyphs)).into();
|
||||
let clip = ClipBox::format_1(w(-50), w(-250), w(750), w(800));
|
||||
colr.clip_list = Some(ClipList::new(1, 1, vec![Clip::new(base, base, clip)])).into();
|
||||
colr
|
||||
} else {
|
||||
Colr::new(1, Some(vec![BaseGlyph::new(base, 0, 2)]), Some(vec![Layer::new(o, 0), Layer::new(i, 0xFFFF)]), 2)
|
||||
};
|
||||
colr_font_with(colr_family(v1), &colr)
|
||||
}
|
||||
|
||||
/// The bundled Source Sans 3 Regular as `family` with `colr` and [`colr_font`]'s palette.
|
||||
pub fn colr_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::cpal::{ColorRecord, Cpal};
|
||||
let font = skrifa::FontRef::new(SOURCE_SANS).ok()?;
|
||||
let ps = family.replace(' ', "");
|
||||
let name = name_table(&[(1, family), (2, "Regular"), (4, &format!("{family} Regular")), (6, &format!("{ps}-Regular"))])?;
|
||||
let records = [COLR_RED, COLR_BLUE, COLR_GREEN].map(|[r, g, b, a]| ColorRecord::new(b, g, r, a)).to_vec();
|
||||
let cpal = Cpal::new(3, 1, 3, Some(records), vec![0]);
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(colr).ok()?;
|
||||
builder.add_table(&cpal).ok()?;
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
|
||||
rtl,
|
||||
scale: 1.0,
|
||||
image: None,
|
||||
colour: None,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! COLR colour glyphs (v0 layers, v1 paints): read as trees of document nodes in font units, or
|
||||
//! as an image for paints VectorCraft lacks, and laid out with the type.
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::color::ColorGlyphFormat;
|
||||
|
||||
use super::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_font};
|
||||
|
||||
#[test]
|
||||
fn colr_fonts_build_and_read() {
|
||||
for v1 in [false, true] {
|
||||
let bytes = colr_font(v1).expect("the test font builds");
|
||||
let font = skrifa::FontRef::new(&bytes).unwrap();
|
||||
let glyph = |ch| font.color_glyphs().get(font.charmap().map(ch).unwrap());
|
||||
let format = glyph(COLOUR_CHAR).map(|g| g.format());
|
||||
assert!(matches!((v1, format), (false, Some(ColorGlyphFormat::ColrV0)) | (true, Some(ColorGlyphFormat::ColrV1))), "v1 {v1}");
|
||||
assert_eq!(glyph(COLR_SWEEP_CHAR).is_some(), v1, "only v1 has the sweep glyph");
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use skrifa::instance::LocationRef;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::colr::{ColourContent, colour_glyph};
|
||||
use super::test_fonts::{COLR_GREEN, COLR_RED};
|
||||
|
||||
const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 };
|
||||
|
||||
/// [`colour_glyph`] of `ch` in `font`.
|
||||
fn content(font: &[u8], ch: char) -> Option<ColourContent> {
|
||||
let font = skrifa::FontRef::new(font).unwrap();
|
||||
let gid = font.charmap().map(ch).unwrap().to_u32();
|
||||
colour_glyph(&font, LocationRef::default(), 1000.0, gid, FOREGROUND).0
|
||||
}
|
||||
|
||||
fn art(font: &[u8], ch: char) -> Arc<Node> {
|
||||
match content(font, ch) {
|
||||
Some(ColourContent::Art(a)) => a,
|
||||
_ => panic!("vector art expected"),
|
||||
}
|
||||
}
|
||||
|
||||
fn children(n: &Node) -> &[Arc<Node>] {
|
||||
n.children().map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn fill(n: &Node) -> Paint {
|
||||
n.appearance.fill_paint()
|
||||
}
|
||||
|
||||
fn rgb([r, g, b, _]: [u8; 4]) -> Color {
|
||||
Color::rgb8(r, g, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_layers_are_paths_in_paint_order() {
|
||||
let root = art(&colr_font(false).unwrap(), COLOUR_CHAR);
|
||||
assert!(!root.isolate, "a group without blending needs no layer of its own");
|
||||
let layers = children(&root);
|
||||
assert_eq!(layers.len(), 2, "{layers:?}");
|
||||
assert!(layers.iter().all(|l| matches!(l.kind, NodeKind::Path { .. })));
|
||||
assert_eq!(fill(&layers[0]), Paint::solid(rgb(COLR_RED)));
|
||||
assert_eq!(fill(&layers[1]), Paint::solid(FOREGROUND), "palette index 0xFFFF is the foreground");
|
||||
// In font units, y down: the `O` stands above the baseline.
|
||||
let b = layers[0].geometric_bounds().unwrap();
|
||||
assert!(b.y1 <= 20.0 && b.y0 < -500.0 && b.width() > 400.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_paints_become_gradients_clips_and_isolated_groups() {
|
||||
let root = art(&colr_font(true).unwrap(), COLOUR_CHAR);
|
||||
// The clip box clips the whole glyph.
|
||||
let [clipped] = children(&root) else { panic!("{:?}", children(&root)) };
|
||||
assert!(matches!(clipped.kind, NodeKind::Group { clip: true, .. }), "{:?}", clipped.kind);
|
||||
let layers = &children(clipped)[1..];
|
||||
assert_eq!(layers.len(), 3, "{layers:?}");
|
||||
let gradient = |n: &Node| match fill(n) {
|
||||
Paint::Gradient(g) => g,
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
};
|
||||
let linear = gradient(&layers[0]);
|
||||
assert_eq!(linear.gradient.kind, GradientKind::Linear);
|
||||
let geom = linear.geom.unwrap();
|
||||
assert!((geom.start.x - 0.0).abs() < 1e-6 && (geom.end.x - 500.0).abs() < 1e-6, "{geom:?}");
|
||||
let radial = gradient(&layers[1]);
|
||||
assert_eq!(radial.gradient.kind, GradientKind::Radial);
|
||||
// Scaled by a half about the origin, y down: centre (125, -150), radius 150.
|
||||
let geom = radial.geom.unwrap();
|
||||
assert!((geom.start.x - 125.0).abs() < 1e-3 && (geom.start.y + 150.0).abs() < 1e-3, "{geom:?}");
|
||||
assert!(((geom.end - geom.start).hypot() - 150.0).abs() < 1e-3, "{geom:?}");
|
||||
// The composite: an isolated group, the backdrop, then the source multiplied over it.
|
||||
let composite = &layers[2];
|
||||
assert!(composite.isolate);
|
||||
let [backdrop, source] = children(composite) else { panic!("{:?}", children(composite)) };
|
||||
assert_eq!(fill(backdrop), Paint::solid(rgb(COLR_RED)));
|
||||
let source = children(source).first().cloned().unwrap_or_else(|| source.clone());
|
||||
assert_eq!(fill(&source), Paint::solid(rgb(COLR_GREEN)));
|
||||
assert!(children(composite)[1].blend == BlendMode::Multiply, "{:?}", children(composite)[1].blend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_gradient_makes_the_glyph_an_image() {
|
||||
let Some(ColourContent::Image(image)) = content(&colr_font(true).unwrap(), COLR_SWEEP_CHAR) else { panic!("an image") };
|
||||
assert!(image.blob.bytes.starts_with(b"\x89PNG"));
|
||||
// The `O` is some 600 units tall: at 256 px per em, some 150 px.
|
||||
assert!((100..300).contains(&image.height), "{}", image.height);
|
||||
// Its pixels map back to the glyph's place in font units, y down.
|
||||
let b = image.xf.transform_rect_bbox(image.rect());
|
||||
assert!(b.y1 <= 20.0 && b.y0 < -500.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colr_graph_that_paints_itself_is_no_colour_glyph() {
|
||||
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint};
|
||||
use write_fonts::types::GlyphId16;
|
||||
let base = skrifa::FontRef::new(&colr_font(false).unwrap()).unwrap().charmap().map(COLOUR_CHAR).unwrap().to_u32();
|
||||
let base = GlyphId16::new(u16::try_from(base).unwrap());
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, Paint::colr_glyph(base))])).into();
|
||||
let font = super::test_fonts::colr_font_with("Colrtest Loop", &colr).unwrap();
|
||||
assert!(content(&font, COLOUR_CHAR).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colr_graph_nested_past_the_cap_is_no_colour_glyph() {
|
||||
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint};
|
||||
use write_fonts::types::{F2Dot14, GlyphId16};
|
||||
let font = skrifa::FontRef::new(&colr_font(false).unwrap()).unwrap().charmap().map(COLOUR_CHAR).unwrap().to_u32();
|
||||
let base = GlyphId16::new(u16::try_from(font).unwrap());
|
||||
let mut paint = Paint::glyph(Paint::solid(0, F2Dot14::from_f32(1.0)), base);
|
||||
for _ in 0..200 {
|
||||
paint = Paint::scale_uniform(paint, F2Dot14::from_f32(1.0));
|
||||
}
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, paint)])).into();
|
||||
let font = super::test_fonts::colr_font_with("Colrtest Deep", &colr).unwrap();
|
||||
assert!(content(&font, COLOUR_CHAR).is_none());
|
||||
}
|
||||
|
||||
// Layout: colour glyphs are laid out with their layers, which move with the type.
|
||||
|
||||
use kurbo::Point as KPoint;
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
|
||||
use super::test_fonts::colr_family;
|
||||
use super::{FontDb, layout};
|
||||
|
||||
fn colr_db(v1: bool) -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(colr_font(v1).expect("the test font builds")), 1);
|
||||
db
|
||||
}
|
||||
|
||||
fn colr_style(v1: bool) -> CharStyle {
|
||||
CharStyle { font_family: colr_family(v1).into(), size: 100.0, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colr_glyph_lays_out_its_layers_with_an_empty_outline() {
|
||||
let db = colr_db(false);
|
||||
let l = layout(&db, &TextObject::point(KPoint::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), colr_style(false)));
|
||||
let (g, f) = (&l.glyphs[0], &l.glyphs[1]);
|
||||
assert!(g.outline.elements().is_empty(), "the layers replace the outline");
|
||||
assert_eq!(g.colour.as_deref().map(|c| children(c).len()), Some(2));
|
||||
assert!(g.image.is_none());
|
||||
assert!(f.colour.is_none() && !f.outline.elements().is_empty(), "other glyphs keep their outlines");
|
||||
// In text space at 100 pt: the `O` layer stands on the baseline (y 0), some 70 pt tall.
|
||||
let placed = g.placed_colour(kurbo::Affine::IDENTITY).unwrap();
|
||||
let b = placed.geometric_bounds().unwrap();
|
||||
assert!(b.y1.abs() < 3.0 && b.height() > 60.0 && b.height() < 80.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_layer_takes_the_runs_fill_and_black_for_a_gradient_fill() {
|
||||
let db = colr_db(false);
|
||||
let foreground = |fill: Paint| {
|
||||
let st = CharStyle { fill, ..colr_style(false) };
|
||||
let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st));
|
||||
let colour = l.glyphs[0].colour.clone().unwrap();
|
||||
children(&colour)[1].appearance.fill_paint()
|
||||
};
|
||||
let blue = Color::rgb8(0, 0, 200);
|
||||
assert_eq!(foreground(Paint::solid(blue)), Paint::solid(blue));
|
||||
assert_eq!(foreground(Paint::solid(FOREGROUND)), Paint::solid(FOREGROUND), "a new fill colour, a new glyph");
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_doc::color::GradientPaint::new(Default::default())));
|
||||
assert_eq!(foreground(gradient), Paint::solid(Color::BLACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_bounds_take_in_the_colour_layers() {
|
||||
let db = colr_db(true);
|
||||
let l = layout(&db, &TextObject::point(KPoint::new(10.0, 50.0), &COLOUR_CHAR.to_string(), colr_style(true)));
|
||||
let g = &l.glyphs[0];
|
||||
let b = g.placed_colour(kurbo::Affine::IDENTITY).unwrap().geometric_bounds().unwrap();
|
||||
assert!(l.bounds.contains_rect(b.inset(-0.01)) || l.bounds.contains_rect(b), "{:?} holds {b:?}", l.bounds);
|
||||
// The `O` layer is wider than the `E` the glyph advances by.
|
||||
assert!(b.width() > g.advance * 0.9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_with_colr_and_sbix_draws_colr() {
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::types::Tag;
|
||||
let colr = colr_font(false).unwrap();
|
||||
let sbix = super::test_fonts::colour_bitmap_font(true).unwrap();
|
||||
let sbix = skrifa::FontRef::new(&sbix).unwrap();
|
||||
let font = skrifa::FontRef::new(&colr).unwrap();
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"sbix"), sbix.table_data(skrifa::Tag::new(b"sbix")).unwrap().as_bytes());
|
||||
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()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(builder.build()), 1);
|
||||
let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), colr_style(false)));
|
||||
assert!(l.glyphs[0].colour.is_some() && l.glyphs[0].image.is_none(), "vectors over bitmaps");
|
||||
}
|
||||
|
||||
/// A v1 font whose glyph of `base` is `paint`, given the glyph id of `O`, its layer list one
|
||||
/// red fill.
|
||||
fn v1_font(family: &str, base: char, paint: impl Fn(write_fonts::types::GlyphId16) -> ColrPaint) -> Vec<u8> {
|
||||
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, LayerList};
|
||||
use write_fonts::types::GlyphId16;
|
||||
let bytes = colr_font(false).unwrap();
|
||||
let font = skrifa::FontRef::new(&bytes).unwrap();
|
||||
let gid = |ch| GlyphId16::new(u16::try_from(font.charmap().map(ch).unwrap().to_u32()).unwrap());
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(base), paint(gid('O')))])).into();
|
||||
colr.layer_list = Some(LayerList::new(1, vec![solid(0)])).into();
|
||||
super::test_fonts::colr_font_with(family, &colr).unwrap()
|
||||
}
|
||||
|
||||
use write_fonts::tables::colr::Paint as ColrPaint;
|
||||
|
||||
fn solid(index: u16) -> ColrPaint {
|
||||
ColrPaint::solid(index, write_fonts::types::F2Dot14::from_f32(1.0))
|
||||
}
|
||||
|
||||
fn sweep() -> ColrPaint {
|
||||
use write_fonts::tables::colr::{ColorLine, ColorStop, Extend};
|
||||
use write_fonts::types::{F2Dot14, FWord};
|
||||
let one = F2Dot14::from_f32(1.0);
|
||||
let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]);
|
||||
ColrPaint::sweep_gradient(line, FWord::new(250), FWord::new(300), F2Dot14::from_f32(0.0), one)
|
||||
}
|
||||
|
||||
/// A glyph filled with a composite of solid colours is vector art: the composite's fills fill
|
||||
/// the glyph around them.
|
||||
#[test]
|
||||
fn a_glyph_filled_with_a_composite_is_vector_art() {
|
||||
use write_fonts::tables::colr::CompositeMode;
|
||||
let font = v1_font("Colrtest Composite", COLOUR_CHAR, |o| ColrPaint::glyph(ColrPaint::composite(solid(2), CompositeMode::Multiply, solid(0)), o));
|
||||
assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Art(_))));
|
||||
}
|
||||
|
||||
/// A colour of its own filling a (TrueType, quadratic) glyph's area is that glyph filled: one
|
||||
/// path, not a clipping group around a copy of it.
|
||||
#[test]
|
||||
fn a_glyph_filled_with_one_colour_is_one_path() {
|
||||
let font = v1_font("Colrtest Flood", COLOUR_CHAR, |o| ColrPaint::glyph(ColrPaint::colr_layers(1, 0), o));
|
||||
let root = art(&font, COLOUR_CHAR);
|
||||
let [only] = children(&root) else { panic!("{:?}", children(&root).iter().map(|c| c.kind_label()).collect::<Vec<_>>()) };
|
||||
assert!(matches!(only.kind, NodeKind::Path { .. }), "{}", only.kind_label());
|
||||
assert_eq!(fill(only), Paint::solid(rgb(COLR_RED)));
|
||||
}
|
||||
|
||||
/// A sweep glyph in a font without clip boxes, its base glyph without an outline (the space's,
|
||||
/// as COLRv1 fonts make them): its image is as large as what it paints.
|
||||
#[test]
|
||||
fn a_sweep_glyph_without_a_clip_box_or_outline_is_an_image_of_its_paint() {
|
||||
let font = v1_font("Colrtest Bare", ' ', |o| ColrPaint::glyph(sweep(), o));
|
||||
let Some(ColourContent::Image(image)) = content(&font, ' ') else { panic!("an image") };
|
||||
assert!((100..300).contains(&image.height), "{}", image.height);
|
||||
}
|
||||
|
||||
/// A transform that collapses the glyph (scale 0, from a font) draws nothing in its image,
|
||||
/// without panicking.
|
||||
#[test]
|
||||
fn a_collapsed_sweep_glyph_draws_nothing() {
|
||||
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Clip, ClipBox, ClipList, Colr};
|
||||
use write_fonts::types::{F2Dot14, FWord, GlyphId16};
|
||||
let bytes = colr_font(false).unwrap();
|
||||
let read = skrifa::FontRef::new(&bytes).unwrap();
|
||||
let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap());
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
let flat = ColrPaint::scale_uniform(ColrPaint::glyph(sweep(), gid('O')), F2Dot14::from_f32(0.0));
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), flat)])).into();
|
||||
// A clip box, so the image is made.
|
||||
let clip = ClipBox::format_1(FWord::new(0), FWord::new(0), FWord::new(500), FWord::new(500));
|
||||
colr.clip_list = Some(ClipList::new(1, 1, vec![Clip::new(gid(COLOUR_CHAR), gid(COLOUR_CHAR), clip)])).into();
|
||||
let font = super::test_fonts::colr_font_with("Colrtest Flat", &colr).unwrap();
|
||||
let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("an image") };
|
||||
assert!(image.blob.bytes.starts_with(b"\x89PNG"));
|
||||
}
|
||||
|
||||
/// Each paint documents have no form for makes the glyph an image.
|
||||
#[test]
|
||||
fn paints_documents_lack_make_the_glyph_an_image() {
|
||||
use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend};
|
||||
use write_fonts::types::{F2Dot14, FWord, UfWord};
|
||||
let one = F2Dot14::from_f32(1.0);
|
||||
let line = |extend| ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]);
|
||||
let w = FWord::new;
|
||||
type GlyphPaint<'a> = Box<dyn Fn(write_fonts::types::GlyphId16) -> ColrPaint + 'a>;
|
||||
let cases: [(&str, GlyphPaint<'_>); 3] = [
|
||||
("Colrtest Xor", Box::new(|o| ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Xor, ColrPaint::glyph(solid(0), o)))),
|
||||
(
|
||||
"Colrtest Repeat",
|
||||
Box::new(move |o| ColrPaint::glyph(ColrPaint::linear_gradient(line(Extend::Repeat), w(0), w(0), w(100), w(0), w(0), w(100)), o)),
|
||||
),
|
||||
(
|
||||
"Colrtest Circles",
|
||||
Box::new(move |o| {
|
||||
ColrPaint::glyph(ColrPaint::radial_gradient(line(Extend::Pad), w(200), w(300), UfWord::new(50), w(250), w(300), UfWord::new(300)), o)
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (family, paint) in cases {
|
||||
let font = v1_font(family, COLOUR_CHAR, paint);
|
||||
assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Image(_))), "{family}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial gradient whose start circle is around its centre is vector art, its stops moved out.
|
||||
#[test]
|
||||
fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() {
|
||||
use write_fonts::tables::colr::{ColorLine, ColorStop, Extend};
|
||||
use write_fonts::types::{F2Dot14, FWord, UfWord};
|
||||
let one = F2Dot14::from_f32(1.0);
|
||||
let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]);
|
||||
let w = FWord::new;
|
||||
let font = v1_font("Colrtest Ring", COLOUR_CHAR, |o| {
|
||||
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(250), w(300), UfWord::new(100), w(250), w(300), UfWord::new(400)), o)
|
||||
});
|
||||
let root = art(&font, COLOUR_CHAR);
|
||||
let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") };
|
||||
let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect();
|
||||
assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out");
|
||||
}
|
||||
|
||||
/// A glyph whose layers don't use the foreground colour is read once, whatever the type's fill.
|
||||
#[test]
|
||||
fn a_glyph_without_the_foreground_colour_is_read_once_for_every_fill() {
|
||||
let tree = |v1: bool, fill: Color| {
|
||||
let db = colr_db(v1);
|
||||
let st = CharStyle { fill: Paint::solid(fill), ..colr_style(v1) };
|
||||
let first = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st.clone())).glyphs[0].colour.clone().unwrap();
|
||||
let st = CharStyle { fill: Paint::solid(FOREGROUND), ..st };
|
||||
let other = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st)).glyphs[0].colour.clone().unwrap();
|
||||
Arc::ptr_eq(&first, &other)
|
||||
};
|
||||
assert!(tree(true, Color::rgb8(0, 0, 200)), "v1's glyph uses only its palette");
|
||||
assert!(!tree(false, Color::rgb8(0, 0, 200)), "v0's foreground layer follows the fill");
|
||||
}
|
||||
+9
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user