mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3741 from lehni/feature/otsvg
Type: OpenType SVG colour fonts draw and export as vector art
This commit is contained in:
+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-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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: 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: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
|
||||
- 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.
|
||||
@@ -196,6 +197,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 OpenType SVG colour fonts; headline numbers unchanged. |
|
||||
| 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. |
|
||||
|
||||
@@ -1452,6 +1452,8 @@ impl Session {
|
||||
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
|
||||
vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art);
|
||||
Self {
|
||||
docs: vec![],
|
||||
active: None,
|
||||
@@ -2331,6 +2333,8 @@ mod tests_colormgmt;
|
||||
#[cfg(test)]
|
||||
mod tests_colorthemes;
|
||||
#[cfg(test)]
|
||||
mod tests_colourfonts;
|
||||
#[cfg(test)]
|
||||
mod tests_compoundpaint;
|
||||
#[cfg(test)]
|
||||
mod tests_constrain_new;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Colour fonts through the session: the SVG importer it registers reads OpenType SVG glyphs,
|
||||
//! which then draw, export and outline as art.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_text::test_fonts::{SVG_CHAR, source_sans_gid, svg_font};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// `font` with licence flags (OS/2 `fsType`) that forbid embedding it.
|
||||
fn not_embeddable(mut font: Vec<u8>) -> Vec<u8> {
|
||||
let tables = usize::from(u16::from_be_bytes([font[4], font[5]]));
|
||||
let os2 = (0..tables).map(|i| 12 + 16 * i).find(|r| &font[*r..*r + 4] == b"OS/2").unwrap();
|
||||
let at = u32::from_be_bytes(font[os2 + 8..os2 + 12].try_into().unwrap()) as usize;
|
||||
font[at + 8..at + 10].copy_from_slice(&0x0002u16.to_be_bytes());
|
||||
font
|
||||
}
|
||||
|
||||
/// The fills of `pdf`'s art read back, and where each is.
|
||||
fn fills_of(pdf: &[u8]) -> Vec<(vectorcraft_color::Paint, vectorcraft_geom::Rect)> {
|
||||
let back = vectorcraft_pdf::import_with_report(pdf, &vectorcraft_pdf::ImportOptions::default()).unwrap().document;
|
||||
let mut fills = vec![];
|
||||
for l in &back.layers {
|
||||
l.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
fills.push((n.appearance.fill_paint(), b));
|
||||
}
|
||||
});
|
||||
}
|
||||
fills
|
||||
}
|
||||
|
||||
/// A session with `family`, the SVG test font of `document` for [`SVG_CHAR`]'s glyph, and 32 pt
|
||||
/// type of `SVG_CHAR` and `F` in it at (10, 50).
|
||||
fn with_svg_type(family: &str, document: &str) -> Session {
|
||||
let g = source_sans_gid(SVG_CHAR).unwrap();
|
||||
let mut s = session();
|
||||
vectorcraft_text::FontDb::global().add_font(svg_font(family, &[(g, g, document)]).unwrap());
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{SVG_CHAR}F"), "size": 32, "font": family})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// OpenType SVG glyphs become groups of the art their documents draw, through the SVG importer the
|
||||
/// session registers.
|
||||
#[test]
|
||||
fn create_outlines_turns_svg_glyphs_into_groups() {
|
||||
let g = source_sans_gid(SVG_CHAR).unwrap();
|
||||
let doc = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs><g id="glyph{g}"><path fill="url(#a)" d="M0 0h500v-700h-500z"/></g></svg>"##
|
||||
);
|
||||
let mut s = with_svg_type("Svgtest Outlines", &doc);
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let ch = node(&s, r[0]).children().unwrap().clone();
|
||||
assert!(matches!(ch[0].kind, NodeKind::Group { .. }), "the glyph's art: {:?}", ch[0].kind_label());
|
||||
let mut gradient = false;
|
||||
ch[0].walk(&mut |n| gradient |= matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::Gradient(_)));
|
||||
assert!(gradient, "with its gradient");
|
||||
// Half an em wide at 32 pt, standing 0.7 em on the baseline at y 50.
|
||||
let b = ch[0].geometric_bounds().unwrap();
|
||||
assert!((b.width() - 16.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6 && (b.height() - 22.4).abs() < 1e-6, "{b:?}");
|
||||
assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the `F` an outline");
|
||||
}
|
||||
|
||||
/// In a PDF with real text, an SVG glyph is text like any other (selectable, searchable), which
|
||||
/// draws nothing itself, and its art is drawn where it sits: no outline of the font's covers it.
|
||||
#[test]
|
||||
fn pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters() {
|
||||
let g = source_sans_gid(SVG_CHAR).unwrap();
|
||||
let doc = format!(r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph{g}"><path fill="#f00" d="M0 0h500v-700h-500z"/></g></svg>"##);
|
||||
let s = with_svg_type("Svgtest Pdf", &doc);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
// The glyphs each text-showing line writes (the font's codes are octal escapes), and whether
|
||||
// it is invisible.
|
||||
let raw = String::from_utf8_lossy(&pdf);
|
||||
let shown: Vec<(bool, usize)> =
|
||||
raw.lines().filter(|l| l.contains("Tj") || l.contains("TJ")).map(|l| (l.contains("3 Tr"), l.matches("\\0").count())).collect();
|
||||
assert_eq!(shown, [(false, 2)], "both characters once, as text");
|
||||
// The glyphs as the font draws them (its Type 3 glyph procedures, `<width> 0 d0` and what
|
||||
// they paint): the SVG glyph's draws nothing, rather than the font's plain outline over the
|
||||
// art; the `F` its outline.
|
||||
let procs: Vec<&str> = raw.split("stream\n").filter(|c| c.contains(" d0")).collect();
|
||||
assert_eq!(procs.len(), 2, "{procs:?}");
|
||||
assert_eq!(procs.iter().filter(|c| !c.contains(" Do")).count(), 1, "one glyph draws nothing: {procs:?}");
|
||||
let back = vectorcraft_pdf::import_with_report(&pdf, &vectorcraft_pdf::ImportOptions::default()).unwrap().document;
|
||||
let mut red = false;
|
||||
for l in &back.layers {
|
||||
l.walk(&mut |n| red |= n.appearance.fill_paint() == vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)));
|
||||
}
|
||||
assert!(red, "the glyph's art");
|
||||
}
|
||||
|
||||
/// An SVG glyph whose document holds no art for it keeps its outline in PDF real text too (its
|
||||
/// text draws nothing, as SVG glyphs' do).
|
||||
#[test]
|
||||
fn pdf_real_text_draws_the_outline_of_an_svg_glyph_without_art() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph1"/></svg>"##;
|
||||
let s = with_svg_type("Svgtest Bare Pdf", doc);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
// The `E`'s outline: from x 10 (its side bearing past it), 0.66 em tall, on the baseline at 50.
|
||||
let e = vectorcraft_geom::Rect::new(12.0, 32.0, 20.0, 48.0);
|
||||
assert!(fills_of(&pdf).iter().any(|(_, b)| b.intersect(e).area() > 10.0), "the glyph's outline");
|
||||
}
|
||||
|
||||
/// In a font that may not be embedded, an SVG glyph's art is drawn once.
|
||||
#[test]
|
||||
fn pdf_draws_an_svg_glyphs_art_once_when_its_font_cant_be_embedded() {
|
||||
let g = source_sans_gid(SVG_CHAR).unwrap();
|
||||
let doc = format!(r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph{g}"><path fill="#f00" d="M0 0h500v-700h-500z"/></g></svg>"##);
|
||||
let mut s = session();
|
||||
vectorcraft_text::FontDb::global().add_font(not_embeddable(svg_font("Svgtest Locked", &[(g, g, &doc)]).unwrap()));
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{SVG_CHAR}F"), "size": 32, "font": "Svgtest Locked"})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0));
|
||||
assert_eq!(fills_of(&pdf).iter().filter(|(p, _)| *p == red).count(), 1);
|
||||
}
|
||||
@@ -294,6 +294,9 @@ impl Writer {
|
||||
no_device_cs: out.tagged,
|
||||
cmyk_profile,
|
||||
configuration,
|
||||
// SVG glyphs, which krilla can't draw, are drawn as art where they sit (`glyph_text`):
|
||||
// as text they draw nothing, rather than their fonts' plain outlines over the art.
|
||||
render_svg_glyph_fn: |_, _, _, _, _| Some(()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -1688,8 +1691,8 @@ impl Exporter<'_> {
|
||||
/// 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 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.
|
||||
/// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the
|
||||
/// indices of those it can't write or draw (SVG glyphs) 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;
|
||||
@@ -1719,6 +1722,15 @@ impl Exporter<'_> {
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// SVG glyphs are written as text that draws nothing (see the serialize settings): their
|
||||
// art is drawn, or else their outline.
|
||||
if is_svg_glyph(g) {
|
||||
if coloured {
|
||||
unwritten.push(gi);
|
||||
} else {
|
||||
rest.extend(g.outline.iter());
|
||||
}
|
||||
}
|
||||
let size = upem * sy;
|
||||
let frame = g.xf * Affine::scale(1.0 / sy);
|
||||
// Its pen position along the frame's baseline, when it continues the line.
|
||||
@@ -1972,6 +1984,11 @@ fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `g` drawn from its font's SVG document, which the PDF writer can't draw as a font's glyph?
|
||||
fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool {
|
||||
vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! OpenType SVG glyphs: the art of a font's glyph from the SVG document its `SVG ` table holds for
|
||||
//! it, made as the importer makes art of any SVG file. The text crate asks for it through the
|
||||
//! reader the app registers ([`vectorcraft_text::set_svg_glyph_reader`]).
|
||||
//!
|
||||
//! Where it differs from the `SVG ` table's rules: a size on the root (`width`, `height`) counts
|
||||
//! for nothing; a non-square `viewBox` is scaled to fit the em by its larger side, not centred in
|
||||
//! it; and a glyph element nested in others keeps what they apply (transforms, opacity).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_doc::color::Color;
|
||||
use vectorcraft_doc::{Node, NodeId};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use crate::ImportOptions;
|
||||
|
||||
/// The most bytes a glyph's document may have, unpacked.
|
||||
const MAX_SVG_GLYPH_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// Elements left out of a glyph: text (type inside type), images (a glyph's art has no document
|
||||
/// to keep them in), embedded content and scripts. The `SVG ` table rules text, embedded content
|
||||
/// and scripts out, and allows images.
|
||||
const LEFT_OUT: [&str; 4] = ["text", "image", "foreignObject", "script"];
|
||||
|
||||
/// Glyph `gid`'s art from its SVG `document` (possibly gzipped), in a font of `upem` units per em:
|
||||
/// its element `glyph<gid>` (or the whole document when that is its root), in font units (y down),
|
||||
/// a `viewBox` mapped onto the em square. The text's colour is `fill`: `currentColor`, and the
|
||||
/// legacy `context-fill` and `context-stroke` (none without a `stroke`). Its nodes have no ids
|
||||
/// (they aren't a document's). With the art, whether it uses the text's colours. `None` when the
|
||||
/// document doesn't read, is too large or has no such element.
|
||||
pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option<Color>) -> Option<(Node, bool)> {
|
||||
// The SVGZ budget left is the glyph's cap.
|
||||
let unpacked = AtomicU64::new(crate::MAX_SVGZ.saturating_sub(MAX_SVG_GLYPH_BYTES));
|
||||
let text = crate::text_within(document, &unpacked).ok()?;
|
||||
if text.len() as u64 > MAX_SVG_GLYPH_BYTES {
|
||||
return None;
|
||||
}
|
||||
let id = format!("glyph{gid}");
|
||||
let hex = |c: Color| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
};
|
||||
let Glyph { svg, whole, view_box } = glyph(&text, &id, &hex(fill))?;
|
||||
let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k));
|
||||
// usvg resolves `context-fill` and `context-stroke` only for markers.
|
||||
let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex));
|
||||
let (doc, _) = crate::import::import(&svg, &ImportOptions::default()).ok()?;
|
||||
let mut art = if whole {
|
||||
Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect())
|
||||
} else {
|
||||
// A top-level element is a layer, others an object, named after its id.
|
||||
let named = |n: &Node| n.name.as_deref() == Some(id.as_str());
|
||||
match doc.layers.iter().find(|l| named(l)) {
|
||||
Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()),
|
||||
None => {
|
||||
let mut found = None;
|
||||
for layer in &doc.layers {
|
||||
layer.walk(&mut |n| {
|
||||
if found.is_none() && named(n) {
|
||||
found = Some(n.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
found?
|
||||
}
|
||||
}
|
||||
};
|
||||
// Without a size of its own, the document's viewport is its view box at one unit per unit:
|
||||
// the em square is that box.
|
||||
if let Some(side) = view_box.filter(|s| *s > 0.0 && s.is_finite()) {
|
||||
art.transform(Affine::scale(upem / side), true);
|
||||
}
|
||||
Some((anonymous(&art), coloured))
|
||||
}
|
||||
|
||||
/// A glyph's document made ready for the importer.
|
||||
struct Glyph {
|
||||
svg: String,
|
||||
/// The glyph is the whole document (its root has the glyph's id).
|
||||
whole: bool,
|
||||
/// The larger side of the root's `viewBox`, if it has one.
|
||||
view_box: Option<f64>,
|
||||
}
|
||||
|
||||
/// `svg` with only what glyph `id` draws: without the other glyphs' top-level elements (a font
|
||||
/// may keep all its glyphs in one document), the [`LEFT_OUT`] elements and event attributes, the
|
||||
/// root without a size (the em square is its view box) and in the text's colour `color`. `None`
|
||||
/// when it doesn't read or has no element `id`.
|
||||
fn glyph(svg: &str, id: &str, color: &str) -> Option<Glyph> {
|
||||
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?;
|
||||
let root = xml.root_element();
|
||||
let whole = root.attribute("id") == Some(id);
|
||||
if !whole && !xml.descendants().any(|n| n.attribute("id") == Some(id)) {
|
||||
return None;
|
||||
}
|
||||
let is_glyph = |v: &str| v.strip_prefix("glyph").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
|
||||
// Edits: byte ranges to cut and what to put in their place, in order.
|
||||
let mut edits: Vec<(std::ops::Range<usize>, String)> = vec![];
|
||||
for n in root.children().filter(|n| n.attribute("id").is_some_and(|v| v != id && is_glyph(v))) {
|
||||
edits.push((n.range(), String::new()));
|
||||
}
|
||||
for n in root.descendants().filter(|n| n.is_element() && LEFT_OUT.contains(&n.tag_name().name())) {
|
||||
edits.push((n.range(), String::new()));
|
||||
}
|
||||
for a in root.attributes().filter(|a| matches!(a.name(), "width" | "height" | "color")) {
|
||||
edits.push((a.range(), String::new()));
|
||||
}
|
||||
// A glyph is art: event attributes (scripts) go too.
|
||||
for a in root.descendants().filter(|n| n.is_element()).flat_map(|n| n.attributes()).filter(|a| a.name().starts_with("on")) {
|
||||
edits.push((a.range(), String::new()));
|
||||
}
|
||||
// The text's colour, right after the root's name.
|
||||
let start = root.range().start + 1;
|
||||
let name = start + svg.get(start..)?.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?;
|
||||
edits.push((name..name, format!(" color=\"{color}\"")));
|
||||
edits.sort_by_key(|(r, _)| (r.start, r.end));
|
||||
let mut out = String::with_capacity(svg.len());
|
||||
let mut at = 0;
|
||||
for (r, with) in edits {
|
||||
// An element inside one already cut goes with it.
|
||||
if r.start < at {
|
||||
continue;
|
||||
}
|
||||
out.push_str(svg.get(at..r.start)?);
|
||||
out.push_str(&with);
|
||||
at = r.end;
|
||||
}
|
||||
out.push_str(svg.get(at..)?);
|
||||
let view_box = root.attribute("viewBox").and_then(|v| {
|
||||
let n: Vec<f64> = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect();
|
||||
match n.as_slice() {
|
||||
[_, _, w, h] => Some(w.max(*h)),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
Some(Glyph { svg: out, whole, view_box })
|
||||
}
|
||||
|
||||
/// `n` with no ids, its members' neither.
|
||||
fn anonymous(n: &Node) -> Node {
|
||||
let mut n = n.clone();
|
||||
n.id = NodeId(u64::MAX);
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children.iter_mut() {
|
||||
*c = Arc::new(anonymous(c));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
mod css;
|
||||
mod export;
|
||||
mod glyph;
|
||||
mod import;
|
||||
|
||||
pub use css::{CssOptions, CssRule, CssSheet, CssUnits, css_rules};
|
||||
@@ -534,6 +535,7 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
|
||||
import_with(svg, &ImportOptions::default())
|
||||
}
|
||||
|
||||
pub use glyph::glyph_art;
|
||||
pub use import::MAX_ELEMENT_DEPTH;
|
||||
|
||||
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! OpenType SVG glyphs: `glyph_art` makes a glyph's art of its SVG document, as the importer opens
|
||||
//! SVG files, in font units.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_svg::glyph_art;
|
||||
|
||||
const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 };
|
||||
|
||||
/// [`glyph_art`] in a font of 1000 units per em, in black text.
|
||||
fn art(doc: &str, gid: u32) -> Option<Node> {
|
||||
glyph_art(doc.as_bytes(), gid, 1000.0, Color::BLACK, None).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
fn fills(n: &Node) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push(c.appearance.fill_paint()));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glyphs_element_is_its_art_in_font_units() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
|
||||
<g id="glyph3"><path fill="#0f0" d="M0 0h10v-10z"/></g>
|
||||
<g id="glyph7"><path fill="url(#g)" d="M100 0h500v-700h-500z"/></g>
|
||||
</svg>"##;
|
||||
let art7 = art(doc, 7).unwrap();
|
||||
let b = art7.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.x1 - 600.0).abs() < 1e-6 && (b.y0 + 700.0).abs() < 1e-6 && b.y1.abs() < 1e-6, "{b:?}");
|
||||
let fills = fills(&art7);
|
||||
assert!(fills.iter().any(|p| matches!(p, Paint::Gradient(_))), "its gradient: {fills:?}");
|
||||
assert!(!fills.contains(&Paint::solid(Color::rgb8(0, 255, 0))), "not the other glyph's art");
|
||||
assert!(art(doc, 5).is_none(), "no element, no art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_that_is_its_whole_document_is_its_art() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" id="glyph9"><path fill="#f00" d="M0 0h500v-500z"/></svg>"##;
|
||||
assert_eq!(fills(&art(doc, 9).unwrap()).iter().filter(|p| **p == Paint::solid(Color::rgb8(255, 0, 0))).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_color_and_context_colours_are_the_texts() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph1">
|
||||
<path fill="currentColor" d="M0 0h500v-500z"/>
|
||||
<path fill="context-fill" stroke="context-stroke" stroke-width="10" d="M0 0h500v-500z"/>
|
||||
</g></svg>"##;
|
||||
let (art, coloured) = glyph_art(doc.as_bytes(), 1, 1000.0, BLUE, Some(Color::rgb8(255, 0, 0))).unwrap();
|
||||
assert!(coloured);
|
||||
let mut seen = vec![];
|
||||
art.walk(&mut |c| seen.push((c.appearance.fill_paint(), c.appearance.stroke_paint())));
|
||||
assert!(seen.contains(&(Paint::solid(BLUE), Paint::None)), "currentColor: {seen:?}");
|
||||
assert!(seen.contains(&(Paint::solid(BLUE), Paint::solid(Color::rgb8(255, 0, 0)))), "context-fill and -stroke: {seen:?}");
|
||||
let (art, _) = glyph_art(doc.as_bytes(), 1, 1000.0, BLUE, None).unwrap();
|
||||
let mut strokes = vec![];
|
||||
art.walk(&mut |c| strokes.push(c.appearance.stroke_paint()));
|
||||
assert!(strokes.iter().all(|p| *p == Paint::None), "no stroke without the text's: {strokes:?}");
|
||||
let plain = r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph1"><path fill="#f00" d="M0 0h5v-5z"/></g></svg>"##;
|
||||
assert!(!glyph_art(plain.as_bytes(), 1, 1000.0, BLUE, None).unwrap().1, "a document in its own colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_box_is_the_em_square() {
|
||||
// The view box 2000 units wide is the em: in a font of 1000 units per em, art at 500 is at 250,
|
||||
// in one of 2048 at 512; a size on the root counts for nothing.
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" width="10pt" height="10pt" viewBox="0 0 2000 2000">
|
||||
<g id="glyph2"><path fill="#000" d="M500 500h500v500h-500z"/></g></svg>"##;
|
||||
for (upem, x) in [(1000.0, 250.0), (2048.0, 512.0)] {
|
||||
let b = glyph_art(doc.as_bytes(), 2, upem, Color::BLACK, None).unwrap().0.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - x).abs() < 1e-6 && (b.width() - x).abs() < 1e-6, "{upem}: {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Text (which would lay type out inside type), images, embedded content and scripts are left out
|
||||
/// of a glyph, as the OpenType `SVG ` table asks.
|
||||
#[test]
|
||||
fn text_images_and_scripts_are_left_out() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="glyph1">
|
||||
<path fill="#f00" d="M0 0h500v-500z"/>
|
||||
<text font-family="Svgtest Glyphs" font-size="500">E<tspan>F</tspan></text>
|
||||
<image width="10" height="10" xlink:href="data:image/png;base64,iVBORw0KGgo="/>
|
||||
<script>alert(1)</script>
|
||||
</g></svg>"##;
|
||||
let art = art(doc, 1).unwrap();
|
||||
let mut kinds = vec![];
|
||||
art.walk(&mut |n| kinds.push(n.kind_label()));
|
||||
assert!(art.children().is_some_and(|c| !c.is_empty()), "the path stays");
|
||||
let mut other = false;
|
||||
art.walk(&mut |n| other |= matches!(n.kind, NodeKind::Text(_) | NodeKind::Image(_)));
|
||||
assert!(!other, "{kinds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzipped_documents_are_read_and_malformed_ones_are_no_art() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg"><g id="glyph4"><path fill="#000" d="M0 0h10v-10z"/></g></svg>"##;
|
||||
assert!(glyph_art(&vectorcraft_svg::compress(doc), 4, 1000.0, Color::BLACK, None).is_some(), "gzipped");
|
||||
assert!(art("not xml <", 4).is_none());
|
||||
// A document that unpacks past the glyph cap (a bomb).
|
||||
let huge = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><g id=\"glyph4\"><!--{}--></g></svg>", "x".repeat(4 << 20));
|
||||
assert!(glyph_art(&vectorcraft_svg::compress(&huge), 4, 1000.0, Color::BLACK, None).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// A font's glyph documents are untrusted: whatever they hold, reading one doesn't panic.
|
||||
#[test]
|
||||
fn any_document_reads_without_panicking(body in "[<>/=\"a-z0-9 #:.()-]{0,200}", bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
|
||||
let doc = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"><g id=\"glyph1\">{body}</g></svg>");
|
||||
let _ = glyph_art(doc.as_bytes(), 1, 1000.0, Color::BLACK, Some(BLUE));
|
||||
let _ = glyph_art(&bytes, 1, 0.0, Color::BLACK, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left-out element inside another glyph's element goes with it, and the glyph asked for, nested
|
||||
/// in a group that holds other glyphs' art, is still found.
|
||||
#[test]
|
||||
fn cuts_inside_cuts_and_nested_glyphs_read() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="glyph1"><path fill="#0f0" d="M0 0h5v-5z"/><text>A</text></g>
|
||||
<g><g id="glyph2"><path fill="#f00" d="M0 0h500v-500z"/><script>x</script></g></g>
|
||||
</svg>"##;
|
||||
assert!(fills(&art(doc, 2).unwrap()).contains(&Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
assert!(art(doc, 1).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(0, 255, 0)))));
|
||||
}
|
||||
|
||||
/// A font's glyph is art, never a script: event attributes on its shapes are left out, so no
|
||||
/// export carries them on.
|
||||
#[test]
|
||||
fn event_attributes_are_left_out() {
|
||||
let doc = r##"<svg xmlns="http://www.w3.org/2000/svg" onload="a()"><g id="glyph1" onclick="b()">
|
||||
<path id="p" fill="#f00" onmouseover="c()" d="M0 0h500v-500z"/></g></svg>"##;
|
||||
let art = art(doc, 1).unwrap();
|
||||
let mut events = vec![];
|
||||
art.walk(&mut |n| events.extend(n.extras().attrs.as_ref().map(|a| a.events.clone()).unwrap_or_default()));
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
}
|
||||
@@ -48,6 +48,11 @@ pub(crate) fn has_colour_layers(font: &skrifa::FontRef<'_>) -> bool {
|
||||
font.colr().is_ok()
|
||||
}
|
||||
|
||||
/// Does glyph `gid` of `font` have COLR layers?
|
||||
pub(crate) fn has_colour_glyph(font: &skrifa::FontRef<'_>, gid: u32) -> bool {
|
||||
ColorGlyphCollection::new(font).get(GlyphId::new(gid)).is_some()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
+43
-11
@@ -140,6 +140,8 @@ pub struct FontFace {
|
||||
colour_bitmaps: bool,
|
||||
/// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]).
|
||||
colour_layers: bool,
|
||||
/// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]).
|
||||
svg_glyphs: bool,
|
||||
}
|
||||
|
||||
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
|
||||
@@ -420,9 +422,11 @@ type CanonicalNames = HashMap<(String, String), (String, String)>;
|
||||
|
||||
/// [`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>;
|
||||
/// [`FontDb::glyph_colour`]'s answers by face and glyph, and the text's fill and stroke colours for
|
||||
/// glyphs that paint with them.
|
||||
type GlyphColours = GlyphCache<(u32, u32, Option<TextColours>), ColourContent>;
|
||||
/// The text's fill and stroke colours, as a cache key.
|
||||
type TextColours = ([u8; 4], Option<[u8; 4]>);
|
||||
|
||||
/// What a glyph's colour content (an image, layers) takes of memory, in bytes.
|
||||
trait Weight {
|
||||
@@ -1598,6 +1602,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
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);
|
||||
let svg_glyphs = crate::svg_glyph::has_svg_glyphs(&f);
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
@@ -1626,6 +1631,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
icf_margins: std::sync::OnceLock::new(),
|
||||
colour_bitmaps,
|
||||
colour_layers,
|
||||
svg_glyphs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2514,26 +2520,52 @@ impl FontDb {
|
||||
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 {
|
||||
/// Is glyph `gid` of face `face_id` drawn from its SVG document (it has one, and no COLR
|
||||
/// layers, which come first)? Writers that draw a font's own glyphs can draw COLR and bitmap
|
||||
/// glyphs, not always SVG ones.
|
||||
pub fn is_svg_glyph(&self, face_id: u32, gid: u32) -> bool {
|
||||
let Some(face) = self.face_by_id(face_id) else { return false };
|
||||
face.skrifa().is_some_and(|f| svg_drawn(&face, &f, gid) && crate::svg_glyph::has_document(&f, gid))
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s colour content in `face`, from its COLR layers or else its SVG document, in
|
||||
/// the text's `foreground` (fill) and `stroke` colours where it uses them; cached per (face,
|
||||
/// glyph), and per text colours for the glyphs that paint with them.
|
||||
pub(crate) fn glyph_colour(&self, face: &FontFace, gid: u32, foreground: Color, stroke: Option<Color>) -> Option<ColourContent> {
|
||||
if !face.colour_layers && !face.svg_glyphs {
|
||||
return None;
|
||||
}
|
||||
let keyed = Some(foreground.to_rgba8(1.0));
|
||||
let f = face.skrifa()?;
|
||||
// COLR first; SVG for glyphs without COLR layers (those whose layers don't read keep their
|
||||
// outlines, as in fonts without SVG).
|
||||
let svg = svg_drawn(face, &f, gid);
|
||||
// Kept without colours, or by the text's colours it uses: the fill, and for SVG art the
|
||||
// stroke too.
|
||||
let coloured = Some((foreground.to_rgba8(1.0), stroke.filter(|_| svg).map(|c| c.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))) {
|
||||
if let Some(c) = [None, coloured].iter().find_map(|k| cache.kept.get(&(face.id, gid, *k))) {
|
||||
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 });
|
||||
let (content, used) = if svg {
|
||||
let (art, used) = crate::svg_glyph::svg_glyph(&f, gid, face.upem, foreground, stroke);
|
||||
(art.map(ColourContent::Art), used)
|
||||
} else {
|
||||
crate::colr::colour_glyph(&f, face.location(), face.upem, gid, foreground)
|
||||
};
|
||||
let key = (face.id, gid, if used { coloured } else { None });
|
||||
self.colours.lock().unwrap_or_else(|e| e.into_inner()).keep(key, content.clone());
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
/// Is glyph `gid` of `face` (as `font`) drawn from an SVG document, if it has one: the face has
|
||||
/// SVG glyphs, and the glyph no COLR layers, which come first.
|
||||
fn svg_drawn(face: &FontFace, font: &skrifa::FontRef<'_>, gid: u32) -> bool {
|
||||
face.svg_glyphs && !crate::colr::has_colour_glyph(font, gid)
|
||||
}
|
||||
|
||||
/// A pen drawing outlines y down, as text space has them.
|
||||
pub(crate) struct FlipPen(pub(crate) BezPath);
|
||||
|
||||
|
||||
@@ -106,15 +106,19 @@ 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 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.
|
||||
// A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or
|
||||
// COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps.
|
||||
// Art in the text's colours takes the run's fill (black when it isn't a colour) and
|
||||
// stroke.
|
||||
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_of = |p: &vectorcraft_doc::color::Paint| match p {
|
||||
vectorcraft_doc::color::Paint::Solid { color, .. } => Some(*color),
|
||||
_ => None,
|
||||
};
|
||||
let (colour, image) = match (!plain).then(|| self.db.glyph_colour(&g.face, g.gid, foreground)).flatten() {
|
||||
let st = self.style_at(g.byte);
|
||||
let foreground = colour_of(&st.fill).unwrap_or(vectorcraft_doc::color::Color::BLACK);
|
||||
let stroke = colour_of(&st.stroke).filter(|_| st.has_stroke());
|
||||
let (colour, image) = match (!plain).then(|| self.db.glyph_colour(&g.face, g.gid, foreground, stroke)).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()),
|
||||
|
||||
@@ -26,6 +26,7 @@ mod layout;
|
||||
mod shape;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod suitcase;
|
||||
mod svg_glyph;
|
||||
#[cfg(any(test, feature = "test-fonts"))]
|
||||
pub mod test_fonts;
|
||||
pub mod thread;
|
||||
@@ -39,6 +40,7 @@ pub use fontdb::{
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
use std::sync::Arc;
|
||||
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
|
||||
@@ -126,8 +128,8 @@ pub struct PositionedGlyph {
|
||||
/// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is
|
||||
/// 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.
|
||||
/// A colour glyph's art (COLR layers, an SVG document's art), drawn through `xf` instead of an
|
||||
/// outline: a group in font units (y down). Its `outline` is empty.
|
||||
pub colour: Option<Arc<vectorcraft_doc::Node>>,
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ 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
|
||||
/// The glyph's colour art 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();
|
||||
@@ -647,6 +649,8 @@ mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_snap;
|
||||
#[cfg(test)]
|
||||
mod tests_svg_glyphs;
|
||||
#[cfg(test)]
|
||||
mod tests_sysfonts;
|
||||
#[cfg(test)]
|
||||
mod tests_typo;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! OpenType SVG glyphs: a font's `SVG ` table holds SVG documents whose element `glyph<gid>` draws
|
||||
//! the glyph, in font units (y down, the origin at the glyph's). Turning SVG into art is the SVG
|
||||
//! importer's job, a layer above this crate, so the app registers it as the reader
|
||||
//! ([`set_svg_glyph_reader`]); without one, SVG glyphs keep their outlines.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::raw::TableProvider;
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_doc::color::Color;
|
||||
|
||||
/// Turns glyph `gid`'s SVG document (possibly gzipped) of a font of `upem` units per em into its
|
||||
/// art in font units (y down), in the text's colours `fill` and `stroke`, and says whether it used
|
||||
/// them; `None` when it can't.
|
||||
pub type SvgGlyphReader = fn(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option<Color>) -> Option<(Node, bool)>;
|
||||
|
||||
static READER: OnceLock<SvgGlyphReader> = OnceLock::new();
|
||||
|
||||
/// Registers the reader of SVG glyphs. The first one registered stays.
|
||||
pub fn set_svg_glyph_reader(reader: SvgGlyphReader) {
|
||||
let _ = READER.set(reader);
|
||||
}
|
||||
|
||||
/// Does `font` have SVG glyphs?
|
||||
pub(crate) fn has_svg_glyphs(font: &skrifa::FontRef<'_>) -> bool {
|
||||
font.svg().is_ok()
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s SVG document in `font`, if it has one.
|
||||
fn document<'a>(font: &skrifa::FontRef<'a>, gid: u32) -> Option<&'a [u8]> {
|
||||
font.svg().ok().and_then(|t| t.glyph_data(GlyphId::new(gid)))
|
||||
}
|
||||
|
||||
/// Does glyph `gid` of `font` have an SVG document?
|
||||
pub(crate) fn has_document(font: &skrifa::FontRef<'_>, gid: u32) -> bool {
|
||||
document(font, gid).is_some()
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s art from its SVG document, read by the registered reader, and whether it uses
|
||||
/// the text's colours. `None` without a document, a reader, or art.
|
||||
pub(crate) fn svg_glyph(font: &skrifa::FontRef<'_>, gid: u32, upem: f64, fill: Color, stroke: Option<Color>) -> (Option<Arc<Node>>, bool) {
|
||||
let Some(document) = document(font, gid) else { return (None, false) };
|
||||
let Some(reader) = READER.get() else { return (None, false) };
|
||||
match reader(document, gid, upem, fill, stroke) {
|
||||
Some((art, coloured)) => (Some(Arc::new(art)), coloured),
|
||||
None => (None, false),
|
||||
}
|
||||
}
|
||||
@@ -676,3 +676,48 @@ pub fn colr_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> O
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The character whose glyph [`svg_font`]'s first document draws.
|
||||
pub const SVG_CHAR: char = 'E';
|
||||
|
||||
/// The glyph id of `ch` in the bundled Source Sans 3 (and the fonts made from it).
|
||||
pub fn source_sans_gid(ch: char) -> Option<u16> {
|
||||
source_sans_gids([ch]).map(|[g]| g)
|
||||
}
|
||||
|
||||
/// The bundled Source Sans 3 Regular as `family` with an `SVG ` table written from the OpenType
|
||||
/// specification: `documents` are (first glyph, last glyph, SVG document), in glyph order. Its
|
||||
/// outlines stay. `None` when it can't be built.
|
||||
pub fn svg_font(family: &str, documents: &[(u16, u16, &str)]) -> Option<Vec<u8>> {
|
||||
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"))])?;
|
||||
// The header (version, the document list's offset, reserved), then the document list: its
|
||||
// records (first and last glyph, the document's offset from the list and length), then the
|
||||
// documents.
|
||||
let records = documents.len();
|
||||
let mut list = (u16::try_from(records).ok()?).to_be_bytes().to_vec();
|
||||
let mut offset = 2 + 12 * records;
|
||||
for (first, last, doc) in documents {
|
||||
list.extend(first.to_be_bytes());
|
||||
list.extend(last.to_be_bytes());
|
||||
list.extend((u32::try_from(offset).ok()?).to_be_bytes());
|
||||
list.extend((u32::try_from(doc.len()).ok()?).to_be_bytes());
|
||||
offset += doc.len();
|
||||
}
|
||||
for (_, _, doc) in documents {
|
||||
list.extend(doc.as_bytes());
|
||||
}
|
||||
let mut svg = vec![0, 0, 0, 0, 0, 10, 0, 0, 0, 0];
|
||||
svg.extend(list);
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_raw(Tag::new(b"SVG "), svg);
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! OpenType SVG glyphs: layout asks the reader the app registers (the SVG importer, a layer up)
|
||||
//! for each glyph's art, and carries it as the glyph's colour content.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Rect, Shape};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::test_fonts::{SVG_CHAR, source_sans_gid, svg_font};
|
||||
|
||||
/// The family of the SVG test fonts here (each database holds one).
|
||||
const FAMILY: &str = "Svgtest Glyphs";
|
||||
use super::{FontDb, layout, set_svg_glyph_reader};
|
||||
|
||||
/// Stands in for the SVG importer, which this crate can't use (it is a layer up): a square one em
|
||||
/// wide in the fill, stroked when there is a stroke, for a document that has the glyph's element.
|
||||
fn reader(document: &[u8], gid: u32, _upem: f64, fill: Color, stroke: Option<Color>) -> Option<(Node, bool)> {
|
||||
let text = std::str::from_utf8(document).ok()?;
|
||||
if !text.contains(&format!("id=\"glyph{gid}\"")) {
|
||||
return None;
|
||||
}
|
||||
let coloured = text.contains("context-");
|
||||
let path = PathData::from_bezpath(&Rect::new(0.0, -700.0, 1000.0, 0.0).to_path(0.1));
|
||||
let stroke = stroke.map_or(Paint::None, Paint::solid);
|
||||
let art = Node::group(NodeId(u64::MAX), vec![Arc::new(Node::path(NodeId(u64::MAX), path, Appearance::basic(Paint::solid(fill), stroke, 10.0)))]);
|
||||
Some((art, coloured))
|
||||
}
|
||||
|
||||
fn gid(ch: char) -> u16 {
|
||||
source_sans_gid(ch).unwrap()
|
||||
}
|
||||
|
||||
/// A font database with the SVG test font of `document` for [`SVG_CHAR`]'s glyph.
|
||||
fn db(document: &str) -> FontDb {
|
||||
set_svg_glyph_reader(reader);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let g = gid(SVG_CHAR);
|
||||
assert_eq!(db.add_font(svg_font(FAMILY, &[(g, g, document)]).unwrap()), 1);
|
||||
db
|
||||
}
|
||||
|
||||
fn style(fill: Color) -> CharStyle {
|
||||
CharStyle { font_family: FAMILY.into(), size: 100.0, fill: Paint::solid(fill), ..CharStyle::default() }
|
||||
}
|
||||
|
||||
fn document(body: &str) -> String {
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><g id=\"glyph{}\">{body}</g></svg>", gid(SVG_CHAR))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_glyph_is_what_the_reader_makes_of_its_document() {
|
||||
let db = db(&document("<path fill=\"context-fill\" d=\"M0 0h500v-500z\"/>"));
|
||||
let blue = Color::rgb8(0, 0, 200);
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, &format!("{SVG_CHAR}F"), style(blue)));
|
||||
let (g, f) = (&l.glyphs[0], &l.glyphs[1]);
|
||||
assert!(g.outline.elements().is_empty(), "the art replaces the outline");
|
||||
let art = g.placed_colour(kurbo::Affine::IDENTITY).expect("the reader's art");
|
||||
let mut fills = vec![];
|
||||
art.walk(&mut |n| fills.push(n.appearance.fill_paint()));
|
||||
assert!(fills.contains(&Paint::solid(blue)), "in the run's fill: {fills:?}");
|
||||
// One em at 100 pt.
|
||||
let b = art.geometric_bounds().unwrap();
|
||||
assert!((b.width() - 100.0).abs() < 1e-6 && (b.y0 + 70.0).abs() < 1e-6, "{b:?}");
|
||||
assert!(f.colour.is_none() && !f.outline.elements().is_empty(), "glyphs without documents keep their outlines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_the_reader_makes_nothing_of_keeps_its_outline() {
|
||||
let db = db("<svg xmlns=\"http://www.w3.org/2000/svg\"><g id=\"glyph1\"/></svg>");
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(Color::BLACK)));
|
||||
assert!(l.glyphs[0].colour.is_none() && !l.glyphs[0].outline.elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_text_colours_is_read_once_for_every_fill() {
|
||||
let fonts = db(&document("<path fill=\"red\" d=\"M0 0h500v-500z\"/>"));
|
||||
let art = |fill| layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(fill))).glyphs[0].colour.clone().unwrap();
|
||||
assert!(Arc::ptr_eq(&art(Color::BLACK), &art(Color::rgb8(0, 0, 200))));
|
||||
let fonts = db(&document("<path fill=\"context-fill\" d=\"M0 0h500v-500z\"/>"));
|
||||
let art = |fill| layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(fill))).glyphs[0].colour.clone().unwrap();
|
||||
assert!(!Arc::ptr_eq(&art(Color::BLACK), &art(Color::rgb8(0, 0, 200))), "context-fill follows the fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_comes_before_svg() {
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::types::Tag;
|
||||
set_svg_glyph_reader(reader);
|
||||
let colr = super::test_fonts::colr_font(false).unwrap();
|
||||
let colr = skrifa::FontRef::new(&colr).unwrap();
|
||||
let g = gid(SVG_CHAR);
|
||||
let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap();
|
||||
let svg = skrifa::FontRef::new(&svg).unwrap();
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"SVG "), svg.table_data(skrifa::Tag::new(b"SVG ")).unwrap().as_bytes());
|
||||
for r in colr.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, colr.table_data(r.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(builder.build()), 1);
|
||||
let st = CharStyle { font_family: super::test_fonts::colr_family(false).into(), size: 100.0, ..CharStyle::default() };
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), st));
|
||||
// The COLR glyph has two layers; the reader's art one path.
|
||||
let colour = l.glyphs[0].colour.clone().unwrap();
|
||||
assert_eq!(colour.children().map(Vec::len), Some(2), "COLR's layers");
|
||||
}
|
||||
|
||||
/// A glyph with COLR layers that don't read (one paints itself) keeps its outline, also where the
|
||||
/// font has an SVG document for it: COLR glyphs never draw SVG art, so every writer agrees on what
|
||||
/// it is.
|
||||
#[test]
|
||||
fn a_glyph_whose_colr_layers_dont_read_keeps_its_outline() {
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint};
|
||||
use write_fonts::types::{GlyphId16, Tag};
|
||||
set_svg_glyph_reader(reader);
|
||||
let g = gid(SVG_CHAR);
|
||||
let mut colr = Colr::new(0, None, None, 0);
|
||||
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(GlyphId16::new(g), Paint::colr_glyph(GlyphId16::new(g)))])).into();
|
||||
let looped = super::test_fonts::colr_font_with(FAMILY, &colr).unwrap();
|
||||
let looped = skrifa::FontRef::new(&looped).unwrap();
|
||||
let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap();
|
||||
let svg = skrifa::FontRef::new(&svg).unwrap();
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"SVG "), svg.table_data(skrifa::Tag::new(b"SVG ")).unwrap().as_bytes());
|
||||
for r in looped.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, looped.table_data(r.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
let fonts = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(fonts.add_font(builder.build()), 1);
|
||||
let l = layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(Color::BLACK)));
|
||||
assert!(l.glyphs[0].colour.is_none() && !l.glyphs[0].outline.elements().is_empty());
|
||||
assert!(!fonts.is_svg_glyph(l.glyphs[0].font_id, l.glyphs[0].gid));
|
||||
}
|
||||
|
||||
/// A glyph in the text's stroke colour follows the stroke: added later, it shows.
|
||||
#[test]
|
||||
fn a_glyph_in_the_texts_stroke_follows_it() {
|
||||
let fonts = db(&document("<path fill=\"context-fill\" stroke=\"context-stroke\" d=\"M0 0h500v-500z\"/>"));
|
||||
let stroke_of = |stroke: Paint| {
|
||||
let st = CharStyle { stroke, stroke_width: 2.0, ..style(Color::BLACK) };
|
||||
let art = layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), st)).glyphs[0].colour.clone().unwrap();
|
||||
let mut strokes = vec![];
|
||||
art.walk(&mut |n| strokes.push(n.appearance.stroke_paint()));
|
||||
strokes
|
||||
};
|
||||
assert!(!stroke_of(Paint::None).iter().any(|p| *p != Paint::None), "no stroke at first");
|
||||
let red = Paint::solid(Color::rgb8(255, 0, 0));
|
||||
assert!(stroke_of(red.clone()).contains(&red), "the stroke added");
|
||||
}
|
||||
+7
-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