mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
Merge pull request #3561 from lehni/feature/emoji-bitmaps
Type: colour emoji fonts with bitmap glyphs draw and export
This commit is contained in:
25 files changed
+940
-61
No files matched your search
+4
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -128,8 +128,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
|
||||
- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
|
||||
|
||||
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
|
||||
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
|
||||
|
||||
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
||||
@@ -192,6 +193,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
|
||||
| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
|
||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_doc::{AreaFit, Document, ImageObject, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -559,6 +559,20 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
|
||||
continue;
|
||||
}
|
||||
// Colour bitmap glyphs (emoji) become embedded images, in place; glyphs that show
|
||||
// the same image share its pixels, under a key of their own if another image has
|
||||
// taken theirs.
|
||||
if let Some((image, to_text)) = g.placed_image() {
|
||||
let mut key = format!("glyph-{}", image.blob.content_key());
|
||||
if d.images.get(&key).is_some_and(|b| b.bytes != image.blob.bytes) {
|
||||
key = super::menucmds::unique_key(d, "glyph");
|
||||
}
|
||||
d.images.entry(key.clone()).or_insert_with(|| image.blob.clone());
|
||||
let im =
|
||||
ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() };
|
||||
children.push(Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))));
|
||||
continue;
|
||||
}
|
||||
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -850,6 +850,57 @@ fn create_outlines_matches_text_bounds() {
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image
|
||||
/// share its pixels.
|
||||
#[test]
|
||||
fn create_outlines_turns_bitmap_glyphs_into_images() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
|
||||
let mut s = session();
|
||||
let r = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F{COLOUR_CHAR}"), "size": 32, "font": colour_bitmap_family(false)}),
|
||||
)
|
||||
.unwrap();
|
||||
let t = NodeId(r["id"].as_u64().unwrap());
|
||||
let tb = bounds(&s, t);
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let ch = g.children().unwrap();
|
||||
let images: Vec<_> = ch.iter().filter_map(|c| if let NodeKind::Image(im) = &c.kind { Some(im) } else { None }).collect();
|
||||
assert_eq!(images.len(), 2, "{:?}", ch.iter().map(|c| c.kind_label()).collect::<Vec<_>>());
|
||||
assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the outline between them");
|
||||
assert_eq!(images[0].key, images[1].key, "one image");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.images.contains_key(&images[0].key));
|
||||
// The first one em (32 pt) square, from 2 px (4 pt) right of the origin and 12 px (24 pt)
|
||||
// above the baseline; inside the type's bounds.
|
||||
let b = ch[0].geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 14.0).abs() < 1e-6 && (b.y0 - 26.0).abs() < 1e-6 && (b.width() - 32.0).abs() < 1e-6, "{b:?}");
|
||||
assert!(b.x0 >= tb.x0 && b.y0 >= tb.y0 - 1e-6 && b.y1 <= tb.y1 + 1e-6, "{b:?} in {tb:?}");
|
||||
}
|
||||
|
||||
/// A glyph image whose key another image of the document holds gets a key of its own.
|
||||
#[test]
|
||||
fn create_outlines_keeps_an_image_already_under_a_glyph_images_key() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png};
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
|
||||
let mut s = session();
|
||||
let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE));
|
||||
let taken = format!("glyph-{}", glyph.content_key());
|
||||
let other = vectorcraft_doc::ImageBlob::png(solid_png(4, [0, 255, 0, 255]));
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(taken.clone(), other.clone());
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})).unwrap();
|
||||
let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let Some(NodeKind::Image(im)) = g.children().unwrap().first().map(|c| &c.kind) else { panic!("an image") };
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_ne!(im.key, taken);
|
||||
assert_eq!(d.images[&im.key].bytes, glyph.bytes, "the glyph's own pixels");
|
||||
assert_eq!(d.images[&taken].bytes, other.bytes, "the other image is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_style_size_changes_bounds() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -32,5 +32,6 @@ serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
# Tests: a Group 4 encoder and the T.4 run codes, to write CCITT test images.
|
||||
fax = "0.2"
|
||||
@@ -64,8 +64,8 @@ use std::sync::Arc;
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageObject, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin,
|
||||
Node, NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
|
||||
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node,
|
||||
NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
|
||||
@@ -678,7 +678,7 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
self.lock(locked);
|
||||
self.image(im);
|
||||
self.image(self.doc.images.get(&im.key), im.width, im.height, im.xf);
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
self.warn(LIVE);
|
||||
@@ -1288,6 +1288,12 @@ impl<'a> Writer<'a> {
|
||||
n.appearance = vectorcraft_doc::Appearance { items, ..vectorcraft_doc::Appearance::default() }.into();
|
||||
self.shape(&n, &path, FillRule::NonZero);
|
||||
}
|
||||
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
|
||||
for g in &layout.glyphs {
|
||||
if let Some((image, to_text)) = g.placed_image() {
|
||||
self.image(Some(&image.blob), image.width, image.height, t.xf * to_text);
|
||||
}
|
||||
}
|
||||
self.line("U");
|
||||
}
|
||||
|
||||
@@ -1298,14 +1304,14 @@ impl<'a> Writer<'a> {
|
||||
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
|
||||
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
|
||||
/// can leave out an image in a clipping group and the art after it. No path is open there, so
|
||||
/// the `F` paints nothing.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
/// the `F` paints nothing. `blob` holds the pixels, placed as a grid of `width` × `height`
|
||||
/// by `xf`.
|
||||
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * im.xf * flip;
|
||||
let m = self.to_art * xf * flip;
|
||||
if !invertible(m) {
|
||||
return;
|
||||
}
|
||||
let blob = self.doc.images.get(&im.key);
|
||||
if blob.is_some_and(|b| b.bytes.is_empty()) {
|
||||
self.warn(LARGE_IMAGE);
|
||||
return;
|
||||
@@ -1320,8 +1326,8 @@ impl<'a> Writer<'a> {
|
||||
return;
|
||||
}
|
||||
// The object places the image's own pixel grid (its width × height) wherever the file's is.
|
||||
let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h));
|
||||
let m = self.to_art * im.xf * fit * flip;
|
||||
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
|
||||
let m = self.to_art * xf * fit * flip;
|
||||
let rgba = img.to_rgba8();
|
||||
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
|
||||
hex.push('%');
|
||||
|
||||
+17
-12
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject,
|
||||
AppearanceItem, ColorMode, Document, Effect, ImageBlob, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject,
|
||||
};
|
||||
use vectorcraft_effects::stroke::{self, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
|
||||
@@ -288,7 +288,7 @@ impl<'a> Scene<'a> {
|
||||
self.shape(n, &bp, *rule);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Image(im) => self.image(im),
|
||||
NodeKind::Image(im) => self.image(&im.key, self.doc.images.get(&im.key), im.width, im.height, im.xf),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
@@ -586,6 +586,11 @@ impl<'a> Scene<'a> {
|
||||
let mut bp = BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
// Colour bitmap glyphs (emoji): their images, which the run's paint doesn't paint.
|
||||
if let Some((image, to_text)) = g.placed_image() {
|
||||
let key = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
|
||||
self.image(&key, Some(&image.blob), image.width, image.height, to_text);
|
||||
}
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
@@ -840,18 +845,18 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed image: its pixel grid stretched over its `width` × `height` (each image file
|
||||
/// encoded once).
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let place = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
/// A placed image (`blob`, cached under `key`): its pixel grid of `width` × `height` placed
|
||||
/// by `xf` (each image file encoded once).
|
||||
fn image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) {
|
||||
let place = xf * Affine::scale_non_uniform(f64::from(width.max(1)), f64::from(height.max(1)));
|
||||
if !invertible(place) {
|
||||
return;
|
||||
}
|
||||
let encoded = match self.images.get(&im.key) {
|
||||
let encoded = match self.images.get(key) {
|
||||
Some(e) => e.clone(),
|
||||
None => {
|
||||
let e = self.decode_image(&im.key).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new);
|
||||
self.images.insert(im.key.clone(), e.clone());
|
||||
let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new);
|
||||
self.images.insert(key.to_string(), e.clone());
|
||||
e
|
||||
}
|
||||
};
|
||||
@@ -860,9 +865,9 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written.
|
||||
fn decode_image(&mut self, key: &str) -> Option<image::RgbaImage> {
|
||||
let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
|
||||
/// Image `blob` as RGBA pixels; `None` (with a warning) when it can't be written.
|
||||
fn decode_image(&mut self, blob: Option<&ImageBlob>) -> Option<image::RgbaImage> {
|
||||
let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else {
|
||||
self.warn(UNDECODED);
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -499,3 +499,22 @@ fn a_rich_document_is_well_formed_at_both_levels() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour bitmap glyph (emoji) is written as an image where it sits; the type's fill doesn't
|
||||
/// paint it.
|
||||
#[test]
|
||||
fn colour_bitmap_glyphs_are_images() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
|
||||
let d = doc_with(|d| {
|
||||
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() };
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 100.0), &COLOUR_CHAR.to_string(), st))))]
|
||||
});
|
||||
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
|
||||
checked_page(&ps);
|
||||
assert!(ps.contains("/Width 16 /Height 16"), "{ps}");
|
||||
// One em (32 pt) square, its top 24 pt above the baseline (12 of 16 px), 4 pt right of the
|
||||
// origin (2 px), in text space.
|
||||
assert!(ps.contains("[32 0 0 32 4 -24] cm"), "{ps}");
|
||||
}
|
||||
@@ -92,20 +92,33 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
|
||||
/// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted
|
||||
/// in their first stop's color.
|
||||
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
|
||||
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
|
||||
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type), and
|
||||
// its colour bitmap glyphs as images (their pixels added to `images`).
|
||||
fn outlined(t: &vectorcraft_doc::TextObject, images: &mut std::collections::BTreeMap<String, ImageBlob>) -> Node {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let runs = t.runs.iter().enumerate().map(|(i, run)| {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
let path = PathData::from_bezpath(&bp).transformed(t.xf);
|
||||
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
|
||||
});
|
||||
Node::group(vectorcraft_doc::NodeId(0), runs.collect())
|
||||
let mut nodes: Vec<Arc<Node>> = t
|
||||
.runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, run)| {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
let path = PathData::from_bezpath(&bp).transformed(t.xf);
|
||||
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
|
||||
})
|
||||
.collect();
|
||||
for g in &layout.glyphs {
|
||||
let Some((image, to_text)) = g.placed_image() else { continue };
|
||||
let key = image.blob.content_key();
|
||||
images.insert(key.clone(), image.blob.clone());
|
||||
let im = ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() };
|
||||
nodes.push(Arc::new(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im)))));
|
||||
}
|
||||
Node::group(vectorcraft_doc::NodeId(0), nodes)
|
||||
}
|
||||
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions) {
|
||||
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap<String, ImageBlob>) {
|
||||
nodes.retain(|n| match &n.kind {
|
||||
NodeKind::Text(t) => o.outline_text || !writable(t),
|
||||
NodeKind::Layer { printable, .. } => *printable,
|
||||
@@ -113,7 +126,7 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
});
|
||||
for n in nodes {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
*n = Arc::new(outlined(t));
|
||||
*n = Arc::new(outlined(t, images));
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if o.hidden == HiddenObjects::Visible && !matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
@@ -129,12 +142,14 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
}
|
||||
}
|
||||
if let Some(c) = n.children_mut() {
|
||||
plain(c, o);
|
||||
plain(c, o, images);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
plain(&mut d.layers, o);
|
||||
let mut images = std::mem::take(&mut d.images);
|
||||
plain(&mut d.layers, o, &mut images);
|
||||
d.images = images;
|
||||
d
|
||||
}
|
||||
|
||||
@@ -1141,3 +1156,22 @@ fn layer_names_outside_latin_1_are_replaced_and_warned() {
|
||||
// A name Latin-1 can hold is written as it is, with no warning.
|
||||
assert!(!layered("Caf\u{e9}").warnings.iter().any(|w| w == LAYER_NAME));
|
||||
}
|
||||
|
||||
/// Type written as outlines writes its colour bitmap glyphs (emoji) as images where they sit.
|
||||
#[test]
|
||||
fn colour_bitmap_glyphs_of_outlined_type_are_images() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() };
|
||||
// Cyrillic, which 8-bit text can't hold: the type is written as outlines.
|
||||
let t = TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), &format!("{COLOUR_CHAR}\u{416}"), st);
|
||||
let l = doc.layers[0].id;
|
||||
let id = doc.alloc_id();
|
||||
doc.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 1, "{text}");
|
||||
assert!(images[0][2].ends_with(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI"), "{:?}", images[0]);
|
||||
}
|
||||
@@ -1571,12 +1571,15 @@ impl Exporter<'_> {
|
||||
}
|
||||
bp.extend(run_bars.iter());
|
||||
let fill = &run.style.fill;
|
||||
// Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves.
|
||||
let mut images: Option<Vec<usize>> = None;
|
||||
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
|
||||
s.push_transform(&text_space);
|
||||
// The outlines left to fill as paths: all of them, or those real text leaves.
|
||||
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) {
|
||||
Some(text) => {
|
||||
let mut left = ex.glyph_text(s, &layout, i, text, fill);
|
||||
let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true);
|
||||
images = Some(unwritten);
|
||||
left.extend(run_bars.iter());
|
||||
Cow::Owned(left)
|
||||
}
|
||||
@@ -1593,6 +1596,13 @@ impl Exporter<'_> {
|
||||
}
|
||||
s.pop();
|
||||
});
|
||||
// They paint themselves, whatever the fill.
|
||||
let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect());
|
||||
s.push_transform(&text_space);
|
||||
for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) {
|
||||
self.glyph_image(s, g);
|
||||
}
|
||||
s.pop();
|
||||
// Characters with neither fill nor stroke (a scanned page's recognized text), or whose
|
||||
// fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text),
|
||||
// are still text: written invisible (rendering mode 3), so they can still be selected
|
||||
@@ -1603,8 +1613,8 @@ impl Exporter<'_> {
|
||||
crate::forms::form(s, Mark::Invisible, |s| {
|
||||
s.push_transform(&text_space);
|
||||
// The paint only lets the writer write the text; the glyphs it can't write
|
||||
// as text draw nothing.
|
||||
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)));
|
||||
// as text draw nothing, and colour bitmap glyphs are drawn already.
|
||||
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false);
|
||||
s.pop();
|
||||
});
|
||||
}
|
||||
@@ -1669,16 +1679,21 @@ impl Exporter<'_> {
|
||||
/// text space; `text` is the plain text, whose characters the glyphs stand for, which makes
|
||||
/// the text selectable and searchable) → the outlines of the glyphs left to draw as paths:
|
||||
/// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame
|
||||
/// (size, scale, rotation) and a baseline are written together.
|
||||
fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint) -> BezPath {
|
||||
let mut rest = BezPath::new();
|
||||
/// (size, scale, rotation) and a baseline are written together. Colour bitmap glyphs (emoji),
|
||||
/// with `images`, are written as text too, which draws their images; the indices of those it
|
||||
/// can't write are returned with the outlines.
|
||||
fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint, images: bool) -> (BezPath, Vec<usize>) {
|
||||
let (mut rest, mut unwritten) = (BezPath::new(), vec![]);
|
||||
let mut line: Option<GlyphLine> = None;
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == run) {
|
||||
for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
|
||||
if g.image.is_some() && !images {
|
||||
continue;
|
||||
}
|
||||
let range = g.byte..g.byte + g.len;
|
||||
let ch = text.get(range.clone()).and_then(|t| t.chars().next());
|
||||
// Spaces are written too (text extraction needs them); control characters and soft
|
||||
// hyphens draw nothing.
|
||||
let blank = g.outline.elements().is_empty();
|
||||
let blank = g.outline.elements().is_empty() && g.image.is_none();
|
||||
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
|
||||
continue;
|
||||
}
|
||||
@@ -1690,6 +1705,9 @@ impl Exporter<'_> {
|
||||
let det = g.xf.determinant();
|
||||
let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else {
|
||||
rest.extend(g.outline.iter());
|
||||
if g.image.is_some() {
|
||||
unwritten.push(gi);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let size = upem * sy;
|
||||
@@ -1719,7 +1737,28 @@ impl Exporter<'_> {
|
||||
if let Some(l) = line {
|
||||
self.draw_glyph_line(s, l, text, fill, layout.bounds);
|
||||
}
|
||||
rest
|
||||
(rest, unwritten)
|
||||
}
|
||||
|
||||
/// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once.
|
||||
fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) {
|
||||
let Some((image, to_text)) = g.placed_image() else { return };
|
||||
if !writable(s, to_text, image.rect()) {
|
||||
return;
|
||||
}
|
||||
let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
|
||||
let img = match self.images.get(&cache) {
|
||||
Some(img) => img.clone(),
|
||||
None => {
|
||||
let img = embed(&image.blob, self.interpolate, self.sixteen_bit);
|
||||
self.images.insert(cache, img.clone());
|
||||
img
|
||||
}
|
||||
};
|
||||
let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return };
|
||||
s.push_transform(&xf(to_text));
|
||||
s.draw_image(img, size);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where
|
||||
|
||||
@@ -509,6 +509,30 @@ fn text_exported_as_outlines() {
|
||||
assert!(b.x0 >= 9.0 && b.x1 < 120.0 && b.y1 <= 56.0 && b.y0 > 25.0, "{b:?}");
|
||||
}
|
||||
|
||||
/// Colour bitmap glyphs (emoji) are images in the PDF: in their font's Type 3 glyphs with real
|
||||
/// text, else drawn where the glyphs are.
|
||||
#[test]
|
||||
fn colour_bitmap_glyphs_export_as_images() {
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
|
||||
for sbix in [true, false] {
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap());
|
||||
let mut d = doc(300.0, 100.0);
|
||||
let st = CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 24.0, ..CharStyle::default() };
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), st)))));
|
||||
let text = uncompressed(&d).replace(' ', "");
|
||||
assert!(text.contains("/Type3") && text.contains("/Subtype/Image"), "an image in a Type 3 glyph (sbix {sbix})");
|
||||
let mut outlined = PdfOptions::uncompressed();
|
||||
outlined.settings.advanced.outline_text = true;
|
||||
let r = import_with_report(&export(&d, &outlined).unwrap(), &ImportOptions::default()).unwrap();
|
||||
let l = leaves(&r.document);
|
||||
let images: Vec<_> = l.iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).collect();
|
||||
assert_eq!(images.len(), 1, "outlined, the glyph is an image (sbix {sbix})");
|
||||
let b = images[0].geometric_bounds().unwrap();
|
||||
assert!((b.width() - 24.0).abs() < 0.1 && (b.height() - 24.0).abs() < 0.1, "one em: {b:?}");
|
||||
assert!(l.iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "the other glyph stays an outline");
|
||||
}
|
||||
}
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let mut img = image::RgbaImage::new(4, 2);
|
||||
for (x, _, p) in img.enumerate_pixels_mut() {
|
||||
|
||||
@@ -1811,6 +1811,12 @@ impl Renderer {
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
ctx.set_paint(peniko::Color::BLACK);
|
||||
ctx.stroke_path(&p);
|
||||
// Colour bitmap glyphs: their boxes, as images show in outline mode.
|
||||
for (_, image, to_text) in &g.images {
|
||||
let mut b = image.rect().to_path(0.1);
|
||||
b.apply_affine(t.xf * *to_text);
|
||||
self.hairline(ctx, f, &b, [0, 0, 0, 255]);
|
||||
}
|
||||
self.draw_inlines(ctx, f, t, g);
|
||||
return;
|
||||
}
|
||||
@@ -1852,6 +1858,11 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
// Colour bitmap glyphs paint themselves: the run's fill and stroke don't.
|
||||
for (_, image, to_text) in g.images.iter().filter(|(r, ..)| *r == i) {
|
||||
overprint(ctx, false);
|
||||
self.draw_glyph_image(ctx, f, image, t.xf * *to_text);
|
||||
}
|
||||
let path = g.run(i);
|
||||
let path = path.as_ref();
|
||||
if path.elements().is_empty() {
|
||||
@@ -1908,6 +1919,26 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw colour bitmap glyph `image` through `xf` (its pixels → document space), its pixels
|
||||
/// decoded once and kept with the images' pixels.
|
||||
fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) {
|
||||
let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return };
|
||||
let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true);
|
||||
let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64);
|
||||
let pm = image_mip(&key, pm, unit);
|
||||
let sx = image.width as f64 / pm.width().max(1) as f64;
|
||||
let sy = image.height as f64 / pm.height().max(1) as f64;
|
||||
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler { quality, ..Default::default() },
|
||||
});
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
|
||||
ctx.fill_rect(&image.rect());
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
|
||||
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
|
||||
self.draw_image_in(ctx, f, im, None);
|
||||
}
|
||||
@@ -2336,6 +2367,8 @@ struct TextGeom {
|
||||
alternate_glyphs: std::sync::OnceLock<BezPath>,
|
||||
/// Inline graphics placed by the layout.
|
||||
inlines: Vec<vectorcraft_text::InlineGlyph>,
|
||||
/// Colour bitmap glyphs: (run, image, its pixels → text space), in text order.
|
||||
images: Vec<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>,
|
||||
}
|
||||
|
||||
impl TextGeom {
|
||||
@@ -2384,6 +2417,7 @@ impl TextGeom {
|
||||
+ self.alternate_glyphs.get().map_or(0, lru::path_bytes)
|
||||
+ self.spans.len() * size_of::<(usize, usize, usize)>()
|
||||
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
|
||||
+ self.images.len() * size_of::<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2464,8 +2498,12 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||||
let plain = t.plain_text();
|
||||
let mut images = vec![];
|
||||
for g in &layout.glyphs {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
if let Some((image, xf)) = g.placed_image() {
|
||||
images.push((g.run, image.clone(), xf));
|
||||
}
|
||||
let Some(&(missing, face)) = faces.get(g.run) else { continue };
|
||||
let Some(line) = layout.lines.get(g.line) else { continue };
|
||||
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
|
||||
@@ -2504,6 +2542,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
alternate_candidates,
|
||||
alternate_glyphs: std::sync::OnceLock::new(),
|
||||
inlines: layout.inlines,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2565,6 +2604,8 @@ mod tests_adjust;
|
||||
#[cfg(test)]
|
||||
mod tests_alternate;
|
||||
#[cfg(test)]
|
||||
mod tests_bitmapglyphs;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_changed;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Colour bitmap glyphs (emoji): drawn as their images, which the type's fill doesn't paint.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, colour_bitmap_family, colour_bitmap_font};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 100 pt [`COLOUR_CHAR`] in the colour bitmap test font (`sbix` or `CBDT`), filled blue, its
|
||||
/// baseline at (20, 120), on a white 160×160 page.
|
||||
fn render(sbix: bool, opts: RenderOptions) -> Rendered {
|
||||
let _adding = crate::tests_fontchange::adding_fonts();
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap());
|
||||
let mut d = Document::new(160.0, 160.0);
|
||||
let st = CharStyle {
|
||||
font_family: colour_bitmap_family(sbix).into(),
|
||||
size: 100.0,
|
||||
fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st))));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bitmap_glyph_draws_its_largest_strike_not_the_fill() {
|
||||
for sbix in [true, false] {
|
||||
let img = render(sbix, RenderOptions::default());
|
||||
// Inside the em square the image covers (the CBDT one from 75 above the baseline to 25
|
||||
// below, the sbix one from 87.2 above to 12.8 below), away from its edges.
|
||||
assert_eq!(img.pixel(70, 80), COLOUR_LARGE, "sbix {sbix}");
|
||||
let blue = (0..160).flat_map(|y| (0..160).map(move |x| (x, y))).any(|(x, y)| img.pixel(x, y)[2] > 200 && img.pixel(x, y)[0] < 50);
|
||||
assert!(!blue, "the fill doesn't paint the glyph (sbix {sbix})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_mode_draws_a_bitmap_glyph_as_its_box() {
|
||||
let img = render(false, RenderOptions { outline: true, ..Default::default() });
|
||||
assert_eq!(img.pixel(70, 80), [255, 255, 255, 255], "no pixels inside");
|
||||
// The box's left edge at x 32.5 (the bearing of 2 px of 6.25 pt), from y 45 to 145.
|
||||
assert!((31..=34).any(|x| img.pixel(x, 100)[0] < 128), "the box's edge shows");
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pub(crate) fn fonts_steady() -> RwLockReadGuard<'static, ()> {
|
||||
}
|
||||
|
||||
/// No test holding [`fonts_steady`] runs until the guard is dropped.
|
||||
fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
|
||||
pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
|
||||
FONTS.write().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
|
||||
@@ -1897,6 +1897,17 @@ impl Writer<'_> {
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
|
||||
for (image, to_text) in lay.glyphs.iter().filter_map(|g| g.placed_image()) {
|
||||
let m = self.matrix(self.xf * to_text);
|
||||
let href = self.blob_href(&image.blob);
|
||||
self.line(&format!(
|
||||
"<image width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
|
||||
image.width,
|
||||
image.height,
|
||||
xml_escape(&href)
|
||||
));
|
||||
}
|
||||
self.xf = saved;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
@@ -2090,6 +2101,11 @@ impl Writer<'_> {
|
||||
let mut face: Option<Arc<FontFace>> = None;
|
||||
for g in &lay.glyphs {
|
||||
let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue };
|
||||
// A colour bitmap font's subset would hold no bitmaps: its emoji are left to the viewer.
|
||||
if g.image.is_some() {
|
||||
self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts");
|
||||
continue;
|
||||
}
|
||||
if face.as_ref().is_none_or(|f| f.id() != g.font_id) {
|
||||
face = db.face_by_id(g.font_id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Colour bitmap glyphs (emoji): outlined text writes their images; live text leaves them to the
|
||||
//! viewer, and embedded fonts don't carry them.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_svg::{ExportOptions, export_full};
|
||||
use vectorcraft_testkit::format::base64_decode;
|
||||
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
|
||||
|
||||
/// [`COLOUR_CHAR`] and an outline glyph in the `CBDT` test font, 24 pt.
|
||||
fn doc() -> Document {
|
||||
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 24.0, ..CharStyle::default() };
|
||||
let mut n =
|
||||
Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), st))));
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlined_text_writes_bitmap_glyphs_as_images() {
|
||||
let svg = export_full(&doc(), &ExportOptions { outline_text: true, ..Default::default() }, None).svg;
|
||||
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
|
||||
assert!(svg.contains("width=\"16\" height=\"16\"") && svg.contains("xlink:href=\"data:image/png;base64,"), "{svg}");
|
||||
assert!(svg.contains("<path"), "the other glyph is an outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() {
|
||||
let out = export_full(&doc(), &ExportOptions { embed_fonts: true, ..Default::default() }, None);
|
||||
let data = out.svg.split("base64,").nth(1).unwrap().split(')').next().unwrap();
|
||||
let font = base64_decode(data).unwrap();
|
||||
let cmap = skrifa::FontRef::new(&font).unwrap().cmap().unwrap();
|
||||
assert!(cmap.map_codepoint('F').is_some());
|
||||
assert!(cmap.map_codepoint(COLOUR_CHAR).is_none(), "its subset would hold no bitmap");
|
||||
assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings);
|
||||
assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Colour bitmap glyphs (`sbix`, as in Apple Color Emoji, and `CBDT`/`CBLC`, as in Noto Color
|
||||
//! Emoji): the PNG of a glyph's largest strike, placed in font units. Other graphic types (`sbix`
|
||||
//! `dupe`, `flip`, `jpg `, `tiff`; `CBDT` BGRA and mask data) and `EBDT` (hinting bitmaps of
|
||||
//! outline fonts) draw nothing.
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::bitmap::{BitmapData, BitmapFormat, BitmapStrikes, Origin};
|
||||
use skrifa::instance::Size;
|
||||
use vectorcraft_doc::ImageBlob;
|
||||
|
||||
/// The most pixels along either side of a glyph image read from a font, as its PNG header gives
|
||||
/// them. Emoji strikes are at most a few hundred pixels (Apple Color Emoji's largest is 160): a
|
||||
/// font is untrusted input, and each glyph image is decoded and kept apart (at most 4 MB of pixels).
|
||||
pub(crate) const MAX_SIDE: u32 = 1024;
|
||||
|
||||
/// The most bytes of a glyph image's PNG: twice its largest pixels' raw size, which a PNG
|
||||
/// (compressed, or stored uncompressed) stays well within.
|
||||
pub(crate) const MAX_PNG_BYTES: usize = 2 * 4 * (MAX_SIDE as usize) * (MAX_SIDE as usize);
|
||||
|
||||
/// A colour bitmap glyph: a PNG drawn in place of an outline. Fill and stroke don't paint it.
|
||||
#[derive(Debug)]
|
||||
pub struct GlyphImage {
|
||||
/// The PNG, as image objects hold their pixels.
|
||||
pub blob: ImageBlob,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Pixel space (0..width, 0..height, y down) → font units (y down, as outlines are).
|
||||
pub xf: Affine,
|
||||
}
|
||||
|
||||
impl GlyphImage {
|
||||
/// The image's pixel rectangle (0..width, 0..height).
|
||||
pub fn rect(&self) -> Rect {
|
||||
Rect::new(0.0, 0.0, self.width as f64, self.height as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `font` have colour bitmap strikes?
|
||||
pub(crate) fn has_colour_bitmaps(font: &skrifa::FontRef<'_>) -> bool {
|
||||
strikes(font).is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn strikes<'a>(font: &skrifa::FontRef<'a>) -> Option<BitmapStrikes<'a>> {
|
||||
BitmapStrikes::with_format(font, BitmapFormat::Sbix).or_else(|| BitmapStrikes::with_format(font, BitmapFormat::Cbdt))
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s image in its largest strike, if it is a PNG.
|
||||
pub(crate) fn glyph_image(font: &skrifa::FontRef<'_>, upem: f64, gid: u32) -> Option<GlyphImage> {
|
||||
let strikes = strikes(font)?;
|
||||
let sbix = strikes.format() == Some(BitmapFormat::Sbix);
|
||||
let g = strikes.glyph_for_size(Size::unscaled(), GlyphId::new(gid))?;
|
||||
let BitmapData::Png(png) = g.data else { return None };
|
||||
// The size the PNG decodes to is its header's (`CBDT`'s own metrics may say otherwise).
|
||||
let (w, h) = png_size(png)?;
|
||||
let (ppem_x, ppem_y) = (f64::from(g.ppem_x), f64::from(g.ppem_y));
|
||||
if png.len() > MAX_PNG_BYTES || w > MAX_SIDE || h > MAX_SIDE || !(ppem_x >= 1.0 && ppem_y >= 1.0 && ppem_x.is_finite() && ppem_y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (upem / ppem_x, upem / ppem_y);
|
||||
// The image's top left in font units (y up): the outer bearings in font units, the inner ones
|
||||
// in pixels, from the bottom left corner for `sbix`.
|
||||
let left = f64::from(g.bearing_x) + f64::from(g.inner_bearing_x) * sx;
|
||||
let mut top = f64::from(g.bearing_y) + f64::from(g.inner_bearing_y) * sy;
|
||||
if g.placement_origin == Origin::BottomLeft {
|
||||
top += h as f64 * sy;
|
||||
}
|
||||
// Apple Color Emoji sits lower than its `sbix` table says (CoreText's look, which the PDF
|
||||
// writer's Type 3 glyphs imitate too).
|
||||
if sbix {
|
||||
top -= 0.128 * upem;
|
||||
}
|
||||
if !(left.is_finite() && top.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(GlyphImage { blob: ImageBlob::png(png.to_vec()), width: w, height: h, xf: Affine::new([sx, 0.0, 0.0, sy, left, -top]) })
|
||||
}
|
||||
|
||||
/// The width and height in PNG `bytes`' header (its signature, then the IHDR chunk); `None` when
|
||||
/// it doesn't read or a side is 0.
|
||||
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.get(..8)? != b"\x89PNG\r\n\x1a\n" || bytes.get(12..16)? != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let be = |at: usize| Some(u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?));
|
||||
let (w, h) = (be(16)?, be(20)?);
|
||||
(w > 0 && h > 0).then_some((w, h))
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider, Tag};
|
||||
|
||||
use crate::GlyphImage;
|
||||
|
||||
/// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue).
|
||||
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
||||
|
||||
@@ -132,6 +134,8 @@ pub struct FontFace {
|
||||
ideographic_centre: std::sync::OnceLock<f64>,
|
||||
/// [`Self::icf_margins`], read once.
|
||||
icf_margins: std::sync::OnceLock<IcfMargins>,
|
||||
/// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]).
|
||||
colour_bitmaps: bool,
|
||||
}
|
||||
|
||||
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
|
||||
@@ -410,10 +414,37 @@ type PostScriptNames = HashMap<String, (String, String)>;
|
||||
/// are neither a loaded nor an installed family.
|
||||
type CanonicalNames = HashMap<(String, String), (String, String)>;
|
||||
|
||||
/// The glyph images [`FontDb::glyph_image`] keeps by face and glyph, and their PNGs' bytes in all.
|
||||
#[derive(Default)]
|
||||
struct GlyphImages {
|
||||
images: HashMap<(u32, u32), Option<Arc<GlyphImage>>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl GlyphImages {
|
||||
/// Keeps `image` (or that there is none), first letting go of all it keeps when that would
|
||||
/// take more than [`IMAGE_CACHE_MAX`] images or [`IMAGE_CACHE_BYTES`]. A glyph it keeps
|
||||
/// already, which another thread looked up meanwhile, stays as it is.
|
||||
fn keep(&mut self, key: (u32, u32), image: Option<Arc<GlyphImage>>) {
|
||||
if self.images.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let bytes = image.as_ref().map_or(0, |i| i.blob.bytes.len());
|
||||
if self.images.len() >= IMAGE_CACHE_MAX || self.bytes.saturating_add(bytes) > IMAGE_CACHE_BYTES {
|
||||
self.images.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
self.bytes += bytes;
|
||||
self.images.insert(key, image);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
/// [`FontDb::glyph_image`]'s answers per (face, glyph), misses too.
|
||||
images: Mutex<GlyphImages>,
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
@@ -550,6 +581,10 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// The most glyph images [`FontDb::glyph_image`] keeps (each a PNG of some 10–60 KB).
|
||||
const IMAGE_CACHE_MAX: usize = 4_096;
|
||||
/// The most PNG bytes it keeps: a font can make each one up to [`crate::bitmap::MAX_PNG_BYTES`].
|
||||
const IMAGE_CACHE_BYTES: usize = 64 << 20;
|
||||
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
|
||||
/// them before it keeps the next one.
|
||||
const CANONICAL_CACHE_MAX: usize = 4_096;
|
||||
@@ -1026,10 +1061,11 @@ fn font_files(path: &Path) -> Vec<Vec<u8>> {
|
||||
std::fs::read(path).into_iter().collect()
|
||||
}
|
||||
|
||||
/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with
|
||||
/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing.
|
||||
/// The tables holding glyphs the font engine draws: TrueType, CFF, CFF2 and VARC outlines, and
|
||||
/// `sbix` and `CBDT` colour bitmaps. A face with none of them (outlines in Apple's `hvgl` table,
|
||||
/// monochrome bitmaps only) draws nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 6] = [b"glyf", b"CFF ", b"CFF2", b"VARC", b"sbix", b"CBDT"];
|
||||
|
||||
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
|
||||
/// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of
|
||||
@@ -1517,6 +1553,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
};
|
||||
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
|
||||
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
|
||||
let colour_bitmaps = crate::bitmap::has_colour_bitmaps(&f);
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
@@ -1543,6 +1580,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
path,
|
||||
ideographic_centre: std::sync::OnceLock::new(),
|
||||
icf_margins: std::sync::OnceLock::new(),
|
||||
colour_bitmaps,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1646,6 +1684,7 @@ impl FontDb {
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
images: Mutex::new(GlyphImages::default()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_paths,
|
||||
@@ -2414,6 +2453,20 @@ impl FontDb {
|
||||
cache.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s colour bitmap (`sbix`, `CBDT`) in `face`, cached per (face, glyph).
|
||||
pub fn glyph_image(&self, face: &FontFace, gid: u32) -> Option<Arc<GlyphImage>> {
|
||||
if !face.colour_bitmaps {
|
||||
return None;
|
||||
}
|
||||
let key = (face.id, gid);
|
||||
if let Some(i) = self.images.lock().unwrap_or_else(|e| e.into_inner()).images.get(&key) {
|
||||
return i.clone();
|
||||
}
|
||||
let image = face.skrifa().and_then(|f| crate::bitmap::glyph_image(&f, face.upem, gid)).map(Arc::new);
|
||||
self.images.lock().unwrap_or_else(|e| e.into_inner()).keep(key, image.clone());
|
||||
image
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
@@ -2575,3 +2628,29 @@ mod race_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod image_cache_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_glyph_image_cache_keeps_its_bytes_within_its_budget() {
|
||||
// Images as large as a font may make them: no test font has enough of those to fill the
|
||||
// budget, so they're made here.
|
||||
let image = || {
|
||||
let blob = vectorcraft_doc::ImageBlob::png(vec![0; crate::bitmap::MAX_PNG_BYTES]);
|
||||
Some(Arc::new(GlyphImage { blob, width: 1, height: 1, xf: kurbo::Affine::IDENTITY }))
|
||||
};
|
||||
let mut cache = GlyphImages::default();
|
||||
for gid in 0..20 {
|
||||
cache.keep((1, gid), image());
|
||||
assert!(cache.bytes <= IMAGE_CACHE_BYTES, "{} bytes after {} images", cache.bytes, gid + 1);
|
||||
}
|
||||
// The same glyph again, as when two threads look it up at once, counts once.
|
||||
cache.keep((1, 19), image());
|
||||
assert_eq!(cache.bytes, cache.images.values().flatten().map(|i| i.blob.bytes.len()).sum::<usize>());
|
||||
// A glyph without an image costs nothing, and is still kept.
|
||||
cache.keep((1, 99), None);
|
||||
assert!(cache.images.contains_key(&(1, 99)));
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,11 @@ impl Ctx<'_> {
|
||||
}
|
||||
|
||||
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
|
||||
let src = if g.inline.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) };
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let blank = g.is_soft_hyphen() || g.ch.is_control();
|
||||
// A colour bitmap glyph draws its image, not its outline.
|
||||
let image = if g.inline.is_some() || blank { None } else { self.db.glyph_image(&g.face, g.gid) };
|
||||
let src = if g.inline.is_some() || image.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) };
|
||||
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
|
||||
// upright one in vertical type by moving it up the column once it stands upright.
|
||||
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
|
||||
@@ -140,8 +144,7 @@ impl Ctx<'_> {
|
||||
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)))
|
||||
* m;
|
||||
}
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
|
||||
let outline = if src.elements().is_empty() || blank {
|
||||
BezPath::new()
|
||||
} else {
|
||||
let mut p = BezPath::with_capacity(src.elements().len());
|
||||
@@ -169,6 +172,7 @@ impl Ctx<'_> {
|
||||
// Vertical type keeps its logical order down the column.
|
||||
rtl: g.level.is_rtl() && !self.vertical,
|
||||
scale: 1.0,
|
||||
image,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -723,6 +727,9 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
if !g.outline.elements().is_empty() {
|
||||
add(g.outline.bounding_box());
|
||||
}
|
||||
if let Some((image, xf)) = g.placed_image() {
|
||||
add(xf.transform_rect_bbox(image.rect()));
|
||||
}
|
||||
}
|
||||
// Ruby: covered by its line's ascent, but its ink can overhang sideways past the line.
|
||||
for g in &out.ruby {
|
||||
@@ -2935,5 +2942,7 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
|
||||
xf: Affine::IDENTITY,
|
||||
rtl: false,
|
||||
scale: 1.0,
|
||||
// Ruby draws outlines only.
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bitmap;
|
||||
mod composer;
|
||||
pub mod craft_fonts;
|
||||
pub mod edit;
|
||||
@@ -28,6 +29,7 @@ mod suitcase;
|
||||
pub mod test_fonts;
|
||||
pub mod thread;
|
||||
|
||||
pub use bitmap::GlyphImage;
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
|
||||
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use fontdb::{
|
||||
@@ -35,6 +37,8 @@ pub use fontdb::{
|
||||
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
@@ -118,6 +122,16 @@ pub struct PositionedGlyph {
|
||||
/// (Justification…), Desired and justification together: 1 at 100%. Horizontal Scale isn't
|
||||
/// included.
|
||||
pub scale: f64,
|
||||
/// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is
|
||||
/// empty.
|
||||
pub image: Option<Arc<GlyphImage>>,
|
||||
}
|
||||
|
||||
impl PositionedGlyph {
|
||||
/// The glyph's colour bitmap, and the transform from its pixels to text space.
|
||||
pub fn placed_image(&self) -> Option<(&Arc<GlyphImage>, Affine)> {
|
||||
self.image.as_ref().map(|i| (i, self.xf * i.xf))
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of laid-out text.
|
||||
@@ -596,6 +610,8 @@ mod tests_axes;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_bitmaps;
|
||||
#[cfg(test)]
|
||||
mod tests_combos;
|
||||
#[cfg(test)]
|
||||
mod tests_devanagari;
|
||||
|
||||
@@ -407,3 +407,181 @@ pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The character whose glyph [`colour_bitmap_font`] draws as a colour bitmap.
|
||||
pub const COLOUR_CHAR: char = 'E';
|
||||
|
||||
/// The colour of [`colour_bitmap_font`]'s bitmap in its largest strike (RGBA); the `sbix` font's
|
||||
/// smaller strike is [`COLOUR_SMALL`].
|
||||
pub const COLOUR_LARGE: [u8; 4] = [255, 0, 0, 255];
|
||||
|
||||
/// The colour of the `sbix` font's smaller strike.
|
||||
pub const COLOUR_SMALL: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
/// The pixels per em of [`colour_bitmap_font`]'s largest strike: its square bitmap is one em wide.
|
||||
pub const COLOUR_PPEM: u8 = 16;
|
||||
|
||||
/// Where [`colour_bitmap_font`]`(false)`'s (`CBDT`) bitmap sits, in pixels: its left edge right of
|
||||
/// the glyph's origin, its top edge above the baseline.
|
||||
pub const COLOUR_CBDT_BEARINGS: (i8, i8) = (2, 12);
|
||||
|
||||
/// The family of [`colour_bitmap_font`] (`sbix`, or `CBDT`).
|
||||
pub fn colour_bitmap_family(sbix: bool) -> &'static str {
|
||||
if sbix { "Sbixtest Emoji" } else { "Cbdttest Emoji" }
|
||||
}
|
||||
|
||||
/// The bundled Source Sans 3 Regular as family [`colour_bitmap_family`], [`COLOUR_CHAR`] a solid
|
||||
/// square colour bitmap one em wide: in `sbix` strikes of [`COLOUR_PPEM`] ([`COLOUR_LARGE`]) and
|
||||
/// half that ([`COLOUR_SMALL`]), each with no origin offset, or (`sbix` false) in one `CBDT`
|
||||
/// strike of [`COLOUR_PPEM`] ([`COLOUR_LARGE`], bearings [`COLOUR_CBDT_BEARINGS`]). Its outline
|
||||
/// stays too. Tables written from the OpenType specification. `None` when it can't be built.
|
||||
pub fn colour_bitmap_font(sbix: bool) -> Option<Vec<u8>> {
|
||||
colour_bitmap_font_with(sbix, &solid_png(COLOUR_PPEM.into(), COLOUR_LARGE))
|
||||
}
|
||||
|
||||
/// [`colour_bitmap_font`] with `png` as the bitmap of its largest strike (a malformed one, say).
|
||||
pub fn colour_bitmap_font_with(sbix: bool, png: &[u8]) -> Option<Vec<u8>> {
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let glyphs = font.maxp().ok()?.num_glyphs();
|
||||
let gid = u16::try_from(font.charmap().map(COLOUR_CHAR)?.to_u32()).ok()?;
|
||||
let family = colour_bitmap_family(sbix);
|
||||
let ps = family.replace(' ', "");
|
||||
let name = name_table(&[(1, family), (2, "Regular"), (4, &format!("{family} Regular")), (6, &format!("{ps}-Regular"))])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
if sbix {
|
||||
let small = solid_png((COLOUR_PPEM / 2).into(), COLOUR_SMALL);
|
||||
builder.add_raw(Tag::new(b"sbix"), sbix_table(glyphs, gid, &[(COLOUR_PPEM / 2, &small), (COLOUR_PPEM, png)]));
|
||||
} else {
|
||||
let (cblc, cbdt) = cbdt_tables(gid, png);
|
||||
builder.add_raw(Tag::new(b"CBLC"), cblc);
|
||||
builder.add_raw(Tag::new(b"CBDT"), cbdt);
|
||||
}
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// An `sbix` table of `strikes` (ppem, PNG), each holding a PNG for glyph `gid` alone.
|
||||
fn sbix_table(glyphs: u16, gid: u16, strikes: &[(u8, &[u8])]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(1u16.to_be_bytes()); // version
|
||||
out.extend(1u16.to_be_bytes()); // flags
|
||||
out.extend((strikes.len() as u32).to_be_bytes());
|
||||
let header = 8 + 4 * strikes.len();
|
||||
let mut data = Vec::new();
|
||||
for (ppem, png) in strikes {
|
||||
out.extend(((header + data.len()) as u32).to_be_bytes());
|
||||
let offsets = 4 + 4 * (usize::from(glyphs) + 1);
|
||||
let mut strike = Vec::new();
|
||||
strike.extend(u16::from(*ppem).to_be_bytes());
|
||||
strike.extend(72u16.to_be_bytes()); // ppi
|
||||
// Glyph data offsets: empty but for `gid`'s (origin offsets 0, `png `, the PNG).
|
||||
let record = 8 + png.len();
|
||||
for g in 0..=glyphs {
|
||||
let at = offsets + if g > gid { record } else { 0 };
|
||||
strike.extend((at as u32).to_be_bytes());
|
||||
}
|
||||
strike.extend([0, 0, 0, 0]);
|
||||
strike.extend(b"png ");
|
||||
strike.extend(*png);
|
||||
data.extend(strike);
|
||||
}
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// `CBLC` and `CBDT` tables of one strike of [`COLOUR_PPEM`] holding `png` for glyph `gid`
|
||||
/// (index subtable format 1, glyph data format 17).
|
||||
fn cbdt_tables(gid: u16, png: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let ppem = COLOUR_PPEM;
|
||||
let mut cbdt = Vec::new();
|
||||
cbdt.extend(3u16.to_be_bytes());
|
||||
cbdt.extend(0u16.to_be_bytes());
|
||||
// Small glyph metrics: height, width, bearing x, bearing y, advance.
|
||||
let (bx, by) = COLOUR_CBDT_BEARINGS;
|
||||
cbdt.extend([ppem, ppem, bx as u8, by as u8, ppem]);
|
||||
cbdt.extend((png.len() as u32).to_be_bytes());
|
||||
cbdt.extend(png);
|
||||
let mut cblc = Vec::new();
|
||||
cblc.extend(3u16.to_be_bytes());
|
||||
cblc.extend(0u16.to_be_bytes());
|
||||
cblc.extend(1u32.to_be_bytes()); // numSizes
|
||||
let array = 8 + 48;
|
||||
let subtable = 8;
|
||||
let index_size = subtable + 8 + 2 * 4;
|
||||
cblc.extend((array as u32).to_be_bytes());
|
||||
cblc.extend((index_size as u32).to_be_bytes());
|
||||
cblc.extend(1u32.to_be_bytes()); // numberOfIndexSubTables
|
||||
cblc.extend(0u32.to_be_bytes()); // colorRef
|
||||
// Horizontal and vertical line metrics.
|
||||
let line = [ppem as i8, 0, ppem as i8, 1, 0, 0, 0, 0, 0, 0, 0, 0].map(|v| v as u8);
|
||||
cblc.extend(line);
|
||||
cblc.extend(line);
|
||||
cblc.extend(gid.to_be_bytes());
|
||||
cblc.extend(gid.to_be_bytes());
|
||||
cblc.extend([ppem, ppem, 32, 1]); // ppem x, ppem y, bit depth, flags (horizontal)
|
||||
// The index subtable array, then the subtable: format 1, image format 17, its data at 4.
|
||||
cblc.extend(gid.to_be_bytes());
|
||||
cblc.extend(gid.to_be_bytes());
|
||||
cblc.extend((subtable as u32).to_be_bytes());
|
||||
cblc.extend(1u16.to_be_bytes());
|
||||
cblc.extend(17u16.to_be_bytes());
|
||||
cblc.extend(4u32.to_be_bytes());
|
||||
cblc.extend(0u32.to_be_bytes());
|
||||
cblc.extend(((cbdt.len() - 4) as u32).to_be_bytes());
|
||||
(cblc, cbdt)
|
||||
}
|
||||
|
||||
/// A `side`×`side` PNG of one colour (RGBA), written uncompressed (stored deflate blocks).
|
||||
pub fn solid_png(side: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in bytes {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
let mut body = kind.to_vec();
|
||||
body.extend(data);
|
||||
out.extend(&body);
|
||||
out.extend(crc32(&body).to_be_bytes());
|
||||
}
|
||||
// Each row: filter type 0 (none), then its pixels.
|
||||
let row: Vec<u8> = std::iter::once(0).chain(rgba.repeat(side as usize)).collect();
|
||||
let raw = row.repeat(side as usize);
|
||||
let (mut a, mut b) = (1u32, 0u32);
|
||||
for v in &raw {
|
||||
a = (a + u32::from(*v)) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
let mut zlib = vec![0x78, 0x01];
|
||||
let blocks: Vec<&[u8]> = raw.chunks(0xffff).collect();
|
||||
for (i, block) in blocks.iter().enumerate() {
|
||||
zlib.push(u8::from(i + 1 == blocks.len()));
|
||||
let n = block.len() as u16;
|
||||
zlib.extend(n.to_le_bytes());
|
||||
zlib.extend((!n).to_le_bytes());
|
||||
zlib.extend(*block);
|
||||
}
|
||||
zlib.extend(((b << 16) | a).to_be_bytes());
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend([8, 6, 0, 0, 0]); // 8 bits, RGBA
|
||||
chunk(&mut out, b"IHDR", &ihdr);
|
||||
chunk(&mut out, b"IDAT", &zlib);
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
@@ -160,6 +160,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
|
||||
xf: Affine::IDENTITY,
|
||||
rtl,
|
||||
scale: 1.0,
|
||||
image: None,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Colour bitmap glyphs (`sbix`, `CBDT`): laid out as images in place of outlines, in the box
|
||||
//! their strike gives, moving with the type.
|
||||
|
||||
use kurbo::{Point, Rect};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use super::test_fonts::{COLOUR_CBDT_BEARINGS, COLOUR_CHAR, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font};
|
||||
use super::*;
|
||||
|
||||
fn db(sbix: bool) -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(colour_bitmap_font(sbix).expect("the test font builds")), 1);
|
||||
db
|
||||
}
|
||||
|
||||
fn style(sbix: bool) -> CharStyle {
|
||||
CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 100.0, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
/// The text-space box of glyph `g`'s image.
|
||||
fn image_box(g: &PositionedGlyph) -> Rect {
|
||||
let (image, xf) = g.placed_image().expect("the glyph is an image");
|
||||
xf.transform_rect_bbox(image.rect())
|
||||
}
|
||||
|
||||
fn assert_near(a: Rect, b: Rect) {
|
||||
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1];
|
||||
assert!(d.iter().all(|d| d.abs() < 1e-6), "{a:?} is not {b:?}");
|
||||
}
|
||||
|
||||
/// The left side bearing of [`COLOUR_CHAR`] in the bundled Source Sans 3 (font units of 1000).
|
||||
fn lsb() -> f64 {
|
||||
let font = skrifa::FontRef::new(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
||||
let gid = font.charmap().map(COLOUR_CHAR).unwrap();
|
||||
f64::from(font.glyph_metrics(Size::unscaled(), LocationRef::default()).left_side_bearing(gid).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_sbix_glyph_is_its_largest_strike_one_em_wide_sitting_on_the_shifted_baseline() {
|
||||
let db = db(true);
|
||||
let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(true)));
|
||||
let g = &l.glyphs[0];
|
||||
assert!(g.outline.elements().is_empty(), "the image replaces the outline");
|
||||
let image = g.image.as_ref().unwrap();
|
||||
assert_eq!((image.width, image.height), (u32::from(COLOUR_PPEM), u32::from(COLOUR_PPEM)), "the largest strike");
|
||||
// From the glyph's left side bearing, its bottom 0.128 em below the baseline.
|
||||
let x = g.origin.x + lsb() * 0.1;
|
||||
assert_near(image_box(g), Rect::new(x, g.origin.y - 87.2, x + 100.0, g.origin.y + 12.8));
|
||||
assert!(l.bounds.contains_rect(image_box(g)), "the bounds hold the image: {:?}", l.bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cbdt_glyph_sits_where_its_bearings_put_it() {
|
||||
let db = db(false);
|
||||
let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(false)));
|
||||
let g = &l.glyphs[0];
|
||||
assert!(g.outline.elements().is_empty());
|
||||
let (bx, by) = COLOUR_CBDT_BEARINGS;
|
||||
let px = 100.0 / f64::from(COLOUR_PPEM);
|
||||
let (x, top) = (g.origin.x + f64::from(bx) * px, g.origin.y - f64::from(by) * px);
|
||||
assert_near(image_box(g), Rect::new(x, top, x + 100.0, top + 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bitmap_glyph_scales_with_the_type() {
|
||||
let db = db(false);
|
||||
let st = CharStyle { h_scale: 200.0, ..style(false) };
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), st));
|
||||
let b = image_box(&l.glyphs[0]);
|
||||
assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bitmap_glyph_moves_when_type_snaps_to_pixels() {
|
||||
let db = db(false);
|
||||
let mut l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(false)));
|
||||
let (before, origin) = (image_box(&l.glyphs[0]), l.glyphs[0].origin);
|
||||
assert!(l.snap_to_pixels(Affine::translate((0.3, 0.4))));
|
||||
let g = &l.glyphs[0];
|
||||
assert!(g.origin != origin);
|
||||
assert_near(image_box(g), before + (g.origin - origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_glyphs_of_a_colour_font_keep_their_outlines() {
|
||||
let db = db(true);
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, "F", style(true)));
|
||||
assert!(l.glyphs[0].image.is_none());
|
||||
assert!(!l.glyphs[0].outline.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A strike PNG past the caps, or whose header doesn't read, gives the glyph no image: it is not
|
||||
/// decoded (a font is untrusted input).
|
||||
#[test]
|
||||
fn a_bitmap_past_the_caps_or_without_a_header_is_no_image() {
|
||||
use super::bitmap::{MAX_PNG_BYTES, MAX_SIDE};
|
||||
use super::test_fonts::{COLOUR_LARGE, colour_bitmap_font_with, solid_png};
|
||||
let good = solid_png(COLOUR_PPEM.into(), COLOUR_LARGE);
|
||||
// A header claiming a side past the cap (the IHDR width).
|
||||
let mut wide = good.clone();
|
||||
wide[16..20].copy_from_slice(&(MAX_SIDE + 1).to_be_bytes());
|
||||
// More bytes than a glyph's PNG may have.
|
||||
let mut long = good.clone();
|
||||
long.resize(MAX_PNG_BYTES + 1, 0);
|
||||
let headless = good[8..].to_vec();
|
||||
for sbix in [true, false] {
|
||||
for (what, png) in [("wide", &wide), ("long", &long), ("headless", &headless), ("good", &good)] {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(colour_bitmap_font_with(sbix, png).unwrap()), 1);
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(sbix)));
|
||||
assert_eq!(l.glyphs[0].image.is_some(), what == "good", "{what} (sbix {sbix})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour bitmap font without outline tables (Noto Color Emoji has `CBDT` but no `glyf`) is an
|
||||
/// installed font like any other.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_bitmap_only_font_file_is_cataloged() {
|
||||
use crate::fontdb::file_face_names_within;
|
||||
let full = colour_bitmap_font(false).unwrap();
|
||||
let font = skrifa::FontRef::new(&full).unwrap();
|
||||
let mut builder = write_fonts::FontBuilder::new();
|
||||
for r in font.table_directory.table_records() {
|
||||
if ![*b"glyf", *b"loca"].contains(&r.tag().to_be_bytes()) {
|
||||
builder.add_raw(write_fonts::types::Tag::new(&r.tag().to_be_bytes()), font.table_data(r.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("vc-bitmaps-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Bitmaps.ttf");
|
||||
std::fs::write(&file, builder.build()).unwrap();
|
||||
let faces = file_face_names_within(&file, 4, u64::MAX);
|
||||
assert_eq!(faces.len(), 1, "the face is cataloged");
|
||||
}
|
||||
|
||||
/// The system's colour emoji font, when installed: an emoji draws as an image, and a ZWJ sequence
|
||||
/// as one glyph.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_installed_colour_emoji_font_draws_emoji_as_images() {
|
||||
let db = FontDb::with_font_dirs(system_font_dirs());
|
||||
let Some(family) = ["Apple Color Emoji", "Noto Color Emoji"].into_iter().find(|f| db.has_family(f)) else {
|
||||
eprintln!("no colour emoji font installed: skipped");
|
||||
return;
|
||||
};
|
||||
let st = CharStyle { font_family: family.into(), size: 40.0, ..CharStyle::default() };
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F600}", st.clone()));
|
||||
assert!(l.glyphs[0].image.is_some(), "{family} draws 😀 as an image");
|
||||
let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F469}\u{200D}\u{1F4BB}", st));
|
||||
assert_eq!(l.glyphs.iter().filter(|g| g.image.is_some()).count(), 1, "the sequence is one glyph");
|
||||
}
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; ArtboLine truncated
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -241,7 +241,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
- **Done:** Ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above or below (right of the
|
||||
column in vertical type), aligned, sized, overhanging or squeezed; saved natively, laid out with line growth, drawn
|
||||
on the canvas and in SVG/PDF (outlines where ruby is present); estimate unchanged.
|
||||
- **Missing:** CJK vertical
|
||||
- **Done:** colour emoji fonts with bitmap glyphs (`sbix`, as Apple Color Emoji; `CBDT`/`CBLC`, as Noto Color Emoji):
|
||||
each glyph is its largest strike's PNG, placed and transformed with the type, its fill and stroke ignored; PDF writes
|
||||
it in the font's Type 3 glyphs (or as an image), SVG as an image when type is outlined (live text leaves emoji to the
|
||||
viewer, and their fonts aren't embedded), EPS and legacy `.ai` as images, and Create Outlines makes embedded images.
|
||||
Fonts with bitmaps but no outlines (Noto Color Emoji) are now found among the installed fonts. Limits: `sbix`
|
||||
duplicate, flipped, JPEG and TIFF glyphs and `CBDT` BGRA and mask data draw nothing, nor do emoji in ruby or in
|
||||
enveloped or warped type; estimate unchanged.
|
||||
- **Missing:** colour fonts with COLR or OpenType SVG glyphs; CJK vertical
|
||||
composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern
|
||||
features beyond bidi and digit types (Character Direction, kashidas, composers), variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
|
||||
@@ -437,6 +444,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. |
|
||||
| 2026-10-22 | minor | G13: gradient Dither (30.0) in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports; G13 estimate 15–25 h → 14–24 h. |
|
||||
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). |
|
||||
| 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (imported PDF type keeps a word space between two sizes (#3623); G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged; #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; boLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images, see [type-parity.md](type-parity.md); scores unchanged; imported PDF type keeps a word space between two sizes (#3623); G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged; #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocatioLine truncated
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -102,7 +102,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 46/50, depth estimated; Warp Options controls verified by focused checks and original-image review |
|
||||
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
|
||||
| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; scores and hours unchanged |
|
||||
| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; scores and hours unchanged |
|
||||
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
|
||||
| Raster effects (Effect Gallery) | 4 | 49% | the Effect Gallery dialog | 4–6 | measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged |
|
||||
@@ -448,6 +448,7 @@ since 2026-10-01.
|
||||
- **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes.
|
||||
- **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure.
|
||||
- **Artboard clean active-tab close (2026-10-15):** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. Nine targeted checks passed: three existing tab regressions, one clean close/Delete regression and five existing unsaved-document controls. The close regression checks the correct surviving board, preserved artwork/symbols and object selection, affected guides, one Delete edit, saved state and Undo/Redo. Three reviewed original full-app headless captures show the active tab before closing, the surviving document's shown board after closing, and the correct survivor after Properties Delete, using the actual tab × and Delete controls.
|
||||
- **Colour emoji with bitmap glyphs:** `sbix` (Apple Color Emoji) and `CBDT` (Noto Color Emoji) glyphs draw as their largest strike's PNG, placed with the type and unpainted by its fill; PDF writes them in Type 3 glyphs or as images, SVG as images with outlined text, EPS and legacy `.ai` as images, and Create Outlines makes embedded images. Synthetic test fonts in `crates/text/src/test_fonts.rs`; tests `crates/text/src/tests_bitmaps.rs`, `crates/render/src/tests_bitmapglyphs.rs`, `crates/svg/tests/colour_glyphs.rs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, the `eps` `colour_bitmap_glyphs_*` tests and `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`. COLR and OpenType SVG fonts remain.
|
||||
- **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html).
|
||||
- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides and attributes the new definition leaves out stay. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (#3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); 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), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: PropoLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); 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), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; Line truncated
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -33,7 +33,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
|
||||
| Variable fonts | partial | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Variable filter is missing |
|
||||
| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic |
|
||||
| Colour fonts (OpenType SVG / COLR) | partial | |
|
||||
| Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, ruby, envelopes or warps. COLR and OpenType SVG missing. Evidence: `tests_bitmaps`, `render::tests_bitmapglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `svg/tests/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images` |
|
||||
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
|
||||
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang |
|
||||
| System Layout | done | Character panel menu › System Layout, `type.systemLayout`: the whole document, off by default and for documents from before it, saved only when on. On, every text object advances by whole points (at least one when a character takes space). New point, area and path type, threaded frames, placed text, graph and dimension labels and pasted plain text inherit it. Evidence: `tests::system_layout_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::system_layout_sets_the_document_and_every_text_object`, `panels::character::tests::system_layout_menu_item_toggles_the_document` |
|
||||
@@ -92,6 +92,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
| 2026-10-13 | minor | The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); scores and hours unchanged |
|
||||
| 2026-10-13 | minor | Character Styles range capture samples the first selected character at run boundaries and closes prior typing; boundary and typing-history input tests and full workspace CI pass; lifecycle and range controls retained; scores and hours unchanged |
|
||||
| 2026-10-13 | minor | The commands that make or restyle type, and MCP `add_text`, report area type that does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471); a tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458); scores and hours unchanged |
|
||||
| 2026-10-22 | minor | Colour fonts: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged |
|
||||
| 2026-10-13 | minor | Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444); text frames, wrap shapes and type paths with coordinates beyond a billion points hold no text, and layout limits its search for room, instead of hanging (#1451); scores and hours unchanged |
|
||||
| 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged |
|
||||
| 2026-10-12 | minor | A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992); scores and hours unchanged |
|
||||
|
||||
Reference in new issue
Block a user