Merge pull request #5999 from mockydevs/fix/eps-composite-fonts

EPS import: composite fonts draw their Type 42 glyphs (pdftops EPS shows its type)
This commit is contained in:
BFlat
2026-11-09 03:40:23 +08:00
committed by GitHub
12 changed files with 686 additions and 28 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(());
}
+12 -1
View File
@@ -73,6 +73,12 @@ 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`; `None` when it can't be read), the most recently used last.
pub type42: Type42Cache,
/// The `sfnts` arrays whose TrueType data has been counted against [`MAX_MEMORY`]: a font
/// read again after it left `type42` doesn't count again.
pub type42_counted: HashSet<Shared<Obj>>,
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
@@ -94,6 +100,9 @@ pub(crate) struct Fault {
pub procs: Vec<Rc<str>>,
}
/// The TrueType data of Type 42 fonts by their `sfnts` array (see [`Interp::type42`]).
pub(crate) type Type42Cache = Vec<(Shared<Obj>, Option<Rc<Vec<u8>>>)>;
/// Most procedure names a [`Fault`] keeps.
const FAULT_PROCS: usize = 3;
@@ -189,6 +198,8 @@ impl<'a> Interp<'a> {
journal: vec![],
fault: None,
glyph_width: None,
type42: vec![],
type42_counted: HashSet::new(),
g,
saved: vec![],
out,
@@ -362,7 +373,7 @@ impl<'a> Interp<'a> {
/// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search`
/// charges per 64 bytes, so a loop of big copies runs out of the budget.
fn spend_bytes(&mut self, n: usize) -> Res {
pub fn spend_bytes(&mut self, n: usize) -> Res {
self.spend((n / 256) as u64)
}
+1 -1
View File
@@ -1125,7 +1125,7 @@ fn read_within(body: &str, secs: u64) -> crate::Imported {
/// budget. Running to the end of the budget can take over 20 s on a slow CI runner
/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the
/// limit here only catches a program that never stops.
fn runs_out_of_budget(body: &str) {
pub(super) fn runs_out_of_budget(body: &str) {
let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120);
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings);
+253
View File
@@ -0,0 +1,253 @@
//! 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:?}");
}
/// `widthshow` adds its spacing to the composite character whose font number times 256 plus code
/// is its `char` (PLRM 3rd ed., `widthshow` in chapter 8), not to every character with that code.
#[test]
fn widthshow_matches_a_composite_character_by_font_number_and_code() {
let (_, wh) = glyph('H');
let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def");
let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}"));
let end = |ch: u32| format!("gsave nulldevice 10 20 moveto 20 0 {ch} (\\000\\001\\001\\001) widthshow currentpoint grestore pop");
// 257 is font 1's code 1: the second H only.
check(format!("{} {} sub abs 0.01 lt", end(257), 10.0 + 100.0 * wh + 20.0));
// 1 is font 0's code 1: the first H only.
check(format!("{} {} sub abs 0.01 lt", end(1), 10.0 + 100.0 * wh + 20.0));
// 2 is neither.
check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh));
}
/// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`)
/// counts against the operation budget, as drawing them does.
#[test]
fn measuring_type42_descendants_in_a_loop_runs_out_of_the_budget() {
super::tests::runs_out_of_budget(&format!(
"{} /Comp findfont 50 scalefont setfont /s 100000 string def {{ s stringwidth pop pop }} loop",
fonts()
));
}
/// A descendant whose TrueType data can't be read (an `sfnts` element that isn't a string, after
/// 32 MB of strings) is read once, not again for every character measured: copying its strings
/// costs about 130,000 operations, so 1,000 measures finish only if it is copied once (reading
/// it each time would need 130 million, over the budget of 50 million).
#[test]
fn unreadable_type42_data_is_read_once() {
super::tests::check(
"10 dict begin /FontName /Bad 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\n\
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ 0 1 7 { pop 4000000 string } for 0 ] 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 [/Bad findfont] def FontName currentdict end definefont pop\n\
/Comp findfont 50 scalefont setfont 1000 { (\\000\\001) stringwidth pop pop } repeat true",
);
}
/// A Type 3 descendant draws and measures its glyphs with its own procedure, under the composite
/// font's matrix: here 50 pt and twice as wide, so a glyph 500 × 700 units wide (600 units
/// apart) is 50 × 35 pt, 60 pt apart (PLRM 3rd ed., §5.10: a descendant's glyphs are under the
/// composite font's matrix as well as its own).
#[test]
fn type3_descendants_draw_and_measure_under_the_composite_matrix() {
let fonts = "8 dict begin /FontName /T3 def /FontType 3 def /FontMatrix [0.001 0 0 0.001 0 0] def /FontBBox [0 0 1000 1000] def\n\
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 1 /box put\n\
/BuildChar { exch begin 1 eq { 600 0 0 0 500 700 setcachedevice 0 0 500 700 rectfill } { 0 0 setcharwidth } ifelse end } def\n\
FontName currentdict end definefont pop\n\
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [2 0 0 1 0 0] def /FMapType 2 def\n\
/Encoding [0] def /FDepVector [/T3 findfont] def FontName currentdict end definefont pop\n\
/Comp findfont 50 scalefont setfont";
let r = read(&format!("{fonts} 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\001) show"));
let paths: Vec<_> = all(&r.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert_eq!(paths.len(), 2, "{:?}", r.warnings);
let (a, b) = (bounds(&paths[0]), bounds(&paths[1]));
for g in [a, b] {
assert!((g.width() - 50.0).abs() < 0.01 && (g.height() - 35.0).abs() < 0.01, "{g:?}");
}
assert!((a.x0 - 10.0).abs() < 0.01 && (b.x0 - 70.0).abs() < 0.01, "{a:?} {b:?}");
let check = |cond: &str| super::tests::check(&format!("{fonts} {cond}"));
check("(\\000\\001\\000\\001) stringwidth pop 120 sub abs 0.01 lt");
check("newpath 10 20 moveto (\\000\\001) true charpath currentpoint pop 70 sub abs 0.01 lt");
}
/// A descendant found by name is scaled for each character it sets: copying a huge font
/// dictionary for every character counts against the operation budget, so a `loop` of it stops.
#[test]
fn a_huge_named_descendant_in_a_loop_runs_out_of_the_budget() {
super::tests::runs_out_of_budget(
"200000 dict begin 0 1 199999 { dup def } for /FontName /Big def /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def\n\
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 [/Big findfont] def FontName currentdict end definefont pop\n\
/Comp findfont 50 scalefont setfont { (\\000A) stringwidth pop pop } loop",
);
}
/// A composite font's TrueType data stays read while it is in use: the font a string uses for
/// every other character isn't dropped (and read again, counting its size against the memory
/// budget each time) while 16 other fonts are read between its uses.
#[test]
fn type42_data_in_use_stays_read() {
let mut others = String::new();
for k in 1..=16 {
others.push_str(&format!(
"10 dict begin /FontName /B{k} 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\n\
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [<00>] def FontName currentdict end definefont pop\n"
));
}
let deps: String = (1..=16).map(|k| format!(" /B{k} findfont")).collect();
let fonts = fonts().replace(
"/Encoding [0] def /FDepVector [/T42 findfont] def",
&format!("/Encoding [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16] def /FDepVector [/T42 findfont{deps}] def"),
);
let s: String = (1..=16).map(|k| format!("\\000\\001\\{k:03o}\\001")).collect();
super::tests::check(&format!("{others}{fonts} /Comp findfont 50 scalefont setfont 3000 {{ ({s}) stringwidth pop pop }} repeat true"));
}
/// A page that cycles through more Type 42 fonts than are kept read reads each again when it
/// comes back, but counts its data against the memory budget once.
#[test]
fn type42_data_read_again_counts_once() {
let mut fonts = format!("/S [\n{}] def\n", sfnts());
for k in 0..17 {
fonts.push_str(&format!(
"10 dict begin /FontName /F{k} 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\n\
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop ] def FontName currentdict end definefont pop\n"
));
}
let deps: String = (0..17).map(|k| format!(" /F{k} findfont")).collect();
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
fonts.push_str(&format!(
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n"
));
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
// 17 fonts of 431 kB, each read 200 times: 1.4 GB if each read counted.
super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont 200 {{ ({s}) stringwidth pop pop }} repeat true"));
}
/// Reading `sfnts` data that turns out unreadable is charged too: a composite font cycling
/// through more such descendants than are kept read copies their strings on every glyph, and a
/// `loop` of it runs out of the budget.
#[test]
fn unreadable_type42_data_read_again_runs_out_of_the_budget() {
let mut fonts = String::from("/S [ 0 1 7 { pop 4000000 string } for ] def\n");
for k in 0..17 {
fonts.push_str(&format!(
"10 dict begin /FontName /Bad{k} 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\n\
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop 0 ] def FontName currentdict end definefont pop\n"
));
}
let deps: String = (0..17).map(|k| format!(" /Bad{k} findfont")).collect();
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
super::tests::runs_out_of_budget(&format!(
"{fonts}16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n\
/Comp findfont 50 scalefont setfont {{ ({s}) stringwidth pop pop }} loop"
));
}
+273 -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,240 @@ 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,
/// The byte that picked the descendant (the font number).
pick: u8,
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, fonts) = (f.get(&Key::name("Encoding"))?.items()?.clone(), f.get(&Key::name("FDepVector"))?.items()?.clone());
let (encoding, fonts) = (encoding.borrow(), fonts.borrow());
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(), pick, 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)?;
// `widthshow` and `awidthshow` match a composite font's character by its font number
// times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8).
let d = spacing.advance(i, i64::from(g.pick) * 256 + 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)? {
// Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it.
self.spend((outline.elements().len() as u64).saturating_add(16))?;
if paint != Draw::No {
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) };
// Scaling the descendant copies its dictionary for every character: charged by its size,
// so a huge font dictionary shown in a loop runs out of the budget.
let entries = g.font.borrow().len();
self.spend(entries as u64)?;
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)
};
// The least recently used font goes first: a hit moves to the end.
let data = match self.type42.iter().position(|(k, _)| k.same(&sfnts)) {
Some(i) => {
let hit = self.type42.remove(i);
let data = hit.1.clone();
self.type42.push(hit);
data
}
None => {
// Joining the strings visits each element and copies its bytes, whether or not
// the font can then be read: charged first, as other bulk copies are.
let (elements, bytes) = super::type42::font_size(&sfnts.borrow());
self.spend_bytes(bytes)?;
self.spend((elements / 16) as u64)?;
// Fonts whose data can't be read are kept too, so they are copied only once.
let data = super::type42::font_data(&sfnts.borrow()).map(Rc::new);
if let Some(d) = &data
&& self.type42_counted.insert(sfnts.clone())
{
self.alloc(d.len())?;
}
if self.type42.len() >= MAX_TYPE42 {
self.type42.remove(0);
}
self.type42.push((sfnts, data.clone()));
data
}
};
let Some(data) = data else { return Ok(None) };
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();
+89
View File
@@ -0,0 +1,89 @@
//! 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)
}
/// What [`font_data`] visits for `sfnts`: the elements up to the first that isn't a string, and
/// the bytes of those strings (at most [`MAX_FONT`] and one string more).
pub(super) fn font_size(sfnts: &[Obj]) -> (usize, usize) {
let mut bytes = 0usize;
for (i, s) in sfnts.iter().enumerate() {
let Obj::Str(s) = s else { return (i + 1, bytes) };
bytes = bytes.saturating_add(s.borrow().len());
if bytes > MAX_FONT {
return (i + 1, bytes.min(2 * MAX_FONT));
}
}
(sfnts.len(), bytes)
}
/// 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. |