mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Discord feedback: "EPS files are always rasterized: they open as bitmaps, in
other programs as vectors". Every EPS saved by Illustrator 2026 fell back to
its TIFF preview (`gcheck` was unknown), and cairo/Inkscape files stopped at
their first image (`status`), dropped masked images (ImageType 3) and failed
on mesh gradients; the warning didn't say where the program failed.
Fallback never silent:
- The warning names the PostScript error, the operator that raised it and
the named procedures it ran in, innermost first ("a PostScript error
(typecheck in `add`), in `inner` in `outer`"); errors caught by `stopped`
aren't reported. It reaches the status bar, document.open, app.open and
file.open (which now return document.open's result, so MCP's open_file over
the control channel gets it), and `vectorcraft-cli info` ("warnings").
- A file whose PostScript can't be read opens as its TIFF preview, else its
Windows metafile preview (as vectors, scaled to the bounding box), else its
EPSI bitmap. es @msg rows for the new messages.
Interpreter:
- Operators programs probe or use: gcheck/scheck, rootfont,
currentcolorrendering, currenthsbcolor, status, token, read,
bytesavailable, fileposition/setfileposition, resetfile, write*, pdfmark
(marks dropped, as Ghostscript does), execform, nulldevice, strokepath,
pathforall, user paths (ufill/ueofill/ustroke/uappend/upath/setbbox, the
encoded form too), setcachedevice2, findcolorrendering, cache, device and
VM queries, revision; (%stdout) (w) file gives a sink.
- `//name` is replaced by its value when read, inside procedures too.
- Tiling patterns become pattern swatches (their PaintProc drawn in pattern
space, one cell; uncoloured ones in the colour under them; makepattern
copies share one swatch); nested patterns stop at 8 levels.
- Type 3 fonts draw their glyphs (BuildGlyph/BuildChar, widths from
setcachedevice/setcharwidth) as outlines grouped per string, spaced by
ashow/widthshow/awidthshow/xshow/yshow/xyshow/kshow; glyphshow and
stringwidth/cshow use them.
- Shadings of types 1 and 4-7 become gradient meshes (data from arrays,
strings or files, packed or not, flags sharing edges), as fills through a
shading pattern too; sampled functions (type 0) are evaluated;
GradientMesh::coons/triangle are shared with PDF import.
- ImageType 3 masked images, interleaved by sample, by row or in separate
sources; ReusableStreamDecode reads its data when made; Flate/LZW PNG and
TIFF predictors.
- Bounded: DeviceN at 32 inks, sampled functions at 2^24 samples and 32
outputs, 4096 patches a shading, strokepath ignores dashes finer than 1e5
pieces, Type 3 glyphs nest 8 deep (a glyph that shows itself overflowed the
stack before the limit), 64-bit index maths for 32-bit targets.
Tests: a synthetic program per generator (cairo/Inkscape, its Type 3 fonts,
matplotlib, CorelDRAW, Affinity, LibreOffice, Ghostscript eps2write,
Illustrator procsets), meshes and function shadings, masked images, fault
messages, probing operators, metafile/EPSI previews, `//name`, user paths,
hostile patterns/glyphs/shadings (run against main, 12 of the 14 generator
and feature tests fail; CorelDRAW- and LibreOffice-style programs already
read there). The import fuzz vocabulary covers the new operators. Checked
against real files made here with Inkscape 1.4 (cairo) and Illustrator 2026
(its own format and the CS-to-CC compatibility formats; all fell back or
stopped early on main and open as vectors now; its Illustrator 8 format
needs the next commit); those files aren't committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
4.7 KiB
Rust
95 lines
4.7 KiB
Rust
//! Export As → EPS Options, drawn headlessly.
|
|
|
|
use serde_json::json;
|
|
|
|
use super::tests_export::{app, frame, kind, names, set};
|
|
use super::*;
|
|
|
|
/// The PostScript of an EPS file.
|
|
fn ps(bytes: &[u8]) -> String {
|
|
String::from_utf8(vectorcraft_eps::sections(bytes).unwrap().0.to_vec()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn export_as_eps_shows_eps_options_then_writes_and_remembers_them() {
|
|
let (mut app, written) = app(2);
|
|
app.run("file.exportAs", json!({"format": "eps"})).unwrap();
|
|
assert_eq!(kind(&app), Some("exportAs"));
|
|
frame(&mut app);
|
|
set(&mut app, "useArtboards", json!(true));
|
|
confirm(&mut app).unwrap();
|
|
assert_eq!(kind(&app), Some("epsOptions"), "EPS Options follow");
|
|
let d = app.ui.dialog.as_ref().unwrap();
|
|
assert_eq!(
|
|
(d.str("path"), d.str("previewFormat"), d.str("flattenerPreset")),
|
|
("/out/Untitled-1.eps".into(), "tiffColor".into(), "Medium Resolution".into())
|
|
);
|
|
frame(&mut app);
|
|
// Every control draws: the black-and-white preview greys out Transparent / Opaque.
|
|
set(&mut app, "previewFormat", json!("tiffBw"));
|
|
set(&mut app, "level", json!(2));
|
|
set(&mut app, "cmykPostScript", json!(false));
|
|
frame(&mut app);
|
|
assert_eq!(app.ui.dialog.as_ref().unwrap().str("level"), "2", "a number reads as the level");
|
|
set(&mut app, "flattenerPreset", json!("High Resolution"));
|
|
let r = confirm(&mut app).unwrap();
|
|
assert!(app.ui.dialog.is_none());
|
|
// One file per artboard, named with an underscore.
|
|
assert_eq!(names(&written), ["/out/Untitled-1_Artboard-1.eps", "/out/Untitled-1_Artboard-2.eps"]);
|
|
assert_eq!(r["files"].as_array().map(Vec::len), Some(2));
|
|
let bytes = &written.borrow()[0].1;
|
|
let (text, tiff) = vectorcraft_eps::sections(bytes).unwrap();
|
|
assert!(tiff.is_some() && String::from_utf8_lossy(text).contains("%%LanguageLevel: 2"));
|
|
assert!(ps(bytes).contains(" rg\n"), "RGB PostScript");
|
|
// The next EPS Options start from these choices, but not from this export's path.
|
|
crate::menus::invoke(&mut app, "ui.epsOptionsDialog", json!({}));
|
|
let d = app.ui.dialog.as_ref().unwrap();
|
|
assert_eq!((d.str("previewFormat"), d.str("level"), d.str("flattenerPreset")), ("tiffBw".into(), "2".into(), "High Resolution".into()));
|
|
assert!(!d.bool("cmykPostScript") && !d.fields.contains_key("path"));
|
|
assert!(app.ui.eps_options.get("useArtboards").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn eps_options_take_saved_flattener_presets_and_refuse_bad_ones() {
|
|
let (mut app, written) = app(1);
|
|
app.run("flattener.presets.save", json!({"name": "Mine", "balance": 0})).unwrap();
|
|
app.run("ui.epsOptionsDialog", json!({"path": "/out/a.eps", "flattenerPreset": "medium"})).unwrap();
|
|
assert_eq!(app.ui.dialog.as_ref().unwrap().str("flattenerPreset"), "Medium Resolution", "an id shows as its name");
|
|
frame(&mut app);
|
|
set(&mut app, "flattenerPreset", json!("Nope"));
|
|
let e = confirm(&mut app).unwrap_err();
|
|
assert!(e.contains("preset"), "{e}");
|
|
assert_eq!(kind(&app), Some("epsOptions"));
|
|
set(&mut app, "flattenerPreset", json!("Mine"));
|
|
set(&mut app, "previewFormat", json!("none"));
|
|
confirm(&mut app).unwrap();
|
|
assert_eq!(names(&written), ["/out/a.eps"]);
|
|
assert!(written.borrow()[0].1.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"), "{:?}", &written.borrow()[0].1[..8]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_eps_options_dialog_needs_a_document() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
assert!(app.run("ui.epsOptionsDialog", json!({})).is_err());
|
|
assert!(!crate::menus::enabled(&app, "ui.epsOptionsDialog"));
|
|
}
|
|
|
|
/// Opening an EPS file whose PostScript reads only partly says why, in the status bar and in
|
|
/// `file.open`'s result (what `app.open` and MCP's `open_file` return): the error, the operator
|
|
/// and the procedure it ran in.
|
|
#[test]
|
|
fn opening_an_eps_that_reads_partly_says_why() {
|
|
let dir = std::env::temp_dir().join(format!("vc-eps-open-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("partial.eps");
|
|
std::fs::write(&path, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 10 10 rectfill /p { frobnicate } def p\n%%EOF\n")
|
|
.unwrap();
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
|
let r = app.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
|
let w = r["warnings"][0].as_str().unwrap();
|
|
assert!(w.contains("`frobnicate`") && w.contains("in `p`"), "{r}");
|
|
assert!(app.ui.status.contains("Opened with 1 note(s)") && app.ui.status.contains("frobnicate"), "{}", app.ui.status);
|
|
std::fs::remove_dir_all(dir).ok();
|
|
}
|