diff --git a/Cargo.lock b/Cargo.lock index 5faa6567a..9fd8db586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6338,6 +6338,7 @@ dependencies = [ "memchr", "ruzstd", "serde_json", + "skrifa 0.47.0", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f72240edf..5499dfd88 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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::()), 0..12), + body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::()), 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::())).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 diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index eac7b16b2..fb84265b2 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -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). diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 8bc8a174c..8e3a53ba2 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -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; diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index a21d6c9f6..243187d6b 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -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. diff --git a/crates/eps/src/import/graphics/path.rs b/crates/eps/src/import/graphics/path.rs index f9580077e..3d4f244dc 100644 --- a/crates/eps/src/import/graphics/path.rs +++ b/crates/eps/src/import/graphics/path.rs @@ -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(()); } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 13382e533..90db1614d 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -73,6 +73,9 @@ pub(crate) struct Interp<'a> { pub fault: Option, /// 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, Rc>)>, pub g: GState, pub saved: Vec, pub out: Out, @@ -189,6 +192,7 @@ impl<'a> Interp<'a> { journal: vec![], fault: None, glyph_width: None, + type42: vec![], g, saved: vec![], out, diff --git a/crates/eps/src/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs new file mode 100644 index 000000000..13e1bb3bd --- /dev/null +++ b/crates/eps/src/import/tests_composite.rs @@ -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::(); + 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 = 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::() / 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:?}"); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 7b0df91f9..75b61ebe8 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -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 { + 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 { + 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 { 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 { @@ -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), } +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 { + 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> { + 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 { + 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> { + 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 { + 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(); diff --git a/crates/eps/src/import/type42.rs b/crates/eps/src/import/type42.rs new file mode 100644 index 000000000..41c234cc2 --- /dev/null +++ b/crates/eps/src/import/type42.rs @@ -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> { + 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(); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e4828cca..fcd8da020 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 ``, 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. |