From 740faf1c1f4587b5422ae01e610f8a2f2b92826c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:01:10 -0400 Subject: [PATCH] M13.12: EPS text (show variants, charpath, stringwidth, embedded Type 1/CFF, fallback fonts) Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/cff.rs | 8 + crates/pdf/src/font.rs | 46 +++- crates/pdf/src/ps.rs | 452 +++++++++++++++++++++++++++++---- crates/pdf/src/tests.rs | 3 +- crates/pdf/src/tests_import.rs | 85 ++++++- 5 files changed, 530 insertions(+), 64 deletions(-) diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 7d647e7c..03493d81 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -366,6 +366,14 @@ pub struct Cff { pub encoding: [u16; 256], } +/// The first font name in a CFF program's Name INDEX. +pub fn font_name(data: &[u8]) -> Option { + let hdr = *data.get(2)? as usize; + let (names, _) = index(data, hdr)?; + let r = names.first()?.clone(); + Some(String::from_utf8_lossy(data.get(r)?).into_owned()) +} + impl Cff { pub fn parse(data: &[u8]) -> Option { let d = data; diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index c2716fc8..e2ef1693 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -311,11 +311,9 @@ fn parse_cmap(data: &[u8]) -> CMap { impl Font { /// Read a font dictionary. `skipped` collects what could not be read. - pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { - let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); - let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); - let mut f = Font { - composite: subtype == "Type0", + fn blank(base_name: &str, composite: bool) -> Font { + Font { + composite, program: Program::None, matrix: Affine::scale(0.001), first_char: 0, @@ -333,8 +331,44 @@ impl Font { dw2: (0.88, -1.0), cache: RefCell::new(HashMap::new()), has_widths: false, - base_name: base_name.clone(), + base_name: base_name.to_string(), + } + } + + /// A simple font outside a PDF (EPS text): `program` is an embedded font program (CFF when + /// the flag is set, else Type 1), otherwise the bundled stand-in for `name` is used; + /// `names` is the encoding (code → glyph name), else the program's built-in encoding or + /// StandardEncoding. Advances are the program's. + pub fn standalone(name: &str, program: Option<(&[u8], bool)>, names: Option>>) -> Font { + let mut f = Font::blank(name, false); + f.program = match program { + Some((data, true)) => Cff::parse(data).map(|c| Program::Cff(Box::new(c))), + Some((data, false)) => Type1::parse(data).map(|t| Program::Type1(Box::new(t))), + None => None, + } + .unwrap_or_else(|| Program::Fallback(fallback(name))); + match &f.program { + Program::Cff(c) => f.matrix = Affine::new(c.font_matrix), + Program::Type1(t) => f.matrix = t.font_matrix.map(Affine::new).unwrap_or(f.matrix), + _ => {} + } + let symbol = matches!(f.program, Program::Fallback(_)) && (name.contains("Symbol") || name.contains("Dingbats")); + f.names = match names { + // Unencoded codes draw .notdef (not the built-in encoding). + Some(n) => (0..256).map(|c| Some(n.get(c).cloned().flatten().unwrap_or_else(|| ".notdef".into()))).collect(), + None => match &f.program { + Program::Type1(t) if !t.encoding.is_empty() => (0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect(), + _ if symbol => vec![None; 256], + _ => (0..256).map(|c| Base::Standard.name(c as u8)).collect(), + }, }; + f + } + + pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { + let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); + let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); + let mut f = Font::blank(&base_name, subtype == "Type0"); if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode") && let Some(data) = decode_stream(file, sd, raw) { diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 1a980f6f..153956ed 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -2,7 +2,9 @@ //! edition, and the EPSF 3.0 specification) covering what exported vector EPS files use: the //! operand and dictionary stacks, procedures and `def`, arithmetic, control flow, arrays and //! dictionaries, the graphics state, path construction (including arcs) and painting, clipping -//! and device colours. Text is skipped, as are images (their inline data is stepped over) and +//! and device colours, and text (`findfont` / `selectfont`, `show` and its variants, `charpath`, +//! `stringwidth`, re-encoded fonts) with embedded Type 1 and CFF programs or the bundled +//! stand-in fonts. Images are skipped (their inline data is stepped over), as is //! Level 3 smooth shading (`shfill`). use std::cell::RefCell; @@ -13,6 +15,7 @@ use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; use crate::build::Builder; +use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; type DictRef = Rc>>; @@ -79,6 +82,8 @@ struct GState { cur: Option, start: Option, depth: usize, + /// The current font dictionary. + font: Option, } /// Source tokenizer (the data source for `currentfile`). @@ -311,6 +316,12 @@ pub(crate) struct Interp { budget: usize, /// Nested procedure calls (recursion is cut off at [`MAX_CALL_DEPTH`]). calls: usize, + /// Fonts by name: `definefont`d dictionaries. + font_dir: HashMap, + /// Embedded font programs found in the file (name → (program, CFF)). + embedded: HashMap>, bool)>, + /// Fonts built for drawing, by base font and encoding. + font_cache: HashMap>, page: [f64; 4], pub unknown: Vec, } @@ -353,16 +364,21 @@ impl Interp { cur: None, start: None, depth: 1, + font: None, }, saved: vec![], budget: 8_000_000, calls: 0, + font_dir: HashMap::new(), + embedded: HashMap::new(), + font_cache: HashMap::new(), page, unknown: vec![], } } pub fn run(&mut self, data: &[u8]) { + self.embedded = embedded_fonts(data); let mut src = Src { d: data, pos: 0 }; while let Some(t) = src.token(0) { if let Err(Flow::Quit) = self.exec_token(t, &mut src) { @@ -924,16 +940,24 @@ impl Interp { "countdictstack" => self.push(V::Num(self.dicts.len() as f64)), "cleardictstack" => self.dicts.truncate(1), "findresource" | "findfont" | "findencoding" => { - if n == "findresource" { - self.pop(); + let category = if n == "findresource" { self.pop().key() } else { String::new() }; + let key = self.pop().key(); + match (n, category.as_str()) { + ("findfont", _) | (_, "Font") => { + let f = self.find_font(&key); + self.push(V::Dict(f)); + } + ("findencoding", _) | (_, "Encoding") => self.push(encoding_array(&key)), + _ => self.push(V::Dict(new_dict())), } - self.pop(); - self.push(V::Dict(new_dict())); } "defineresource" => { - self.pop(); + let category = self.pop().key(); let v = self.pop(); - self.pop(); + let key = self.pop().key(); + if let (V::Dict(d), "Font") = (&v, category.as_str()) { + self.font_dir.insert(key, d.clone()); + } self.push(v); } "resourcestatus" => { @@ -1355,55 +1379,10 @@ impl Interp { self.pop(); self.b.skip("smooth shading (shfill)"); } - // Text (skipped). - "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "setcachedevice" | "setcharwidth" => { - match n { - "scalefont" | "makefont" => { - self.pop(); - } - "selectfont" => { - self.pop(); - self.pop(); - } - "charpath" => { - self.pop(); - self.pop(); - } - "setcachedevice" => { - for _ in 0..6 { - self.pop(); - } - } - "setcharwidth" => { - self.pop(); - self.pop(); - } - "rootfont" => self.push(V::Dict(new_dict())), - _ => { - self.pop(); - } - } - if matches!(n, "show" | "charpath" | "glyphshow") { - self.b.skip("text"); - } - } - "currentfont" => self.push(V::Dict(new_dict())), - "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => { - let k = match n { - "ashow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" => 3, - "widthshow" => 4, - _ => 6, - }; - for _ in 0..k.min(self.st.len()) { - self.pop(); - } - self.b.skip("text"); - } - "stringwidth" => { - self.pop(); - self.push(V::Num(0.0)); - self.push(V::Num(0.0)); - } + // Text (see `text_op`). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" + | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" + | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, // Images: operands popped, inline data skipped. "image" | "imagemask" | "colorimage" => { let mut reads_file = false; @@ -1478,3 +1457,364 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { let bbox = find_box(b"%%HiResBoundingBox:").or_else(|| find_box(b"%%BoundingBox:"))?; Some((ps, bbox)) } + +// ------------------------------------------------------------------ text + +/// An encoding array of glyph names (`StandardEncoding`; `ISOLatin1Encoding` and others are +/// approximated by WinAnsi, which agrees with ISO Latin-1 on the printable codes). +fn encoding_array(name: &str) -> V { + let base = if name == "StandardEncoding" { crate::encoding::Base::Standard } else { crate::encoding::Base::WinAnsi }; + array((0..256).map(|c| V::Lit(base.name(c as u8).unwrap_or_else(|| ".notdef".into()).into())).collect()) +} + +fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { + hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +} + +/// The name after `key` (`/FontName /Name`). +fn name_after(b: &[u8], key: &[u8]) -> Option { + let p = find_from(b, key, 0)? + key.len(); + let rest = &b[p..]; + let s = rest.iter().position(|c| *c == b'/')? + 1; + let e = rest[s..].iter().position(|c| is_white(*c) || matches!(c, b'/' | b'[' | b'(' | b'{')).map_or(rest.len(), |q| s + q); + (e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned()) +} + +/// Embedded font programs of an EPS file: Type 1 fonts (`… /FontName /X … currentfile eexec +/// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). +fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { + let mut out = HashMap::new(); + let mut from = 0; + while let Some(ee) = find_from(ps, b"eexec", from) { + from = ee + 5; + let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] + .iter() + .filter_map(|h| rfind_before(ps, h, ee)) + .max() + .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let Some(start) = start else { continue }; + let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); + if let Some(name) = name_after(&ps[start..ee], b"/FontName") { + out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); + } + from = end; + } + let mut from = 0; + while let Some(sd) = find_from(ps, b"StartData", from) { + from = sd + 9; + let head = String::from_utf8_lossy(&ps[sd.saturating_sub(64)..sd]).into_owned(); + let Some(len) = head.split_whitespace().last().and_then(|t| t.parse::().ok()) else { continue }; + let s = sd + 10; + let Some(data) = ps.get(s..s.saturating_add(len)) else { continue }; + if let Some(name) = crate::cff::font_name(data) { + out.insert(name, (Rc::new(data.to_vec()), true)); + } + from = s + len; + } + out +} + +fn dict_matrix(d: &DictRef, key: &str) -> Affine { + d.borrow().get(key).and_then(matrix_of).unwrap_or(Affine::IDENTITY) +} + +/// A copy of a font dictionary with `m` applied after its matrix (`scalefont`, `makefont`). +fn transformed(font: &DictRef, m: Affine) -> DictRef { + let copy: HashMap = font.borrow().clone(); + let d = Rc::new(RefCell::new(copy)); + let old = dict_matrix(&d, "__m"); + d.borrow_mut().insert("__m".into(), matrix_val(m * old)); + d +} + +impl Interp { + /// A font dictionary by name: one `definefont` made, else a new dictionary for an + /// embedded program or a bundled stand-in. + fn find_font(&mut self, key: &str) -> DictRef { + if let Some(d) = self.font_dir.get(key) { + return d.clone(); + } + let d = new_dict(); + { + let mut m = d.borrow_mut(); + m.insert("FontName".into(), V::Lit(key.into())); + m.insert("FontType".into(), V::Num(1.0)); + m.insert("FontMatrix".into(), matrix_val(Affine::scale(0.001))); + m.insert("FID".into(), V::Null); + m.insert("__base".into(), V::Lit(key.into())); + m.insert("__m".into(), matrix_val(Affine::IDENTITY)); + } + self.font_dir.insert(key.to_string(), d.clone()); + d + } + + /// The drawable font of a font dictionary (its base program and encoding) and the matrix + /// from text space (font size 1) to user space. + fn font_of(&mut self, d: &DictRef) -> Option<(Rc, Affine)> { + let (base, names) = { + let m = d.borrow(); + let base = m.get("__base").or_else(|| m.get("FontName")).map(V::key)?; + let names: Option>> = m.get("Encoding").and_then(V::items).map(|a| { + a.iter() + .map(|v| match v { + V::Lit(n) | V::Name(n) if &**n != ".notdef" => Some(n.to_string()), + _ => None, + }) + .collect() + }); + (base, names) + }; + let key = match &names { + Some(n) => format!("{base}\u{0}{}", n.iter().map(|x| x.as_deref().unwrap_or("")).collect::>().join(" ")), + None => base.clone(), + }; + let font = match self.font_cache.get(&key) { + Some(f) => f.clone(), + None => { + // Not embedded: a bundled stand-in, as for PDF. + let prog = self.embedded.get(&base).cloned(); + let f = Rc::new(Font::standalone(&base, prog.as_ref().map(|(d, cff)| (d.as_slice(), *cff)), names)); + self.font_cache.insert(key, f.clone()); + f + } + }; + Some((font, dict_matrix(d, "__m"))) + } + + /// A string's glyph outlines (device space) from the current point, with an extra + /// displacement `extra(index, code)` (user space) after each glyph; moves the current + /// point. + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { + let d = self.gs.font.clone()?; + let (font, m) = self.font_of(&d)?; + let mut p = self.current_user(); + let mut path = BezPath::new(); + let mut text = String::new(); + let ctm = self.gs.ctm; + for (i, &code) in bytes.iter().enumerate() { + let code = code as u32; + if let Some(g) = font.glyph(code) { + path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); + } + text.push_str(&font.unicode(code)); + let adv = m * Point::new(font.width(code), 0.0) - m * Point::ZERO; + let (ex, ey) = extra(i, code); + p += adv + kurbo::Vec2::new(ex, ey); + } + self.move_to(p); + Some((path, text)) + } + + fn fill_text(&mut self, path: BezPath, text: &str) { + let label: String = text.chars().filter(|c| !c.is_control()).take(40).collect(); + let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) }; + self.b.fill_stroke_named(&name, path, Affine::IDENTITY, Some((Paint::Color(self.gs.color), 1.0, FillRule::NonZero)), None); + } + + fn pop_string(&mut self) -> Vec { + match self.pop() { + V::Str(s) => s.borrow().clone(), + _ => vec![], + } + } + + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { + match self.text_path(s, extra) { + Some((path, text)) => self.fill_text(path, &text), + None => self.b.skip("text (no font)"), + } + } + + /// Text and font operators (PostScript Language Reference §5.1, §8.2). + fn text_op(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + match n { + "scalefont" | "makefont" => { + let m = if n == "scalefont" { + Affine::scale(self.popn()) + } else { + let v = self.pop(); + matrix_of(&v).unwrap_or(Affine::IDENTITY) + }; + let font = match self.pop() { + V::Dict(d) => d, + _ => self.find_font("Helvetica"), + }; + self.push(V::Dict(transformed(&font, m))); + } + "selectfont" => { + let size = self.pop(); + let key = self.pop().key(); + let font = self.find_font(&key); + let m = match &size { + V::Num(s) => Affine::scale(*s), + other => matrix_of(other).unwrap_or(Affine::IDENTITY), + }; + self.gs.font = Some(transformed(&font, m)); + } + "setfont" => { + if let V::Dict(d) = self.pop() { + self.gs.font = Some(d); + } + } + "currentfont" | "rootfont" => { + let d = self.gs.font.clone().unwrap_or_else(new_dict); + self.push(V::Dict(d)); + } + "definefont" => { + let v = self.pop(); + let key = self.pop().key(); + if let V::Dict(d) = &v { + if !d.borrow().contains_key("__base") { + // A font the file defines itself: its embedded program, by name. + d.borrow_mut().insert("__base".into(), V::Lit(key.as_str().into())); + d.borrow_mut().insert("__m".into(), matrix_val(Affine::IDENTITY)); + } + self.font_dir.insert(key, d.clone()); + } + self.push(v); + } + "undefinefont" => { + let key = self.pop().key(); + self.font_dir.remove(&key); + } + "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), + "show" => { + let s = self.pop_string(); + self.show_with(&s, |_, _| (0.0, 0.0)); + } + "ashow" => { + let s = self.pop_string(); + let ay = self.popn(); + let ax = self.popn(); + self.show_with(&s, |_, _| (ax, ay)); + } + "widthshow" => { + let s = self.pop_string(); + let ch = self.popn() as u32; + let cy = self.popn(); + let cx = self.popn(); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + } + "awidthshow" => { + let s = self.pop_string(); + let ay = self.popn(); + let ax = self.popn(); + let ch = self.popn() as u32; + let cy = self.popn(); + let cx = self.popn(); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + } + "kshow" | "cshow" => { + // The procedure between characters is not run. + let s = self.pop_string(); + self.pop(); + self.show_with(&s, |_, _| (0.0, 0.0)); + } + "xshow" | "yshow" | "xyshow" => { + let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); + let s = self.pop_string(); + self.explicit_show(&s, &disp, n); + } + "glyphshow" => { + let name = self.pop().key(); + let Some(d) = self.gs.font.clone() else { return Ok(()) }; + // Drawn through a one-glyph encoding. + let mut e: Vec = (0..256).map(|_| V::Lit(".notdef".into())).collect(); + e[65] = V::Lit(name.as_str().into()); + let tmp = transformed(&d, Affine::IDENTITY); + tmp.borrow_mut().insert("Encoding".into(), array(e)); + self.gs.font = Some(tmp); + self.show_with(b"A", |_, _| (0.0, 0.0)); + self.gs.font = Some(d); + } + "charpath" => { + self.pop(); + let s = self.pop_string(); + let saved = std::mem::take(&mut self.gs.path); + let start = self.gs.start; + match self.text_path(&s, |_, _| (0.0, 0.0)) { + Some((glyphs, _)) => { + let mut p = saved; + p.extend(glyphs); + if let Some(c) = self.gs.cur { + p.move_to(c); + } + self.gs.path = p; + self.gs.start = start; + } + None => self.gs.path = saved, + } + } + "stringwidth" => { + let s = self.pop_string(); + let (mut w, mut h) = (0.0, 0.0); + if let Some(d) = self.gs.font.clone() + && let Some((font, m)) = self.font_of(&d) + { + for &c in &s { + let v = m * Point::new(font.width(c as u32), 0.0) - m * Point::ZERO; + w += v.x; + h += v.y; + } + } + self.push(V::Num(w)); + self.push(V::Num(h)); + } + "setcachedevice" => { + for _ in 0..6 { + self.pop(); + } + } + "setcharwidth" => { + self.pop(); + self.pop(); + } + // An embedded Type 1 program's encrypted part (read by `embedded_fonts`). + "eexec" => { + if matches!(self.pop(), V::File) { + let rest = &src.d[src.pos.min(src.d.len())..]; + src.pos += rest.windows(11).position(|w| w == b"cleartomark").map_or(rest.len(), |p| p + 11); + } + } + // A CFF font set's binary data (read by `embedded_fonts`). + "StartData" => { + let len = self.popn().max(0.0) as usize; + self.pop(); + src.pos = src.pos.saturating_add(1).saturating_add(len).min(src.d.len()); + } + _ => {} + } + Ok(()) + } + + /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the + /// advances. + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + let step = if n == "xyshow" { 2 } else { 1 }; + let d: Vec<(f64, f64)> = (0..s.len()) + .map(|k| { + let i = k * step; + let v = |j: usize| disp.get(j).copied().unwrap_or(0.0); + match n { + "xshow" => (v(i), 0.0), + "yshow" => (0.0, v(i)), + _ => (v(i), v(i + 1)), + } + }) + .collect(); + let Some(fd) = self.gs.font.clone() else { + self.b.skip("text (no font)"); + return; + }; + let Some((font, m)) = self.font_of(&fd) else { return }; + self.show_with(s, move |k, c| { + let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; + let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); + (dx - adv.x, dy - adv.y) + }); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b3e054a3..dac34bee 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -119,7 +119,8 @@ showpage\n%%EOF\n"; fn eps_postscript_subset() { let doc = parse(EPS.as_bytes()).unwrap(); assert_eq!((doc.width, doc.height), (100.0, 100.0)); - assert!(doc.skipped.contains(&"text".to_string())); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert!(shape_names(&doc).contains(&"Text: text".to_string()), "EPS text is drawn: {:?}", shape_names(&doc)); let at = |x, y| px(&doc, x, y); assert!(at(25, 75)[0] > 0.99 && at(25, 75)[3] > 0.99, "red box {:?}", at(25, 75)); assert!(at(70, 50)[2] > 0.99, "blue disc {:?}", at(70, 50)); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 3709d4ff..b6b96b80 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -2,7 +2,90 @@ //! corrupt inputs (generated fixtures). use super::*; -use crate::tests::{page_pdf, render}; +use crate::tests::{coverage, page_pdf, render, shape_names}; + +fn eps(body: &[u8]) -> Doc { + let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec(); + b.extend_from_slice(body); + let doc = parse(&b).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + doc +} + +/// The ink columns `(x0, x1)` of a render (alpha > 0.5). +fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) { + let (mut x0, mut x1) = (i64::MAX, i64::MIN); + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + (x0, x1) = (x0.min(x), x1.max(x)); + } + } + } + (x0, x1) +} + +#[test] +fn eps_text_with_bundled_fonts_and_show_variants() { + // Not embedded: drawn with the bundled stand-in, like the same text in a PDF. + let doc = eps(b"/Helvetica findfont 40 scalefont setfont 10 30 moveto (Hi) show\n"); + assert_eq!(shape_names(&doc), vec!["Text: Hi"]); + let pdf = parse(&page_pdf("BT /F1 40 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![])).unwrap(); + let (a, b) = (render(&doc), render(&pdf)); + let (ia, ib) = (coverage(&a, 0, 0, 200, 100), coverage(&b, 0, 0, 200, 100)); + assert!(ia > 100.0 && (ia - ib).abs() < 1.0, "eps {ia} vs pdf {ib}"); + assert_eq!(ink_x(&a), ink_x(&b)); + // ashow spreads the letters; widthshow widens spaces only; selectfont; stringwidth centres. + let plain = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HHH) show\n"))); + let spread = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 10 0 (HHH) ashow\n"))); + assert_eq!(spread.0, plain.0); + assert!(((spread.1 - plain.1) - 20).abs() <= 1, "{plain:?} {spread:?}"); + let ws = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto 15 0 72 (HHH) widthshow\n"))); + assert!(((ws.1 - plain.1) - 30).abs() <= 1, "{plain:?} {ws:?}"); + let centred = ink_x(&render(&eps(b"/Helvetica 30 selectfont 100 (HHH) stringwidth pop 2 div sub 30 moveto (HHH) show\n"))); + let mid = (centred.0 + centred.1) as f64 / 2.0; + assert!((mid - 100.0).abs() < 3.0, "{centred:?}"); + // xshow: explicit advances. + let xs = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (HH) [50 0] xshow\n"))); + let one = ink_x(&render(&eps(b"/Helvetica 30 selectfont 10 30 moveto (H) show\n"))); + assert!(((xs.1 - one.1) - 50).abs() <= 1, "{one:?} {xs:?}"); + // makefont mirrors; charpath clips; colour applies. + let doc = eps(b"1 0 0 setrgbcolor /Helvetica findfont [40 0 0 40 0 0] makefont setfont 10 30 moveto (I) false charpath clip 0 0 200 100 rectfill\n"); + let img = render(&doc); + assert!(coverage(&img, 0, 0, 200, 100) > 50.0); + assert!(coverage(&img, 60, 0, 200, 100) < 1.0, "only the glyph is painted"); +} + +#[test] +fn eps_text_with_an_embedded_type1_font_and_reencoding() { + // hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let raw = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]); + // Give the program a name. + let mut font = vec![]; + let nl = raw.iter().position(|c| *c == b'\n').unwrap() + 1; + font.extend_from_slice(&raw[..nl]); + font.extend_from_slice(b"12 dict begin /FontName /BoxFont def /FontType 1 def\n"); + font.extend_from_slice(&raw[nl..]); + let mut body = font.clone(); + body.extend_from_slice(b"\n/BoxFont findfont 100 scalefont setfont 0 0 moveto (BB) show\n"); + let doc = eps(&body); + let img = render(&doc); + // Boxes 50..550 / 650..1150 font units at size 100: x 5..55 and 65..115, y 0..50. + for (x, on) in [(30, true), (60, false), (90, true), (120, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}"); + } + // Re-encoded copy (`forall` / `definefont`): code 65 → box. + let mut body = font; + body.extend_from_slice( + b"\n/BoxFont findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall \ +/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 65 /box put currentdict end \ +/BoxA exch definefont pop /BoxA findfont 100 scalefont setfont 0 0 moveto (AB) show\n", + ); + let img = render(&eps(&body)); + assert!(img.get(30, 75)[3] > 0.99, "A is the box now"); + assert!(img.get(90, 75)[3] < 0.01, "B is unencoded"); +} /// An image XObject of `img` (black = true) CCITT-encoded with `k`, drawn over the page. fn ccitt_page(img: &[Vec], k: i64, mask: bool) -> Doc {