Fix #505: Illustrator EPS type with drop shadows and glows opens as vectors

Text effects from stock sites are Illustrator EPS with live type whose
appearance (several fills and strokes, offset paths, drop shadows, glows) is
flattened into many small images and clipped type. Made here with
Illustrator 2026 (graphic styles and live effects on type, live and
outlined, formats 2026, CS6, 10 and 8), every one fell back to its preview:

- Illustrator's procsets keep their own graphics-state stack in a
  dictionary, count each `save` in it and raise `limitcheck` at 64, relying
  on `restore` to put the count back. `restore` didn't undo dictionary
  changes, so a file with more than 64 images (each drawn in a save)
  stopped. Now `restore` puts dictionaries back as they were at the `save`,
  as restoring virtual memory does: while a save is open, `def`, `store`,
  `put`, `undef` and dictionary `copy` keep the old value in a journal
  (counted against the memory budget), undone newest first. A save that was
  restored already (or through an outer one) can't be restored again
  (`invalidrestore`).
- The Illustrator 8 format's pattern fills divide by the font cache's
  largest glyph (`cachestatus`), which was 0: it now reports a printer's
  sizes.
- Type in Illustrator's reencoded font copies (`/Impact*1`) was set in the
  fallback font: the `*1` suffix is dropped when the family is looked up.

The images of those effects also came in banded on main; that is #498's
fix, which this builds on.

Tests: 100 saves counted in a dictionary and restored, every dictionary
change undone, saves restored twice, `cachestatus`, reencoded font names.
The import fuzz vocabulary has saves and restores around dictionary
changes. The generated files (not committed) now open with no warnings and
look like their previews; the Illustrator 8-format ones draw what their
PostScript holds (that format writes effects as opaque rasters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 21:15:18 +07:00
co-authored by Claude Opus 5.5
parent a303add775
commit 262caec2d6
6 changed files with 101 additions and 24 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -953,7 +953,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"/f currentfile /ASCIIHexDecode filter def", "{f 3 string readstring pop}", "{f 0 string readstring pop}", "true 3 colorimage",
"3 2 8 [3 0 0 2 0 0] {f 5 string readstring pop} {(ab)} {f 1 string readstring pop} true 3 colorimage",
"<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 4 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 2 0 0] /MultipleDataSources true /DataSource [(a) (bc) {f 2 string readstring pop} (d)] >>",
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
// Virtual memory restored: dictionary changes undone, saves restored twice.
"/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store",
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
])
.prop_map(str::to_string),
]
+62 -20
View File
@@ -49,6 +49,11 @@ pub(crate) struct Interp<'a> {
/// Bytes asked for so far (see [`MAX_MEMORY`]).
allocated: usize,
seed: u32,
/// The `save`s not restored yet, oldest first.
saves: Vec<SaveMark>,
/// What dictionary entries were before they changed since the oldest `save` (dictionary,
/// key, old value): `restore` puts them back, as restoring virtual memory does.
journal: Vec<(DictRef, Key, Option<Obj>)>,
/// Where the error that is unwinding was raised (see [`Fault`]).
pub fault: Option<Fault>,
/// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`).
@@ -72,6 +77,13 @@ pub(crate) struct Fault {
/// Most procedure names a [`Fault`] keeps.
const FAULT_PROCS: usize = 3;
/// Where a `save` was made: the graphics states saved and the journal's length then.
#[derive(Clone, Copy, Debug)]
struct SaveMark {
gstates: usize,
journal: usize,
}
fn new_dict() -> DictRef {
Rc::new(RefCell::new(Dict::new()))
}
@@ -143,6 +155,8 @@ impl<'a> Interp<'a> {
ops: 0,
allocated: 0,
seed: 1,
saves: vec![],
journal: vec![],
fault: None,
glyph_width: None,
g,
@@ -190,6 +204,7 @@ impl<'a> Interp<'a> {
}
self.dicts.clear();
self.stack.clear();
self.journal.clear();
}
// ---------- the operand stack ----------
@@ -879,7 +894,7 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
insert(&d, key, v)?;
self.insert(&d, key, Some(v))?;
true
}
_ => return ps_err("typecheck", ""),
@@ -968,7 +983,7 @@ impl<'a> Interp<'a> {
let v = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Load => {
let k = self.pop()?;
@@ -981,7 +996,7 @@ impl<'a> Interp<'a> {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned();
let d = d.ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Known => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
@@ -1001,7 +1016,8 @@ impl<'a> Interp<'a> {
}
Undef => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
self.pop_dict()?.borrow_mut().remove(&k);
let d = self.pop_dict()?;
self.insert(&d, k, None)?;
}
CurrentDict => {
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
@@ -1034,11 +1050,23 @@ impl<'a> Interp<'a> {
}
Save => {
self.gsave()?;
self.push(Obj::Save(self.saved.len()))?;
self.push(Obj::Save(self.saves.len()))?;
self.saves.push(SaveMark { gstates: self.saved.len(), journal: self.journal.len() });
}
Restore => {
let Obj::Save(level) = self.pop()? else { return ps_err("typecheck", "") };
while self.saved.len() >= level && !self.saved.is_empty() {
let Obj::Save(i) = self.pop()? else { return ps_err("typecheck", "") };
// A save restored already (or through an outer one) can't be restored again.
let mark = self.saves.get(i).copied().ok_or(PsError::Ps("invalidrestore", String::new()))?;
self.saves.truncate(i);
// The dictionaries as they were at the save, newest change undone first.
for (d, k, old) in self.journal.drain(mark.journal..).rev() {
let mut d = d.borrow_mut();
match old {
Some(v) => d.insert(k, v),
None => d.remove(&k),
};
}
while self.saved.len() >= mark.gstates && !self.saved.is_empty() {
self.grestore();
}
}
@@ -1083,9 +1111,11 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(1 << 20))?;
}
}
// An empty font cache with a printer's sizes: bytes used and most, fonts used and most,
// glyphs used and most, and the largest glyph cached (programs divide by it).
CacheStatus => {
for _ in 0..7 {
self.push(Obj::Int(0))?;
for v in [0, 1 << 20, 0, 1000, 0, 10_000, 25_000] {
self.push(Obj::Int(v))?;
}
}
StartJob => {
@@ -1143,7 +1173,9 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let from = self.pop_dict()?.borrow().clone();
d.borrow_mut().extend(from);
for (k, v) in from {
self.insert(&d, k, Some(v))?;
}
self.push(Obj::Dict(d))?;
}
_ => return ps_err("typecheck", "copy"),
@@ -1219,6 +1251,26 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d` (`None`: remove it), within [`MAX_ALLOC`] entries; while a `save`
/// is open the old value is kept for `restore`.
fn insert(&mut self, d: &DictRef, k: Key, v: Option<Obj>) -> Res {
let old = {
let mut dm = d.borrow_mut();
if v.is_some() && dm.len() >= MAX_ALLOC && !dm.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
match v {
Some(v) => dm.insert(k.clone(), v),
None => dm.remove(&k),
}
};
if !self.saves.is_empty() {
self.alloc(2 * std::mem::size_of::<Obj>())?;
self.journal.push((d.clone(), k, old));
}
Ok(())
}
// ---------- resources ----------
/// The resources of `category`.
@@ -1293,16 +1345,6 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
fn insert(d: &DictRef, k: Key, v: Obj) -> Res {
let mut d = d.borrow_mut();
if d.len() >= MAX_ALLOC && !d.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
d.insert(k, v);
Ok(())
}
/// An array or string index.
fn index(k: &Obj) -> Res<usize> {
match *k {
+4
View File
@@ -280,6 +280,10 @@ fn type_becomes_point_type_in_the_font_named() {
assert!(t2.xf.translation().x > 15.0);
assert_eq!(crate::family_style("ABCDEF+TimesNewRomanPS-BoldItalicMT"), ("Times New Roman".into(), "Bold Italic".into()));
assert_eq!(crate::family_style("Times-Roman"), ("Times".into(), "Regular".into()));
// Illustrator's reencoded copies (#505).
assert_eq!(crate::family_style("Impact*1"), ("Impact".into(), "Regular".into()));
assert_eq!(crate::family_style("MyriadPro-Bold*12"), ("Myriad Pro".into(), "Bold".into()));
assert_eq!(crate::family_style("Odd*Name"), ("Odd*Name".into(), "Regular".into()));
}
#[test]
+24
View File
@@ -991,3 +991,27 @@ showpage"##;
assert!(all(d).iter().any(|n| fill(n).and_then(Paint::color) == Some(Color::rgb(0.0, 1.0, 0.0))));
assert_eq!(objects(d).len(), 3);
}
/// `restore` puts dictionaries back as they were at the `save`, as restoring virtual memory does:
/// Illustrator's procsets count `save`s in a dictionary and raise `limitcheck` at 64, so every file
/// with more than 64 images (the rasters of drop shadows and glows on type, #505) fell back to
/// its preview while the count only grew.
#[test]
fn restore_puts_dictionaries_back() {
let r = read(
"/AGM 4 dict def AGM /n 0 put \
/mysave { save AGM /n get 1 add dup 64 ge { limitcheck } if AGM exch /n exch put } def \
1 1 100 { pop mysave 0 0 1 1 rectfill restore } for AGM /n get 0 eq { 10 10 5 5 rectfill } if",
);
clean(&r);
assert_eq!(objects(&r.document).len(), 101);
// def, store, put, undef and copy are undone, newest first; a save restored can't be again.
super::tests::check(
"/a 1 def /d 2 dict def d /k 1 put save /a 2 def /b 3 def d /k 2 put d /k undef 1 dict dup /z 9 put d copy pop \
/a 4 store restore a 1 eq /b where not and d /k get 1 eq and d /z known not and",
);
super::tests::check("save dup restore { restore } stopped");
super::tests::check("save save exch restore { restore } stopped");
// The font cache's sizes are numbers programs divide by (Illustrator 8's pattern fills).
super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt");
}
+6 -1
View File
@@ -20,12 +20,17 @@ const DEFAULT_FONT: &str = "Helvetica";
/// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`,
/// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset
/// prefix (`ABCDEF+`).
/// prefix (`ABCDEF+`) or a reencoded copy's suffix (`*1`).
pub fn family_style(name: &str) -> (String, String) {
let name = match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => name,
};
// A copy the program reencoded (`Impact*1`, as Illustrator's type procsets name them).
let name = match name.rsplit_once('*') {
Some((font, n)) if !font.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => font,
_ => name,
};
let (family, style) = name.split_once('-').unwrap_or((name, ""));
let trim = |s: &str| -> String {
let s = ["PSMT", "MT", "PS"].iter().find_map(|suffix| s.strip_suffix(suffix)).unwrap_or(s);