EPS import: composite fonts draw their Type 42 descendants' glyphs

A composite (Type 0) font with the 8/8 mapping picks a descendant font
and a code in it with each pair of bytes (PLRM 3rd ed., 5.10). The reader
set its strings as type in a font of the composite font's name, the
bytes read as Latin-1 and at a thousand times the size, so EPS from
pdftops of a PDF with embedded TrueType fonts showed nothing, or one
black shape, where its type was.

Characters now go to their descendants: a Type 42 font's glyphs are drawn
from the TrueType outlines its sfnts strings carry (Adobe Technical Note
5012), by the glyph index CharStrings gives their names, spaced by their
own widths or the show operator's numbers; charpath and stringwidth read
them too. A Type 3 descendant runs its procedures, and a descendant found
by name sets its character as type at the composite font's size.
This commit is contained in:
Don Omondi
2026-11-09 02:24:54 +07:00
committed by bflatastic
parent b142814bbd
commit 3bb43b2fe6
11 changed files with 490 additions and 26 deletions
Generated
+1
View File
@@ -6338,6 +6338,7 @@ dependencies = [
"memchr",
"ruzstd",
"serde_json",
"skrifa 0.47.0",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+47
View File
@@ -2381,6 +2381,53 @@ proptest! {
);
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
/// Damaged or hostile TrueType data in a Type 42 font that a composite font shows (its table
/// directory and its glyphs), any glyph index: drawn, measured and outlined, or left out.
#[test]
fn eps_type42_glyphs_never_panic(
head in prop::collection::vec((0usize..2048, any::<u8>()), 0..12),
body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::<u8>()), 0..12),
gid in 0u32..70_000,
) {
let mut font = TYPE42_FONT.to_vec();
for (at, b) in head.into_iter().chain(body) {
if let Some(x) = font.get_mut(at) {
*x = b;
}
}
let text = type42_composite_eps(&font, gid);
survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
}
/// The TrueType font [`type42_composite_eps`] carries.
const TYPE42_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
/// An EPS showing glyph `gid` of TrueType font `font` through a Type 42 font and a composite font
/// over it, and measuring and outlining it.
fn type42_composite_eps(font: &[u8], gid: u32) -> String {
let sfnts: String = font.chunks(32_000).map(|c| format!("<{}>\n", c.iter().map(|b| format!("{b:02x}")).collect::<String>())).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put\n\
/CharStrings 2 dict def CharStrings begin /.notdef 0 def /c01 {gid} def end /sfnts [\n{sfnts}] def FontName currentdict end definefont pop\n\
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def /Encoding [0] def\n\
/FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n\
/Comp findfont 40 scalefont setfont 10 20 moveto (\\000\\001\\000\\001) show (\\000\\001) stringwidth pop pop\n\
10 60 moveto (\\000\\001) true charpath fill\nshowpage\n%%EOF\n"
)
}
/// [`type42_composite_eps`] as it is: the glyph draws as filled outlines, not as type.
#[test]
fn the_type42_fixture_draws_its_glyphs() {
let r = vectorcraft_eps::import(type42_composite_eps(TYPE42_FONT, 9).as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let mut kinds = vec![];
r.document.walk(|n| kinds.push(n.kind_label()));
assert!(!kinds.contains(&"Text") && kinds.iter().filter(|k| **k == "Path").count() >= 3, "{kinds:?}");
}
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
+2
View File
@@ -17,6 +17,8 @@ vectorcraft-effects = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
# The glyph outlines of Type 42 fonts a composite font shows.
skrifa = { workspace = true }
# Windows metafile previews, opened when a file's PostScript can't be read.
vectorcraft-metafile = { workspace = true }
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
+3
View File
@@ -44,10 +44,13 @@ mod obj;
mod preview;
mod shading;
mod text;
mod type42;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_composite;
#[cfg(test)]
mod tests_fontnames;
#[cfg(test)]
mod tests_generators;
+1 -1
View File
@@ -42,7 +42,7 @@ const MAX_DASH_ENTRIES: usize = 65_535;
/// Most entries an indexed colour space's table has.
const MAX_HIVAL: usize = 4095;
/// Most elements the current path may have.
const MAX_PATH: usize = 1 << 20;
pub(crate) const MAX_PATH: usize = 1 << 20;
/// Most path elements drawn in all (each object's clips and groups counting as elements too).
const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
+1 -1
View File
@@ -193,7 +193,7 @@ impl Interp<'_> {
// ---------- painting ----------
pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
pub(in super::super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
if bp.elements().is_empty() || self.g.null {
return Ok(());
}
+4
View File
@@ -73,6 +73,9 @@ pub(crate) struct Interp<'a> {
pub fault: Option<Fault>,
/// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`).
pub glyph_width: Option<[f64; 2]>,
/// The TrueType data of the Type 42 fonts drawn so far, by their `sfnts` array (see
/// `type42`), the most recent last.
pub type42: Vec<(Shared<Obj>, Rc<Vec<u8>>)>,
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
@@ -189,6 +192,7 @@ impl<'a> Interp<'a> {
journal: vec![],
fault: None,
glyph_width: None,
type42: vec![],
g,
saved: vec![],
out,
+104
View File
@@ -0,0 +1,104 @@
//! Composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write a PDF's
//! two-byte fonts: each character a byte picking the descendant font and a byte for the code in
//! it (PLRM 3rd ed., §5.10). Type 42 descendants draw their glyphs from the TrueType font they
//! carry (Adobe Technical Note #5012); the fonts here are written from scratch around the bundled
//! Source Sans 3.
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::NodeKind;
use super::tests::{all, bounds, fill, read};
const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
/// The glyph index of `c` in [`FONT`] and its advance width in ems.
fn glyph(c: char) -> (u32, f64) {
let font = FontRef::new(FONT).unwrap();
let gid = font.charmap().map(c).unwrap();
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
let adv = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(gid).unwrap();
(gid.to_u32(), f64::from(adv) / upem)
}
/// `sfnts` strings of [`FONT`]: the first of odd length, ending in a padding byte that isn't part
/// of the font, the rest split at even lengths.
fn sfnts() -> String {
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
let mut out = format!("<{}00>\n", hex(&FONT[..20000]));
for chunk in FONT[20000..].chunks(40000) {
out.push_str(&format!("<{}>\n", hex(chunk)));
}
out
}
/// A program defining Type 42 font `/T42` (codes 1 and 2 are H and I, by glyph names that
/// don't say which characters they are) and composite font `/Comp` over it.
fn fonts() -> String {
let (h, _) = glyph('H');
let (i, _) = glyph('I');
format!(
"10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put Encoding 2 /c02 put\n\
/CharStrings 3 dict def CharStrings begin /.notdef 0 def /c01 {h} def /c02 {i} def end\n\
/sfnts [\n{}] def FontName currentdict end definefont pop\n\
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
/Encoding [0] def /FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n",
sfnts()
)
}
#[test]
fn type42_descendants_draw_their_own_glyphs() {
let (_, wh) = glyph('H');
let (_, wi) = glyph('I');
let r = read(&format!("{}/Comp findfont 50 scalefont setfont 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\002) show", fonts()));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let nodes = all(&r.document);
assert!(!nodes.iter().any(|n| matches!(n.kind, NodeKind::Text(_))));
let paths: Vec<_> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert_eq!(paths.len(), 2);
for p in &paths {
assert_eq!(fill(p), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
}
// The H, then the I one H's width along; both stand on the baseline, a cap height tall.
let (bh, bi) = (bounds(paths[0]), bounds(paths[1]));
assert!((bh.x0 - 10.0).abs() < 8.0 && bh.width() > 20.0 && bh.width() < 50.0 * wh, "{bh:?}");
assert!((bh.height() - bi.height()).abs() < 0.5 && bh.height() > 25.0 && bh.height() < 40.0, "{bh:?} {bi:?}");
assert!(bi.x0 > 10.0 + 50.0 * wh - 1.0 && bi.x1 < 10.0 + 50.0 * (wh + wi), "{bi:?}");
}
#[test]
fn type42_descendants_measure_space_and_outline_their_glyphs() {
let (_, wh) = glyph('H');
let (_, wi) = glyph('I');
let check = |cond: String| super::tests::check(&format!("{} /Comp findfont 50 scalefont setfont {cond}", fonts()));
// The width its font gives each glyph.
check(format!("(\\000\\001\\000\\002) stringwidth pop {} sub abs 0.01 lt", 50.0 * (wh + wi)));
// xyshow places each character where its numbers say.
check("gsave nulldevice 10 20 moveto (\\000\\001\\000\\002) [30 0 7 0] xyshow currentpoint grestore pop 47 sub abs 0.01 lt".into());
// charpath outlines them.
check("newpath 10 20 moveto (\\000\\001) true charpath pathbbox exch pop sub abs 25 gt exch 10 sub abs 8 lt and".into());
}
/// A descendant that is a font by name only (its program isn't run) sets its characters as type,
/// at the size the composite font's matrix gives them, not at 1000 times it.
#[test]
fn named_descendants_set_type_at_the_composite_fonts_size() {
let r = read(
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
/Encoding [0] def /FDepVector [/Helvetica findfont] def FontName currentdict end definefont pop\n\
/Comp findfont 30 scalefont setfont 10 20 moveto (\\000A\\000B) show",
);
let sizes: Vec<f64> = all(&r.document)
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(t.runs.iter().map(|r| r.style.size * t.xf.determinant().abs().sqrt()).sum::<f64>() / t.runs.len() as f64),
_ => None,
})
.collect();
assert!(!sizes.is_empty() && sizes.iter().all(|s| (s - 30.0).abs() < 0.5), "{sizes:?}");
let bounds_all = all(&r.document).iter().map(|n| bounds(n)).reduce(|a, b| a.union(b)).unwrap();
assert!(bounds_all.width() < 60.0, "{bounds_all:?}");
}
+249 -22
View File
@@ -168,9 +168,13 @@ impl Interp<'_> {
ResourceForAll => self.resource_for_all()?,
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
let (s, spacing) = self.show_operands(op)?;
match self.type3_font() {
Some(font) => self.show_type3(&font, &s, &spacing)?,
None => self.show(&s)?,
if let Some(glyphs) = self.composite_glyphs(&s) {
self.show_composite(&glyphs, &spacing)?;
} else {
match self.type3_font() {
Some(font) => self.show_type3(&font, &s, &spacing)?,
None => self.show(&s)?,
}
}
}
CShow => {
@@ -273,6 +277,13 @@ impl Interp<'_> {
/// How far `s` moves the current point (user space).
fn width_of(&mut self, s: &[u8]) -> Res<Vec2> {
if let Some(glyphs) = self.composite_glyphs(s) {
let mut w = Vec2::ZERO;
for g in &glyphs {
w += self.composite_glyph(g, Point::new(w.x, w.y), Draw::No)?;
}
return Ok(w);
}
let Some(font) = self.type3_font() else { return Ok(self.set_type(s, Point::ZERO)?.2) };
let mut w = Vec2::ZERO;
for &code in s {
@@ -287,19 +298,7 @@ impl Interp<'_> {
for (i, &code) in s.iter().enumerate() {
let at = self.user_point()?;
let w = self.type3_glyph(font, Glyph::Code(code), at, true)?;
let d = match spacing {
Spacing::Plain | Spacing::Kern(_) => w,
Spacing::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == i64::from(code)).map_or(Vec2::ZERO, |(_, v)| v),
Spacing::Widths(v, op) => {
let n = |k: usize| v.get(k).copied();
match op {
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
}
.unwrap_or(w)
}
};
let d = spacing.advance(i, i64::from(code), w);
self.g.cur = Some(self.xf() * (at + d));
if let (Spacing::Kern(p), Some(next)) = (spacing, s.get(i + 1)) {
self.push(Obj::Int(i64::from(code)))?;
@@ -315,10 +314,15 @@ impl Interp<'_> {
/// Glyph `glyph` of Type 3 font `font` drawn by its `BuildGlyph` (or `BuildChar`) procedure at
/// user point `at` (only measured unless `paint`) → its width (user space).
fn type3_glyph(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool) -> Res<Vec2> {
self.type3_glyph_in(font, glyph, at, paint, Affine::IDENTITY)
}
/// [`Self::type3_glyph`] for a descendant of a composite font whose matrix is `outer`.
fn type3_glyph_in(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool, outer: Affine) -> Res<Vec2> {
let (fm, build_glyph, build_char, encoding) = {
let f = font.borrow();
let get = |k: &str| f.get(&Key::name(k)).cloned();
let fm = get("FontMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
let fm = outer * font_matrix(&f, 0.001);
(fm, get("BuildGlyph"), get("BuildChar"), get("Encoding"))
};
let code_of = |name: &str| -> Option<u8> {
@@ -402,11 +406,16 @@ impl Interp<'_> {
};
let f = font.borrow();
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
// (its FontMatrix, usually the identity, is already the em's).
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
Ok((name, m * Affine::scale(units)))
// A composite font's glyphs are its descendants', in their glyph space under its matrix
// (PLRM 3rd ed., §5.10 "Composite Fonts"): the first descendant stands for them all.
if font_type(&f) == Some(0.0) {
let first = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0));
if let Some(Obj::Dict(d)) = first {
let d = d.borrow();
return Ok((name, font_matrix(&f, 1.0) * font_matrix(&d, 0.001) * Affine::scale(units(&d))));
}
}
Ok((name, font_matrix(&f, 0.001) * Affine::scale(units(&f))))
}
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
@@ -451,6 +460,14 @@ impl Interp<'_> {
/// `charpath`: the glyph outlines of `s` added to the path.
fn char_path(&mut self, s: &[u8]) -> Res {
if let Some(glyphs) = self.composite_glyphs(s) {
for g in &glyphs {
let at = self.user_point()?;
let w = self.composite_glyph(g, at, Draw::Path)?;
self.g.cur = Some(self.xf() * (at + w));
}
return Ok(());
}
let at = self.user_point()?;
let (t, layout, adv) = self.set_type(s, at)?;
for g in &layout.glyphs {
@@ -484,6 +501,216 @@ enum Glyph {
Name(Rc<str>),
}
impl Spacing {
/// How far glyph `i` (character code `code`, width `w`) moves the current point.
fn advance(&self, i: usize, code: i64, w: Vec2) -> Vec2 {
match self {
Self::Plain | Self::Kern(_) => w,
Self::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == code).map_or(Vec2::ZERO, |(_, v)| v),
Self::Widths(v, op) => {
let n = |k: usize| v.get(k).copied();
match op {
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
}
.unwrap_or(w)
}
}
}
}
/// A character of a composite font: the descendant font that draws it, its code there, and the
/// composite font's matrix (over the descendant's own).
struct Mapped {
font: DictRef,
code: u8,
outer: Affine,
}
/// What drawing a glyph does with it.
#[derive(Clone, Copy, PartialEq)]
enum Draw {
/// Fills it (`show`).
Fill,
/// Adds its outline to the current path (`charpath`).
Path,
/// Only measures it (`stringwidth`).
No,
}
/// Font `f`'s `FontType`.
fn font_type(f: &Dict) -> Option<f64> {
f.get(&Key::name("FontType")).and_then(Obj::as_num)
}
/// Font `f`'s matrix, `default` times the identity when it has none.
fn font_matrix(f: &Dict, default: f64) -> Affine {
f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(default))
}
/// Font `f`'s glyph units in its matrix's space: Type 1 glyphs are in 1000-unit space; a Type 42
/// font's glyph space is one unit an em (its FontMatrix, usually the identity, is already the
/// em's).
fn units(f: &Dict) -> f64 {
if font_type(f) == Some(42.0) { 1.0 } else { 1000.0 }
}
impl Interp<'_> {
/// The characters of `s` in the current font when it is a composite font with the 8/8 mapping
/// (`FMapType` 2: a byte picking the descendant through `Encoding` and `FDepVector`, then the
/// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries"); `None` for other fonts and
/// mappings, which are set as type in the font of the composite font's name.
fn composite_glyphs(&self, s: &[u8]) -> Option<Vec<Mapped>> {
let font = self.g.font.clone()?;
let f = font.borrow();
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(2.0) {
return None;
}
let encoding = f.get(&Key::name("Encoding"))?.items()?.to_vec();
let fonts = f.get(&Key::name("FDepVector"))?.items()?.to_vec();
let outer = font_matrix(&f, 1.0);
let mut out = Vec::with_capacity(s.len() / 2);
for &[pick, code] in s.as_chunks::<2>().0 {
let index = encoding.get(usize::from(pick)).and_then(Obj::as_num).filter(|i| *i >= 0.0 && i.fract() == 0.0);
// A character whose descendant is missing (or not a base font) shows nothing.
if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize))
&& font_type(&d.borrow()) != Some(0.0)
{
out.push(Mapped { font: d.clone(), code, outer });
}
}
Some(out)
}
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res {
let start = self.out.drawn.len();
let mut text = String::new();
for (i, g) in glyphs.iter().enumerate() {
let at = self.user_point()?;
let w = self.composite_glyph(g, at, Draw::Fill)?;
let d = spacing.advance(i, i64::from(g.code), w);
self.g.cur = Some(self.xf() * (at + d));
let c = code_char(&g.font.borrow(), g.code);
if c != '\u{2022}' {
text.push(c);
}
}
if text.trim().is_empty()
&& let Some(name) = self.g.font.as_ref().and_then(|f| f.borrow().get(&Key::name("FontName")).and_then(Obj::text))
{
text = name.to_string();
}
self.group_glyphs(start, &text);
Ok(())
}
/// Character `g` of a composite font at user point `at`, drawn by its descendant: a Type 3
/// font's procedure, a Type 42 font's TrueType outline, else as type in the font of the
/// descendant's name (the character its glyph name stands for) → its width (user space).
fn composite_glyph(&mut self, g: &Mapped, at: Point, paint: Draw) -> Res<Vec2> {
let builds = {
let f = g.font.borrow();
font_type(&f) == Some(3.0) && (f.contains_key(&Key::name("BuildGlyph")) || f.contains_key(&Key::name("BuildChar")))
};
if builds {
return self.type3_glyph_in(&g.font, Glyph::Code(g.code), at, paint == Draw::Fill, g.outer);
}
let fm = g.outer * font_matrix(&g.font.borrow(), 1.0);
if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code)? {
if paint != Draw::No {
self.spend((outline.elements().len() as u64).saturating_add(16))?;
let mut p = outline;
p.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm);
if paint == Draw::Fill {
self.fill_path(p, vectorcraft_geom::FillRule::NonZero)?;
} else {
if self.g.path.elements().len().saturating_add(p.elements().len()) > super::graphics::MAX_PATH {
return Err(PsError::Limit("a path has too many points"));
}
self.g.path.extend(p);
}
}
return Ok(fm * Point::new(advance, 0.0) - fm * Point::ZERO);
}
// Set as type in the descendant's font, under the composite font's matrix.
let c = code_char(&g.font.borrow(), g.code);
let Ok(byte) = u8::try_from(u32::from(c)) else { return Ok(Vec2::ZERO) };
let saved = self.g.font.replace(transformed(&g.font, g.outer));
let r = match paint {
Draw::Fill => {
self.g.cur = Some(self.xf() * at);
self.show(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
}
Draw::Path => {
self.g.cur = Some(self.xf() * at);
self.char_path(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
}
Draw::No => self.set_type(&[byte], at).map(|t| t.2),
};
self.g.font = saved;
r
}
/// Glyph `code` of Type 42 font `font` from the TrueType font it carries: its outline and
/// width in its glyph space (ems), or `None` when it isn't a Type 42 font with one.
fn type42_glyph(&mut self, font: &DictRef, code: u8) -> Res<Option<(vectorcraft_geom::BezPath, f64)>> {
let (sfnts, gid) = {
let f = font.borrow();
if font_type(&f) != Some(42.0) {
return Ok(None);
}
let Some(Obj::Array { items: sfnts, .. }) = f.get(&Key::name("sfnts")).cloned() else { return Ok(None) };
let name = glyph_name(&f, code);
let strings = f.get(&Key::name("CharStrings")).and_then(|c| match c {
Obj::Dict(d) => Some(d.clone()),
_ => None,
});
let gid = strings
.and_then(|d| {
let d = d.borrow();
d.get(&Key::name(&name)).or_else(|| d.get(&Key::name(".notdef"))).and_then(Obj::as_num)
})
.filter(|g| (0.0..=f64::from(u16::MAX)).contains(g))
.map_or(0, |g| g as u32);
(sfnts, gid)
};
let data = match self.type42.iter().find(|(k, _)| k.same(&sfnts)) {
Some((_, d)) => d.clone(),
None => {
let Some(data) = super::type42::font_data(&sfnts.borrow()) else { return Ok(None) };
self.alloc(data.len())?;
self.spend((data.len() / 4096) as u64)?;
let data = Rc::new(data);
if self.type42.len() >= MAX_TYPE42 {
self.type42.remove(0);
}
self.type42.push((sfnts, data.clone()));
data
}
};
Ok(super::type42::glyph(&data, gid))
}
}
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
const MAX_TYPE42: usize = 16;
/// The character code `code` of font `f` stands for: its glyph name's, or the code read as
/// Latin-1 when the font names no glyph for it (a font found by name, whose program isn't run).
fn code_char(f: &Dict, code: u8) -> char {
match &*glyph_name(f, code) {
".notdef" => char::from(code),
name => glyph_char(name),
}
}
/// The glyph name font `f`'s `Encoding` gives code `code` (`.notdef` without one).
fn glyph_name(f: &Dict, code: u8) -> Rc<str> {
f.get(&Key::name("Encoding")).and_then(|e| e.items()?.get(usize::from(code))).and_then(|o| o.text()).unwrap_or_else(|| Rc::from(".notdef"))
}
/// A font scaled or transformed by `m` (`scalefont`, `makefont`).
fn transformed(font: &DictRef, m: Affine) -> DictRef {
let mut d = font.borrow().clone();
+75
View File
@@ -0,0 +1,75 @@
//! Type 42 fonts' glyphs drawn from the TrueType font they carry, for the type a composite font
//! shows: its glyphs are picked by glyph index, often in a subset font without a character map,
//! so they can't be set again as type in an installed font. The font is the `sfnts` strings
//! joined, each string of odd length ending in a padding byte that isn't part of it (Adobe
//! Technical Note #5012, "The Type 42 Font Format Specification", on the `sfnts` array); a glyph
//! name maps to its glyph index through `CharStrings`.
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, GlyphId, MetadataProvider};
use vectorcraft_geom::BezPath;
use super::obj::Obj;
/// Most bytes of TrueType data a Type 42 font may carry.
pub(super) const MAX_FONT: usize = 32 << 20;
/// The TrueType font of a Type 42 font's `sfnts` strings, or `None` when an element isn't a
/// string or the font would be larger than [`MAX_FONT`].
pub(super) fn font_data(sfnts: &[Obj]) -> Option<Vec<u8>> {
let mut data = Vec::new();
for s in sfnts {
let Obj::Str(s) = s else { return None };
let s = s.borrow();
let len = s.len() - s.len() % 2;
if data.len() + len > MAX_FONT {
return None;
}
data.extend_from_slice(s.get(..len).unwrap_or_default());
}
Some(data)
}
/// Glyph `gid` of TrueType font `data`: its outline and advance width, in ems (y up), or `None`
/// when the font can't be read.
pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> {
let font = FontRef::new(data).ok()?;
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem <= 0.0 {
return None;
}
let id = GlyphId::new(gid);
let mut pen = Pen(BezPath::new(), 1.0 / upem);
if gid != 0
&& let Some(g) = font.outline_glyphs().get(id)
{
// A glyph that can't be drawn draws nothing; its width still moves the next one.
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
}
let advance = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(id).map_or(0.0, f64::from) / upem;
Some((pen.0, if advance.is_finite() { advance } else { 0.0 }))
}
/// A pen drawing an outline scaled from font units to ems.
struct Pen(BezPath, f64);
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((f64::from(x) * self.1, f64::from(y) * self.1));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((f64::from(x) * self.1, f64::from(y) * self.1));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
let s = self.1;
self.0.quad_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(x) * s, f64::from(y) * s));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
let s = self.1;
self.0.curve_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(cx1) * s, f64::from(cy1) * s), (f64::from(x) * s, f64::from(y) * s));
}
fn close(&mut self) {
self.0.close_path();
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS and `.ai` editing data: opacity mask art without its own paint takes the paint before its dictionary; a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -268,7 +268,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
@@ -306,6 +306,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. |
| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. |
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |