mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Setting a tiling pattern went through every pattern made so far to find one with the same PaintProc, cell and color, and went through the document's patterns again to name a new one. Each spot ink was looked up in the list of every ink used. When the reading ended, each ink and each pattern became a swatch after a search of all the swatches, and the second object of each clipping group of two was looked up in the list of every shading painted. For a file that makes n of any of these, the searches did work that grows with n squared: a dev-profile build of vectorcraft-cli info took 99 user CPU seconds on a file that makes 160,000 tiling patterns, 35 on one that uses 160,000 spot inks, and 3.2 on one that paints 160,000 shadings through clips. The patterns are now kept in a hash map, under a hash of the PaintProc's storage, the cell, and the color, tint and swatch that an uncolored pattern is set in. A lookup compares the patterns under the same hash, with the comparison it made before. The inks are kept in a map by name, and the shadings are kept in a set. The swatch names go in a set while the swatches are made. The reader's document gets its patterns only from tiling, so the name search always ended at Pattern n+1 after n patterns, and that name is now made directly. A pattern whose cell has a NaN corner never compared equal to another, and it is no longer kept for lookups. The three files now read in 1.3, 0.6 and 0.9 user CPU seconds, and the documents read are the same as before. Tests: a program that makes 200,000 tiling patterns and uses 200,001 spot inks reads within 20 seconds, with the patterns named in order; a pattern made again from its prototype, and an uncolored pattern set again in a color it was set in, are the same pattern.
1003 lines
53 KiB
Rust
1003 lines
53 KiB
Rust
//! The PostScript reader: each operator family on a small program, files the EPS writer made,
|
||
//! the preview fallback and refused files.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use vectorcraft_color::{Color, GradientKind, Paint};
|
||
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind};
|
||
use vectorcraft_geom::{Point, Rect};
|
||
|
||
use crate::import::{Dsc, import};
|
||
use crate::{EpsOptions, Level, Preview, Raster};
|
||
|
||
/// `body` as an EPS file with a 100 × 100 pt bounding box.
|
||
pub(super) fn eps(body: &str) -> Vec<u8> {
|
||
format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes()
|
||
}
|
||
|
||
pub(super) fn read(body: &str) -> crate::Imported {
|
||
import(&eps(body)).unwrap()
|
||
}
|
||
|
||
/// The objects of the document's layer.
|
||
pub(super) fn objects(d: &Document) -> Vec<Arc<Node>> {
|
||
d.layers[0].children().unwrap().clone()
|
||
}
|
||
|
||
/// Every object, down through groups, in order.
|
||
pub(super) fn all(d: &Document) -> Vec<Arc<Node>> {
|
||
fn walk(n: &Arc<Node>, out: &mut Vec<Arc<Node>>) {
|
||
out.push(n.clone());
|
||
for c in n.children().into_iter().flatten() {
|
||
walk(c, out);
|
||
}
|
||
}
|
||
let mut out = vec![];
|
||
for n in objects(d) {
|
||
walk(&n, &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
pub(super) fn fill(n: &Node) -> Option<&Paint> {
|
||
n.appearance.items.iter().find_map(|i| match i {
|
||
AppearanceItem::Fill(f) => Some(&f.paint),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
pub(super) fn stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> {
|
||
n.appearance.items.iter().find_map(|i| match i {
|
||
AppearanceItem::Stroke(s) => Some(s),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
pub(super) fn bounds(n: &Node) -> Rect {
|
||
n.geometric_bounds().unwrap()
|
||
}
|
||
|
||
pub(super) fn near(a: Rect, b: Rect) -> bool {
|
||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05)
|
||
}
|
||
|
||
#[test]
|
||
fn paths_fill_and_stroke_in_document_space() {
|
||
let r = read("newpath 10 20 moveto 60 20 lineto 60 70 lineto closepath 1 0 0 setrgbcolor fill");
|
||
let n = &objects(&r.document)[0];
|
||
// PostScript's y goes up from the bottom of the bounding box.
|
||
assert!(near(bounds(n), Rect::new(10.0, 30.0, 60.0, 80.0)), "{:?}", bounds(n));
|
||
assert_eq!(fill(n), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
// Curves and arcs.
|
||
let r = read("0 0 moveto 0 50 50 50 50 0 curveto stroke 50 50 20 0 360 arc fill");
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 2);
|
||
assert!(stroke(&o[0]).is_some() && fill(&o[0]).is_none());
|
||
assert!(near(bounds(&o[1]), Rect::new(30.0, 30.0, 70.0, 70.0)), "{:?}", bounds(&o[1]));
|
||
// Relative operators, rectangles.
|
||
let r = read("10 10 moveto 20 0 rlineto 0 20 rlineto closepath fill 5 5 10 10 rectfill 0 0 4 4 rectstroke");
|
||
let o = objects(&r.document);
|
||
assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0)));
|
||
assert!(near(bounds(&o[1]), Rect::new(5.0, 85.0, 15.0, 95.0)));
|
||
assert!(stroke(&o[2]).is_some());
|
||
}
|
||
|
||
/// Each `closepath` adds an element to the path, so on a path that holds `MAX_PATH` (1,048,576)
|
||
/// elements it stops the reading with the same error as `lineto`, run from a procedure or from an
|
||
/// encoded user path.
|
||
#[test]
|
||
fn closepath_stops_at_the_path_limit() {
|
||
for body in [
|
||
// A moveto and 1,100,000 closepaths.
|
||
"newpath 0 0 moveto 1100000 { closepath } repeat",
|
||
// An encoded user path: setbbox and moveto, then 4,750 pairs of a repeat count of 223 (the
|
||
// byte 255) and closepath (10), 1,059,250 closepaths.
|
||
"/s 9502 string def s 0 0 put s 1 1 put 2 2 9500 { dup s exch 255 put 1 add s exch 10 put } for \
|
||
newpath [ [0 0 10 10 0 0] s ] uappend",
|
||
] {
|
||
// The square drawn before the error stays, and the one after it is left out.
|
||
let r = read(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill"));
|
||
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
|
||
assert!(r.warnings[0].contains("a path has too many points"), "{body}: {:?}", r.warnings);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn colours_in_every_model() {
|
||
let r = read(
|
||
"0 0 10 10 rectfill 0.25 setgray 0 0 10 10 rectfill 0 1 0 0 setcmykcolor 0 0 10 10 rectfill 0 1 1 sethsbcolor 0 0 10 10 rectfill \
|
||
[/Separation (Gold) /DeviceCMYK {dup 0 exch 0.5 mul 0} bind] setcolorspace 0.5 setcolor 0 0 10 10 rectfill \
|
||
/DeviceRGB setcolorspace 0 0 1 setcolor 0 0 10 10 rectfill \
|
||
[/Indexed /DeviceRGB 1 <ff0000 00ff00>] setcolorspace 1 setcolor 0 0 10 10 rectfill",
|
||
);
|
||
let fills: Vec<Paint> = objects(&r.document).iter().map(|n| fill(n).unwrap().clone()).collect();
|
||
// The initial colour is black; grey is ink coverage in the document.
|
||
assert_eq!(fills[0], Paint::solid(Color::gray(1.0)));
|
||
assert_eq!(fills[1], Paint::solid(Color::gray(0.75)));
|
||
assert_eq!(fills[2], Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)));
|
||
let [r0, g0, b0] = fills[3].color().unwrap().to_rgb();
|
||
assert!(r0 > 0.99 && g0 < 0.01 && b0 < 0.01);
|
||
// A spot ink links to its spot swatch at the tint.
|
||
let Paint::Solid { swatch: Some(name), tint, .. } = &fills[4] else { panic!("{:?}", fills[4]) };
|
||
assert_eq!((name.as_str(), *tint), ("Gold", 0.5));
|
||
let gold = r.document.swatch("Gold").unwrap();
|
||
assert!(gold.spot && gold.paint.color() == Some(Color::cmyk(1.0, 0.0, 0.5, 0.0)), "{gold:?}");
|
||
assert_eq!(fills[5], Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||
assert_eq!(fills[6], Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
|
||
// Mostly CMYK art opens in CMYK.
|
||
let r = read("0 0 0 1 setcmykcolor 0 0 5 5 rectfill 1 0 0 0 setcmykcolor 0 0 5 5 rectfill");
|
||
assert_eq!(r.document.color_mode, ColorMode::Cmyk);
|
||
assert_eq!(read("1 0 0 setrgbcolor 0 0 5 5 rectfill").document.color_mode, ColorMode::Rgb);
|
||
}
|
||
|
||
#[test]
|
||
fn gsave_and_grestore_bring_the_state_back() {
|
||
let r = read("gsave 1 0 0 setrgbcolor 5 setlinewidth 50 50 translate 0 0 10 10 rectfill grestore 0 0 10 10 rectfill");
|
||
let o = objects(&r.document);
|
||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||
assert!(near(bounds(&o[0]), Rect::new(50.0, 40.0, 60.0, 50.0)));
|
||
assert_eq!(fill(&o[1]), Some(&Paint::solid(Color::gray(1.0))));
|
||
assert!(near(bounds(&o[1]), Rect::new(0.0, 90.0, 10.0, 100.0)));
|
||
// save/restore too.
|
||
let o = objects(&read("save 0.5 setgray restore 0 0 1 1 rectfill").document);
|
||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::gray(1.0))));
|
||
}
|
||
|
||
#[test]
|
||
fn transforms_concat() {
|
||
let r = read("[2 0 0 2 10 10] concat 0 0 10 10 rectfill 90 rotate 0 0 5 5 rectfill");
|
||
let o = objects(&r.document);
|
||
assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0)), "{:?}", bounds(&o[0]));
|
||
// Rotated a quarter turn about (10, 10), scaled 2: x goes up, y to the left.
|
||
assert!(near(bounds(&o[1]), Rect::new(0.0, 80.0, 10.0, 90.0)), "{:?}", bounds(&o[1]));
|
||
let r = read("10 20 translate 2 3 scale 0 0 moveto 10 10 lineto stroke");
|
||
assert!(near(bounds(&objects(&r.document)[0]), Rect::new(10.0, 50.0, 30.0, 80.0)));
|
||
// Matrix operators: transform and itransform.
|
||
let r = read("2 2 scale 3 4 transform itransform translate 0 0 1 1 rectfill");
|
||
assert!(near(bounds(&objects(&r.document)[0]), Rect::new(6.0, 90.0, 8.0, 92.0)), "{:?}", bounds(&objects(&r.document)[0]));
|
||
}
|
||
|
||
#[test]
|
||
fn line_state_reaches_the_stroke() {
|
||
let r = read("2 setlinewidth 1 setlinecap 2 setlinejoin 4 setmiterlimit [3 1] 0.5 setdash 2 2 scale 0 0 moveto 10 0 lineto stroke");
|
||
let n = &objects(&r.document)[0];
|
||
let st = stroke(n).unwrap();
|
||
// The width and dashes grow with the transform.
|
||
assert_eq!((st.width, st.cap, st.join, st.miter_limit), (4.0, LineCap::Round, LineJoin::Bevel, 4.0));
|
||
let dash = st.dash.as_ref().unwrap();
|
||
assert_eq!((dash.pattern.clone(), dash.offset), (vec![6.0, 2.0], 1.0));
|
||
// A zero-width line is a hairline.
|
||
assert!(stroke(&objects(&read("0 setlinewidth 0 0 moveto 9 9 lineto stroke").document)[0]).unwrap().width > 0.0);
|
||
}
|
||
|
||
/// `setdash` takes a dash pattern of up to 65,535 entries, the most elements an array has in a
|
||
/// typical implementation (PLRM 3rd ed., Table B.1), and raises `limitcheck` for a longer one.
|
||
#[test]
|
||
fn dash_patterns_hold_at_most_65535_entries() {
|
||
let dash = |n: usize| format!("/a {n} array def 0 1 {} {{ a exch 1 put }} for a 0 setdash", n - 1);
|
||
check(&format!("{} currentdash pop length 65535 eq", dash(65535)));
|
||
let r = read(&format!("0 0 10 10 rectfill {}", dash(65536)));
|
||
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
|
||
assert!(r.warnings.iter().any(|w| w.contains("limitcheck in `setdash`")), "{:?}", r.warnings);
|
||
}
|
||
|
||
/// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and
|
||
/// each stroke count the entries they read or copy against the budgets. Under a pattern of
|
||
/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of
|
||
/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath`
|
||
/// run out of a budget.
|
||
#[test]
|
||
fn work_on_the_longest_dash_pattern_ends_soon() {
|
||
let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash";
|
||
for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] {
|
||
let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill"));
|
||
assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings);
|
||
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
|
||
}
|
||
for (body, why) in [
|
||
("{ a 0 setdash } loop", "runs too long"),
|
||
("{ currentdash pop pop } loop", "uses too much memory"),
|
||
("{ newpath 0 0 moveto 100 0 lineto strokepath } loop", "runs too long"),
|
||
] {
|
||
let r = read_soon(&format!("{dash} {body}"));
|
||
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
|
||
assert!(r.warnings.iter().any(|w| w.contains(why)), "{body}: {:?}", r.warnings);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn procedures_bind_def_loops_and_dictionaries() {
|
||
let r = read(
|
||
"/bd {bind def} bind def /sq {newpath moveto 10 0 rlineto 0 10 rlineto -10 0 rlineto closepath fill} bd \
|
||
0 20 60 {0 sq} for 3 {} repeat /n 0 def {n 1 add /n exch def n 2 ge {exit} if} loop \
|
||
5 dict begin /x 70 def x 70 sq end \
|
||
<< /a 1 /b 2 >> /b get 2 eq {0 0 sq} if \
|
||
[1 2 3] {pop} forall (abc) length 3 eq {80 80 sq} if \
|
||
/nothing where {pop} {80 0 sq} ifelse {nonsense} stopped {90 90 moveto 95 95 lineto stroke} if",
|
||
);
|
||
let o = objects(&r.document);
|
||
// Four squares from the loop, one in the dictionary, one from get, one from length, one from
|
||
// where, the stroke after stopped.
|
||
assert_eq!(o.len(), 9, "{:?}", r.warnings);
|
||
assert!(near(bounds(&o[3]), Rect::new(60.0, 90.0, 70.0, 100.0)));
|
||
assert!(near(bounds(&o[4]), Rect::new(70.0, 20.0, 80.0, 30.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_fill_then_a_stroke_of_the_same_path_is_one_object() {
|
||
let r = read("10 10 moveto 50 50 lineto 90 10 lineto closepath gsave 1 0 0 setrgbcolor fill grestore 0 0 1 setrgbcolor stroke");
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 1);
|
||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||
assert_eq!(stroke(&o[0]).unwrap().paint, Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn clips_become_clipping_groups() {
|
||
let r = read("gsave 0 0 50 50 rectclip 0 0 100 100 rectfill 10 10 5 5 rectfill grestore 60 60 5 5 rectfill");
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 2);
|
||
let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("{:?}", o[0].kind) };
|
||
assert_eq!(children.len(), 3);
|
||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||
assert!(near(bounds(&children[0]), Rect::new(0.0, 50.0, 50.0, 100.0)));
|
||
// A clip around the whole page clips nothing.
|
||
let o = objects(&read("0 0 100 100 rectclip 1 1 5 5 rectfill").document);
|
||
assert!(matches!(o[0].kind, NodeKind::Path { .. }));
|
||
// eoclip of a path.
|
||
let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document);
|
||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn clips_nest_only_so_deep() {
|
||
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
|
||
let r = read("1 1 200000 { pop 0 0 50 50 rectclip } for 1 1 5 5 rectfill");
|
||
let mut depth = 0;
|
||
let mut n = objects(&r.document)[0].clone();
|
||
while let NodeKind::Group { children, clip: true } = &n.kind {
|
||
depth += 1;
|
||
n = children.last().unwrap().clone();
|
||
}
|
||
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
|
||
assert!(r.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn shadings_become_gradients() {
|
||
let axial =
|
||
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>";
|
||
let r = read(&format!("gsave 10 10 moveto 90 10 lineto 50 90 lineto closepath clip newpath {axial} shfill grestore"));
|
||
let o = objects(&r.document);
|
||
// The clip path filled with the gradient.
|
||
assert_eq!(o.len(), 1, "{o:?}");
|
||
let Some(Paint::Gradient(g)) = fill(&o[0]) else { panic!("{:?}", o[0]) };
|
||
assert_eq!(g.gradient.kind, GradientKind::Linear);
|
||
assert_eq!(g.gradient.stops.len(), 2);
|
||
assert_eq!(g.gradient.stops[1].color, Color::rgb(0.0, 0.0, 1.0));
|
||
let geom = g.geom.unwrap();
|
||
assert_eq!((geom.start, geom.end), (Point::new(0.0, 100.0), Point::new(100.0, 100.0)));
|
||
// Stitched functions keep their bounds as stops; radial shadings, and shading patterns.
|
||
let radial = "<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>";
|
||
let r = read(&format!("<< /PatternType 2 /Shading {radial} >> matrix makepattern setpattern 0 0 100 100 rectfill"));
|
||
let o = objects(&r.document);
|
||
let Some(Paint::Gradient(g)) = fill(&o[0]) else { panic!() };
|
||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||
let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect();
|
||
assert_eq!(offsets, vec![0.0, 0.25, 1.0]);
|
||
}
|
||
|
||
/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a
|
||
/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds.
|
||
/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made
|
||
/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a
|
||
/// color it was set in before.
|
||
#[test]
|
||
fn many_tiling_patterns_and_spot_inks_read_in_linear_time() {
|
||
let r = read(
|
||
"/pp { pop 0 0 1 1 rectfill } def \
|
||
/a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \
|
||
/b << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 3 /YStep 2 /PaintProc /pp load >> def \
|
||
/u << /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> matrix makepattern def \
|
||
a matrix makepattern setpattern 0 0 10 10 rectfill b matrix makepattern setpattern 10 0 10 10 rectfill \
|
||
a [2 0 0 2 0 0] makepattern setpattern 20 0 10 10 rectfill /DeviceRGB setcolorspace \
|
||
1 0 0 u setpattern 30 0 10 10 rectfill 0 0 1 u setpattern 40 0 10 10 rectfill 1 0 0 u setpattern 50 0 10 10 rectfill",
|
||
);
|
||
let d = &r.document;
|
||
let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect();
|
||
assert_eq!(names, ["Pattern 1", "Pattern 2", "Pattern 3", "Pattern 4"]);
|
||
let fills: Vec<String> = objects(d)
|
||
.iter()
|
||
.map(|o| match fill(o) {
|
||
Some(Paint::Pattern { pattern, .. }) => pattern.clone(),
|
||
p => panic!("{p:?}"),
|
||
})
|
||
.collect();
|
||
assert_eq!(fills, ["Pattern 1", "Pattern 2", "Pattern 1", "Pattern 3", "Pattern 4", "Pattern 3"]);
|
||
assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. }))));
|
||
// 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is
|
||
// white at every tint (only the tint tells those patterns apart), and 200,000 more inks.
|
||
let r = read_soon(
|
||
"0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \
|
||
/PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \
|
||
[/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \
|
||
/p << /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc { pop 0 0 1 1 rectfill } >> \
|
||
matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \
|
||
0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill",
|
||
);
|
||
let d = &r.document;
|
||
assert_eq!(d.patterns.len(), 200_000);
|
||
assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1)));
|
||
assert_eq!(d.swatches.iter().filter(|s| matches!(s.paint, Paint::Pattern { .. })).count(), 200_000);
|
||
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 200_001);
|
||
}
|
||
|
||
#[test]
|
||
fn images_and_image_masks() {
|
||
// 2 × 2 RGB from hex data in the file, through a procedure.
|
||
let r = read(
|
||
"10 10 translate 20 20 scale 2 2 8 [2 0 0 -2 0 2] {currentfile 6 string readhexstring pop} false 3 colorimage\nff000000ff000000ffffffff\n",
|
||
);
|
||
let o = objects(&r.document);
|
||
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?} {:?}", o, r.warnings) };
|
||
assert_eq!((im.width, im.height), (2, 2));
|
||
assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0)), "{:?}", bounds(&o[0]));
|
||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||
assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||
assert_eq!(png.get_pixel(1, 1).0, [255, 255, 255, 255]);
|
||
// The dictionary form through ASCII85 and run-length filters, grey with a decode array.
|
||
let rl = crate::ps::ascii85(&[1, 0, 255, 128]);
|
||
let r = read(&format!(
|
||
"/src currentfile /ASCII85Decode filter /RunLengthDecode filter def /DeviceGray setcolorspace \
|
||
<< /ImageType 1 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [1 0] /ImageMatrix [2 0 0 1 0 0] /DataSource src >> image\n{rl}"
|
||
));
|
||
let NodeKind::Image(im) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||
assert_eq!((png.get_pixel(0, 0).0, png.get_pixel(1, 0).0), ([255, 255, 255, 255], [0, 0, 0, 255]));
|
||
// An image mask paints the current colour where its bits say.
|
||
let r = read("1 0 0 setrgbcolor 8 1 true [8 0 0 1 0 0] <f0> imagemask");
|
||
let NodeKind::Image(im) = &objects(&r.document)[0].kind else { panic!() };
|
||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||
assert_eq!((png.get_pixel(0, 0).0, png.get_pixel(7, 0).0), ([255, 0, 0, 255], [255, 0, 0, 0]));
|
||
}
|
||
|
||
#[test]
|
||
fn type_becomes_point_type_in_the_font_named() {
|
||
let r = read("/Helvetica-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show (!) show");
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 2);
|
||
let NodeKind::Text(t) = &o[0].kind else { panic!() };
|
||
assert_eq!(t.plain_text(), "Hi");
|
||
let style = &t.runs[0].style;
|
||
assert_eq!((style.font_family.as_str(), style.font_style.as_str()), ("Helvetica", "Bold"));
|
||
assert!((style.size - 12.0).abs() < 1e-6);
|
||
// On the baseline at (10, 80) in the document, upright.
|
||
assert!((t.xf.translation().x - 10.0).abs() < 1e-6 && (t.xf.translation().y - 80.0).abs() < 1e-6, "{:?}", t.xf);
|
||
assert!((t.xf.as_coeffs()[0] - 1.0).abs() < 1e-6 && (t.xf.as_coeffs()[3] - 1.0).abs() < 1e-6, "{:?}", t.xf);
|
||
// The second show starts where the first ended.
|
||
let NodeKind::Text(t2) = &o[1].kind else { panic!() };
|
||
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()));
|
||
}
|
||
|
||
#[test]
|
||
fn the_writers_own_output_reads_back() {
|
||
use vectorcraft_doc::{Appearance, Dash};
|
||
use vectorcraft_geom::shapes;
|
||
let mut d = Document::new(200.0, 100.0);
|
||
let layer = d.layers[0].id;
|
||
let look = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||
let mut red = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), look);
|
||
if let Some(AppearanceItem::Stroke(s)) = red.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||
s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||
s.cap = LineCap::Round;
|
||
}
|
||
d.insert(Some(layer), 0, red).unwrap();
|
||
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
||
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(110.0, 50.0), end: Point::new(190.0, 50.0), aspect: 1.0, focal: None });
|
||
let grad = Node::path(
|
||
d.alloc_id(),
|
||
shapes::ellipse(Rect::new(110.0, 10.0, 190.0, 90.0)),
|
||
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
|
||
);
|
||
d.insert(Some(layer), 1, grad).unwrap();
|
||
for level in [Level::Three, Level::Two] {
|
||
let r = d.artboards[0].rect;
|
||
let o = EpsOptions { region: r, origin: Point::new(r.x0, r.y1), preview: Preview::None, level, ..EpsOptions::default() };
|
||
let bytes = crate::export(&d, &o, None, None).unwrap().bytes;
|
||
let back = import(&bytes).unwrap();
|
||
assert!(back.warnings.is_empty(), "{level:?}: {:?}", back.warnings);
|
||
assert_eq!(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
|
||
let objs = all(&back.document);
|
||
let rect = objs.iter().find(|n| fill(n).is_some_and(|p| p.color() == Some(Color::rgb(1.0, 0.0, 0.0)))).unwrap();
|
||
assert!(near(bounds(rect), Rect::new(10.0, 10.0, 90.0, 60.0)), "{:?}", bounds(rect));
|
||
let st = objs.iter().find_map(|n| stroke(n)).unwrap();
|
||
assert_eq!((st.width, st.cap, st.dash.as_ref().unwrap().pattern.clone()), (4.0, LineCap::Round, vec![6.0, 3.0]));
|
||
// Level 3 writes the gradient as a shading (a gradient again); level 2 as bands.
|
||
let gradients = objs.iter().filter(|n| matches!(fill(n), Some(Paint::Gradient(_)))).count();
|
||
assert_eq!(gradients, usize::from(level == Level::Three), "{level:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_unreadable_program_falls_back_to_the_preview() {
|
||
let ps = eps("0 0 10 10 rectfill frobnicate");
|
||
let tiff = crate::tiff::encode(&Raster { width: 4, height: 4, rgba: [0, 128, 0, 255].repeat(16) }, false, false).unwrap();
|
||
let bytes = crate::dos_eps(&ps, &tiff).unwrap();
|
||
let r = import(&bytes).unwrap();
|
||
assert!(r.preview);
|
||
assert!(r.warnings[0].contains("frobnicate") && r.warnings[0].contains("preview"), "{:?}", r.warnings);
|
||
let o = objects(&r.document);
|
||
let NodeKind::Image(im) = &o[0].kind else { panic!() };
|
||
assert_eq!((im.width, im.height), (4, 4));
|
||
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||
// Without a preview, what was drawn before the error stays, with a warning.
|
||
let r = import(&ps).unwrap();
|
||
assert!(!r.preview && objects(&r.document).len() == 1);
|
||
assert!(r.warnings[0].contains("frobnicate"), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn garbage_and_runaway_programs_are_refused() {
|
||
assert!(import(b"").is_err());
|
||
assert!(import(b"hello world").is_err());
|
||
assert!(import(&[0xC5, 0xD0, 0xD3, 0xC6, 0xff, 0xff]).is_err());
|
||
let e = import(b"%!PS\nfoo bar").unwrap_err();
|
||
assert!(e.contains("foo"), "{e}");
|
||
for body in ["{} loop", "/f {f} def f", "0 1 1e300 {pop} for", "[ 0 1 200000 {} for ]", "1000000000 array", "(abc", "{ 1 2"] {
|
||
assert!(import(&eps(body)).is_err(), "{body}");
|
||
}
|
||
// Damaged numbers draw nothing far away.
|
||
let r = read("1e300 1e300 moveto 1e301 0 lineto stroke 0 0 1 1 rectfill");
|
||
assert_eq!(objects(&r.document).len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn the_bounding_box_comes_from_the_header_or_the_trailer() {
|
||
let d = Dsc::read(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 1 2 30 40\n%%HiResBoundingBox: 1.5 2.5 29.5 39.5\n%%EndComments\n");
|
||
assert_eq!(d.bbox, Some([1.5, 2.5, 29.5, 39.5]));
|
||
let d = Dsc::read(b"%!PS-Adobe-3.0\n%%BoundingBox: (atend)\n%%Pages: 3\n%%EndComments\nshowpage\n%%Trailer\n%%BoundingBox: 0 0 10 20\n");
|
||
assert_eq!((d.bbox, d.pages), (Some([0.0, 0.0, 10.0, 20.0]), Some(3)));
|
||
let r = import(b"%!PS-Adobe-3.0\n%%Pages: 2\n0 0 5 5 rectfill showpage 0 0 9 9 rectfill showpage\n").unwrap();
|
||
// No bounding box: a letter page; only the first page.
|
||
assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
|
||
assert_eq!(objects(&r.document).len(), 1);
|
||
assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_font_programs_are_skipped() {
|
||
let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\
|
||
0123456789abcdef garbage that is encrypted\n0000000000000000000000000000000000000000000000000000000000000000\ncleartomark\n\
|
||
/MyFont findfont 10 scalefont setfont 10 10 moveto (x) show";
|
||
let r = read(body);
|
||
let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||
assert_eq!(t.runs[0].style.font_family, "My Font");
|
||
}
|
||
|
||
/// A document with what the writer writes in its own ways: gradients, type, an image with
|
||
/// transparent pixels, a clipping group, a spot colour, dashes.
|
||
fn rich_doc() -> Document {
|
||
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint, Swatch};
|
||
use vectorcraft_doc::{Appearance, CharStyle, ImageBlob, ImageObject, TextObject};
|
||
use vectorcraft_geom::{Affine, shapes};
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let layer = d.layers[0].id;
|
||
let add = |d: &mut Document, n: Node| d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
let solid = |r, g, b| Paint::solid(Color::rgb(r, g, b));
|
||
let n = Node::path(
|
||
d.alloc_id(),
|
||
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)),
|
||
Appearance::basic(solid(1.0, 0.0, 0.0), solid(0.0, 0.0, 1.0), 4.0),
|
||
);
|
||
add(&mut d, n);
|
||
for (kind, r) in [(GradientKind::Linear, Rect::new(110.0, 10.0, 190.0, 90.0)), (GradientKind::Radial, Rect::new(200.0, 10.0, 290.0, 90.0))] {
|
||
let mut g = GradientPaint::new(Gradient { kind, ..Gradient::default() });
|
||
g.geom = Some(GradientGeom::fit(kind, r, 0.0));
|
||
let n = Node::path(d.alloc_id(), shapes::ellipse(r), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0));
|
||
add(&mut d, n);
|
||
}
|
||
let style = CharStyle { size: 30.0, fill: solid(0.0, 0.5, 0.0), stroke: Paint::None, ..CharStyle::default() };
|
||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 120.0), "Type", style))));
|
||
add(&mut d, n);
|
||
let mut img = image::RgbaImage::new(8, 4);
|
||
for (x, _, p) in img.enumerate_pixels_mut() {
|
||
*p = image::Rgba(if x < 4 { [0, 0, 255, 255] } else { [255, 200, 0, 255] });
|
||
}
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
d.images.insert("pic".into(), ImageBlob::new("image/png", png));
|
||
let im = ImageObject {
|
||
key: "pic".into(),
|
||
width: 8,
|
||
height: 4,
|
||
xf: Affine::new([10.0, 0.0, 0.0, 10.0, 110.0, 110.0]),
|
||
link: None,
|
||
placement: Default::default(),
|
||
};
|
||
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||
add(&mut d, n);
|
||
d.swatches.push(Swatch { name: "Spot".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true });
|
||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Spot".into()), tint: 1.0 };
|
||
let mut clip = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(200.0, 110.0, 290.0, 190.0)), Appearance::default());
|
||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||
*clipping = true;
|
||
}
|
||
let mut inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(190.0, 100.0, 300.0, 150.0)), Appearance::basic(spot, Paint::None, 0.0));
|
||
inner.appearance.items.push(AppearanceItem::Stroke({
|
||
let mut st = vectorcraft_doc::StrokeLayer::new(solid(0.0, 0.0, 0.0), 2.0);
|
||
st.dash = Some(vectorcraft_doc::Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false });
|
||
st
|
||
}));
|
||
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inner)], clip: true });
|
||
add(&mut d, g);
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn the_writers_output_reads_back_looking_the_same() {
|
||
let d = rich_doc();
|
||
let r = d.artboards[0].rect;
|
||
let mut renderer = vectorcraft_render::Renderer::new();
|
||
let want = renderer.render_region(&d, r, 1.0, true);
|
||
for level in [Level::Three, Level::Two] {
|
||
let o = EpsOptions { region: r, origin: Point::new(r.x0, r.y1), preview: Preview::None, level, ..EpsOptions::default() };
|
||
let back = import(&crate::export(&d, &o, None, None).unwrap().bytes).unwrap();
|
||
assert!(back.warnings.is_empty(), "{level:?}: {:?}", back.warnings);
|
||
assert!(back.document.swatch("Spot").is_some_and(|s| s.spot), "{level:?}");
|
||
let got = renderer.render_region(&back.document, back.document.artboards[0].rect, 1.0, true);
|
||
assert_eq!((got.width, got.height), (want.width, want.height));
|
||
// Pixels that differ visibly: anti-aliased edges, gradient steps at level 2.
|
||
let off = want.pixels.chunks(4).zip(got.pixels.chunks(4)).filter(|(a, b)| a.iter().zip(b.iter()).any(|(x, y)| x.abs_diff(*y) > 48)).count();
|
||
assert!(off * 100 < want.pixels.len() / 4, "{level:?}: {off} pixels differ");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn device_settings_are_accepted_and_change_nothing() {
|
||
let r = read(
|
||
"currentscreen setscreen currenttransfer settransfer currentcolortransfer setcolortransfer currenthalftone sethalftone \
|
||
currentblackgeneration setblackgeneration currentundercolorremoval setundercolorremoval 1 setflat true setstrokeadjust \
|
||
<< /PageSize [100 100] >> setpagedevice 1000 setcachelimit mark 1 2 setucacheparams ucache /StandardEncoding findencoding pop \
|
||
0 0 5 5 rectfill",
|
||
);
|
||
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
}
|
||
|
||
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
|
||
fn blue(body: &str) {
|
||
let r = read(&format!("{body} 10 10 80 80 rectfill"));
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
|
||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
|
||
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
|
||
}
|
||
|
||
/// Check that PostScript `cond` leaves `true`.
|
||
pub(super) fn check(cond: &str) {
|
||
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_generic_category_defines_new_categories() {
|
||
// #234: the reference app's prolog.
|
||
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
|
||
blue(
|
||
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
|
||
);
|
||
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
|
||
/G1 << /Kind 7 >> /Gradient defineresource pop";
|
||
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
|
||
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
|
||
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
|
||
// Unknown categories aren't instances of Category; a category is a dictionary.
|
||
check("/Nonsense /Category resourcestatus not");
|
||
check("{ /Nonsense /Category findresource } stopped");
|
||
check("{ /X 5 /Category defineresource } stopped");
|
||
}
|
||
|
||
#[test]
|
||
fn clipsave_and_cliprestore_bring_the_clip_back() {
|
||
// #234.
|
||
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
|
||
// The fill after cliprestore covers the whole square, unclipped.
|
||
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 2, "{:?}", r.warnings);
|
||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
|
||
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
|
||
// Nested, leaving the rest of the graphics state alone.
|
||
let r = read(
|
||
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
|
||
10 10 80 80 rectfill",
|
||
);
|
||
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
|
||
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
|
||
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
|
||
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
|
||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||
}
|
||
|
||
#[test]
|
||
fn languagelevel_is_an_integer_and_runs() {
|
||
// #234.
|
||
blue(
|
||
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
|
||
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
|
||
);
|
||
check("languagelevel 3 eq");
|
||
check("{ languagelevel } bind exec 3 eq");
|
||
}
|
||
|
||
#[test]
|
||
fn intervals_share_their_arrays_and_strings() {
|
||
// #234: astore into a subarray stores into the array.
|
||
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
|
||
// put, putinterval and copy into an interval; an interval of an interval.
|
||
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
|
||
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
|
||
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
|
||
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
|
||
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
|
||
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
|
||
// The same interval is the same object; another one is not.
|
||
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
|
||
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
|
||
// Reads and writes stay inside the interval.
|
||
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
|
||
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
|
||
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
|
||
// A procedure from an interval runs only its part.
|
||
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
|
||
// The prolog's stack save: the operands into part of one array, and back.
|
||
check(
|
||
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
|
||
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resourceforall_enumerates_matching_names() {
|
||
// #234.
|
||
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
|
||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
|
||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
|
||
check(&format!("{define} {} 4 eq", count("*")));
|
||
check(&format!("{define} {} 2 eq", count("Fo?")));
|
||
check(&format!("{define} {} 3 eq", count("F*")));
|
||
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
|
||
check(&format!("{define} {} 0 eq", count("Q*")));
|
||
check(&format!("{define} {} 1 eq", count("*a*")));
|
||
// Each name is copied into the scratch string; exit stops.
|
||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
|
||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
|
||
// A scratch string too small for the names, as the prolog probes it.
|
||
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
|
||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||
}
|
||
|
||
#[test]
|
||
fn the_standard_dictionaries_are_dictionaries_in_systemdict() {
|
||
// The reference app's EPS prolog looks them up by name rather than running them.
|
||
check("systemdict /userdict get type /dicttype eq");
|
||
check("/globaldict where { /globaldict get begin currentdict globaldict eq end } { false } ifelse");
|
||
check("systemdict /systemdict get systemdict eq systemdict /statusdict known and");
|
||
check("userdict /x 5 put systemdict /userdict get /x get 5 eq");
|
||
check("/errordict load /handleerror known errordict $error eq and");
|
||
check("SharedFontDirectory type /dicttype eq");
|
||
}
|
||
|
||
#[test]
|
||
fn dictstack_and_internaldict_answer() {
|
||
check("10 array dictstack length countdictstack eq");
|
||
check("userdict begin 10 array dictstack dup length 1 sub get userdict eq end");
|
||
check("{ 1 array dictstack } stopped");
|
||
check("1183615869 internaldict /k 1 put 1183615869 internaldict /k get 1 eq");
|
||
check("currentsystemparams /MaxPatternCache get type /integertype eq");
|
||
check("<< /MaxPatternItem currentsystemparams /MaxPatternCache get >> setuserparams currentuserparams /MaxFontItem known");
|
||
}
|
||
|
||
#[test]
|
||
fn an_executable_filter_runs_its_data_as_a_program() {
|
||
// A string run as a program through a filter (0 and an empty string set no end of data).
|
||
check("(1 2 add) 0 () /SubFileDecode filter cvx exec 3 eq");
|
||
check("(1 2 add) 0 () /SubFileDecode filter dup xcheck not exch cvx xcheck and");
|
||
check("(abcd) 2 () /SubFileDecode filter 8 string readstring pop (ab) eq");
|
||
check("{ (1 add) 0 () /SubFileDecode filter cvx exec } stopped");
|
||
// The data following it in the file: `74727565` is `true`.
|
||
check("currentfile /ASCIIHexDecode filter cvx exec 74727565>");
|
||
// A literal file is only pushed.
|
||
check("(1 2 add) 0 () /SubFileDecode filter exec type /filetype eq");
|
||
}
|
||
|
||
#[test]
|
||
fn flushfile_skips_the_data_of_a_filter() {
|
||
// Metadata that isn't PostScript, skipped up to its end marker.
|
||
blue("currentfile 0 (%%EndPacket) /SubFileDecode filter flushfile\n<?xml version=\"1.0\"?><x a=\"(\"/>\n%%EndPacket\n0 0 1 setrgbcolor");
|
||
}
|
||
|
||
/// Executable files are untrusted programs too: running one inside itself stops at the nesting
|
||
/// limit, and the program's own file runs once. Type laid out in a loop (`cshow` on a long
|
||
/// string, `stringwidth` in `loop`) counts against the operation budget like the loop itself.
|
||
#[test]
|
||
fn hostile_executable_files_and_type_loops_are_refused() {
|
||
let refused = |body: &str, why: &str| {
|
||
let e = import(&eps(body)).err().unwrap_or_default();
|
||
assert!(e.contains(why), "{body}: {e}");
|
||
};
|
||
refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "too deeply");
|
||
refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def { p } stopped pop p", "too deeply");
|
||
refused("/a { (b) 0 () /SubFileDecode filter cvx exec } def /b { (a) 0 () /SubFileDecode filter cvx exec } def a", "too deeply");
|
||
refused("/Helvetica findfont 10 scalefont setfont { pop pop pop } 1000000 string cshow", "too long");
|
||
// The program's own file, made executable, is already running.
|
||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||
}
|
||
|
||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||
/// more than 20 s.
|
||
fn read_soon(body: &str) -> crate::Imported {
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
let ps = eps(body);
|
||
std::thread::spawn(move || tx.send(import(&ps)).ok());
|
||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
|
||
}
|
||
|
||
/// Reads `body` after a square through [`read_soon`]: the square stays, and the program runs out
|
||
/// of the operation budget.
|
||
fn runs_out_of_budget(body: &str) {
|
||
let r = read_soon(&format!("0 0 10 10 rectfill {body}"));
|
||
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);
|
||
}
|
||
|
||
/// A loop of operators whose work grows with the path they make runs out of the operation budget
|
||
/// (PLRM 3rd ed., chapter 8): `arc` and `arct` with a huge radius, which make thousands of curve
|
||
/// segments, `strokepath` with a dash far finer than the line, dashes of length zero included, and
|
||
/// `rectfill` with an array of 2,000 rectangles.
|
||
#[test]
|
||
fn paths_made_in_a_loop_run_out_of_the_budget() {
|
||
for body in [
|
||
"{ newpath 0 0 2.2e18 0 1440 arc } loop",
|
||
"{ newpath 0 0 moveto 1 0 1 1 1e30 arct } loop",
|
||
"[1] 0 setdash { newpath 0 0 moveto 9900 0 lineto strokepath } loop",
|
||
"[0 1] 0 setdash { newpath 0 0 moveto 9900 0 lineto strokepath } loop",
|
||
"/a [ 0 1 1999 { pop 0 0 1 1 } for ] def nulldevice { a rectfill } loop",
|
||
] {
|
||
runs_out_of_budget(body);
|
||
}
|
||
}
|
||
|
||
/// A loop of operators that read or copy the whole current path or clip path runs out of the
|
||
/// operation budget (PLRM 3rd ed., chapter 8): `pathbbox`, `clip`, `clippath`, `shfill` (which
|
||
/// reads the clip path for the area it paints), `pathforall` (which copies the path before it runs
|
||
/// a procedure), `gstate`, `currentgstate` and `setgstate`.
|
||
#[test]
|
||
fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() {
|
||
// 10,001 elements reaching 10,000,000 points to the right: what `shfill` paints in a clip of
|
||
// them lies far outside the page and is left out.
|
||
let path = "newpath 0 0 moveto 0 1 9999 { pop 1000 0 rlineto } for";
|
||
let sh = "/sh << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 10 0] \
|
||
/Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> def";
|
||
for body in [
|
||
"{ pathbbox pop pop pop pop } loop",
|
||
"{ clip } loop",
|
||
"clip newpath { clippath } loop",
|
||
"clip newpath { sh shfill } loop",
|
||
"{ { pop pop exit } { } { } { } pathforall } loop",
|
||
"{ gstate pop } loop",
|
||
"/g gstate def { g currentgstate pop } loop",
|
||
"/g gstate def { g setgstate } loop",
|
||
] {
|
||
runs_out_of_budget(&format!("{sh} {path} {body}"));
|
||
}
|
||
}
|
||
|
||
/// A loop of `uappend` on an encoded user path runs out of the operation budget (PLRM 3rd ed.,
|
||
/// section 4.6.2 and Table 4.3: each byte of the operator string is an operator, or a count of up
|
||
/// to 223 repeats of the next one): one that runs `ucache` millions of times, one of repeat counts
|
||
/// alone, and one with a long data array.
|
||
#[test]
|
||
fn encoded_user_paths_in_a_loop_run_out_of_the_budget() {
|
||
for body in [
|
||
// 100,000 pairs of a count of 223 repeats (byte 255) and `ucache` (byte 11): each
|
||
// `uappend` runs `ucache` 22,300,000 times.
|
||
"/s 200000 string def 0 2 199998 { dup s exch 255 put 1 add s exch 11 put } for /up [ [ ] s ] def",
|
||
// 200,000 repeat counts and no operator.
|
||
"/s 200000 string def 0 1 199999 { s exch 255 put } for /up [ [ ] s ] def",
|
||
// A data array of 400,000 nulls and an empty operator string.
|
||
"/up [ 400000 array () ] def",
|
||
] {
|
||
runs_out_of_budget(&format!("{body} {{ up uappend }} loop"));
|
||
}
|
||
}
|
||
|
||
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
|
||
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
|
||
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
|
||
/// the second angle, the new current point.
|
||
#[test]
|
||
fn arcs_with_huge_angles_end_at_their_second_angle() {
|
||
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
|
||
// document space.
|
||
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
|
||
// Each program is listed with the remainders of its first and second angles after whole turns.
|
||
for (body, first, second) in [
|
||
("50 50 10 1e18 0 arc", 280.0, 0.0),
|
||
("50 50 10 1e308 0 arc", 296.0, 0.0),
|
||
("50 50 10 0 1e18 arcn", 0.0, 280.0),
|
||
("50 50 10 0 1e308 arcn", 0.0, 296.0),
|
||
] {
|
||
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
|
||
let o = objects(&r.document);
|
||
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
|
||
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
|
||
let (a, b) = (at(first), at(second));
|
||
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
|
||
// The second object is the square drawn at the current point.
|
||
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
|
||
}
|
||
}
|
||
|
||
/// An axial shading's function is one function dictionary or an array of one for each color
|
||
/// component (PLRM 3rd ed., Table 4.13). One evaluation visits at most 257 functions, and past
|
||
/// that the shading raises `limitcheck`: when four levels of arrays each have 100 entries that all
|
||
/// refer to the same array of the level below (at the last level, the same function), one
|
||
/// evaluation would visit 101,010,101 functions. Each function visited also counts against the
|
||
/// operation budget.
|
||
#[test]
|
||
fn shading_functions_in_shared_arrays_stop_at_the_limits() {
|
||
let axial = |function: &str| format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function {function} >>");
|
||
let f = "/f << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def";
|
||
// `a`: an array of `n` references to the function `f`.
|
||
let array = |n: usize| format!("{f} /a {n} array def 0 1 {} {{ a exch f put }} for", n - 1);
|
||
// Each level is an array of 100 references to the level below.
|
||
let levels = "/l2 100 array def 0 1 99 { l2 exch a put } for /l3 100 array def 0 1 99 { l3 exch l2 put } for \
|
||
/l4 100 array def 0 1 99 { l4 exch l3 put } for";
|
||
let limitcheck = |r: &crate::Imported| r.warnings.iter().any(|w| w.contains("limitcheck in `shfill`: Function"));
|
||
let r = read_soon(&format!("0 0 10 10 rectfill {} {levels} {} shfill", array(100), axial("l4")));
|
||
assert!(limitcheck(&r) && objects(&r.document).len() == 1, "{:?}", r.warnings);
|
||
// 256 references are 257 functions for each sample, the most allowed; one more raises `limitcheck`.
|
||
let r = read_soon(&format!("{} {} shfill", array(256), axial("a")));
|
||
let o = objects(&r.document);
|
||
assert!(r.warnings.is_empty() && matches!(fill(&o[0]), Some(Paint::Gradient(_))), "{:?}", r.warnings);
|
||
let r = read_soon(&format!("0 0 10 10 rectfill {} {} shfill", array(257), axial("a")));
|
||
assert!(limitcheck(&r) && objects(&r.document).len() == 1, "{:?}", r.warnings);
|
||
// Each of these shadings visits 33 * 257 functions (33 samples), and 2000 of them count for
|
||
// more than the operation budget.
|
||
let r = read(&format!("{} /sh {} def 2000 {{ sh shfill }} repeat", array(256), axial("a")));
|
||
assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn cshow_runs_its_procedure_for_each_character() {
|
||
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
|
||
// Each call gets the character's code and width.
|
||
check("/c 0 def { pop pop /c exch def } (A) cshow c 65 eq");
|
||
check(
|
||
"/Helvetica findfont 10 scalefont setfont /w 0 def { pop w add /w exch def pop } (ab) cshow \
|
||
(ab) stringwidth pop w sub abs 0.001 lt",
|
||
);
|
||
}
|
||
|
||
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
|
||
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
|
||
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
|
||
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
|
||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||
// Shorts in an entry, left-justified.
|
||
let shorts = |v: &[u16]| -> [u8; 4] {
|
||
let mut f = [0; 4];
|
||
for (i, s) in v.iter().enumerate() {
|
||
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
|
||
}
|
||
f
|
||
};
|
||
let row = u32::from(w) * 2;
|
||
let mut out = vec![];
|
||
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
|
||
out.extend([0; 4]);
|
||
let data_at = out.len() as u32;
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
|
||
}
|
||
}
|
||
let map_at = out.len() as u32;
|
||
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
|
||
for i in 0..256 {
|
||
out.extend(u16b(if i == c { full } else { 0 }));
|
||
}
|
||
}
|
||
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
|
||
let offsets_at = out.len() as u32;
|
||
for k in &strips {
|
||
out.extend(u32b(data_at + u32::from(k * per) * row));
|
||
}
|
||
let counts_at = out.len() as u32;
|
||
for k in &strips {
|
||
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
|
||
}
|
||
let ifd = out.len() as u32;
|
||
out[4..8].copy_from_slice(&u32b(ifd));
|
||
let n = strips.len() as u32;
|
||
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
|
||
(256, 3, 1, shorts(&[w])),
|
||
(257, 3, 1, shorts(&[h])),
|
||
(258, 3, 2, shorts(&[8, 8])),
|
||
(259, 3, 1, shorts(&[1])),
|
||
(262, 3, 1, shorts(&[3])),
|
||
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
|
||
(277, 3, 1, shorts(&[2])),
|
||
(278, 3, 1, shorts(&[per])),
|
||
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
|
||
(284, 3, 1, shorts(&[1])),
|
||
(320, 3, 768, u32b(map_at)),
|
||
(338, 3, 1, shorts(&[1])),
|
||
];
|
||
out.extend(u16b(entries.len() as u16));
|
||
for (tag, kind, count, field) in entries {
|
||
out.extend(u16b(tag));
|
||
out.extend(u16b(kind));
|
||
out.extend(u32b(count));
|
||
out.extend(field);
|
||
}
|
||
out.extend([0; 4]);
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn a_palette_preview_with_alpha_is_placed() {
|
||
// #234: the program fails, so the preview is placed.
|
||
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
|
||
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
|
||
let tiff = palette_tiff(20, 10, per, big);
|
||
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
|
||
assert!(r.preview, "{big} {per}");
|
||
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
|
||
let o = objects(&r.document);
|
||
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
|
||
assert_eq!((im.width, im.height), (20, 10));
|
||
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
|
||
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
|
||
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_damaged_palette_preview_is_refused_without_panicking() {
|
||
let tiff = palette_tiff(20, 10, 5, false);
|
||
assert!(crate::tiff::palette_rgba(&tiff).is_some());
|
||
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
|
||
for cut in 0..tiff.len() - 6 {
|
||
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
|
||
}
|
||
// Any byte changed: a picture or nothing.
|
||
for big in [false, true] {
|
||
let tiff = palette_tiff(20, 10, 5, big);
|
||
for at in 0..tiff.len() {
|
||
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
|
||
let mut t = tiff.clone();
|
||
t[at] = v;
|
||
let _ = crate::tiff::palette_rgba(&t);
|
||
}
|
||
}
|
||
}
|
||
// A damaged preview of an unreadable program: the hard error.
|
||
let ps = eps("frobnicate");
|
||
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
|
||
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
|
||
assert!(e.contains("can't be read"), "{e}");
|
||
}
|
||
}
|