Read Illustrator EPS and .ai layers from their editing data
An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of the document; a PDF-compatible .ai carries the same in its private data. Importing only the page lost layer names, order, visibility, hidden layers and hidden objects and hidden text. - eps: read the editing copy (Zstandard %AI24_DataStream, older zlib %AI9_DataStream) into layers, sublayers, groups, compound paths, clip groups and hidden objects, run by the existing PostScript interpreter. Falls back to the page, with a warning, when a shown layer uses an operator that isn't read or the art differs from the page by over 5%. - eps: read the text document (ate.rs) to place type on hidden layers and objects; the page's type is matched to the file's text objects by position (pieces of a line included) and kept with the nearest one when none claims it. Area type and type on a path are left out with a note. - pdf/engine: .ai files go through the same reader. - engine/cli: a file whose import left things out isn't overwritten unless asked (acknowledgeLoss, --replace-lossy). - eps: font names re-encoded with a *<n> suffix resolve to their family. - i18n: es/it/ru for the new import messages; ROADMAP updated. Container and text-document formats were inferred from user-owned files; the Adobe file format specification was not read. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f149f10059
commit
ec43ac5a1e
|
|
@ -62,6 +62,8 @@ skrifa = "0.47"
|
|||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ since 2026-10-01.
|
|||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for layers, hidden objects and hidden type (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
|
|
@ -249,7 +249,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
|
|||
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Native `.ai` private data, beyond layer structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams) is not documented as a whole. We read only the part that is the legacy Illustrator file format: layers (names, order, colour, hidden), nested layers, groups, compound paths, clipping groups, hidden objects, and plain path/colour operators. It was worked out from files the contributors have the right to use; Adobe's published specification of that format was not used. Its Zstandard wrapper is the public [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878) and the ASCII85 wrapper is PostScript Level 2's. Everything else in it (the app's private `%_` comments, its binary text document, live effects, brushes, gradients, images) is not read: such a file comes in as its page, with a warning, as before. Type that shows comes from the page, which draws it; type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type: the keys were checked against the page's own type in the owner's files and are used only where they matched), and area type and type on a path in it are left out with a note. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Each check is a render comparison against the page, which rejects the layers if they differ by more than 5%. The importer was built from public documentation and from files users own, used locally and never committed; no Adobe software was run. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
|
||||
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
|
|
@ -71,12 +71,14 @@ USAGE:
|
|||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths.
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
|
|
@ -173,7 +175,7 @@ fn commands() -> Result<(), String> {
|
|||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
|
|
@ -181,6 +183,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
|||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
"--replace-lossy" => replace_lossy = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -189,7 +192,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
|||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true});
|
||||
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub struct Clip {
|
|||
pub id: u32,
|
||||
/// The region and its rule; `None` for a plain group.
|
||||
pub region: Option<(BezPath, FillRule)>,
|
||||
/// The group is hidden (its art is in it, not shown).
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
|
||||
|
|
@ -78,5 +80,7 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<N
|
|||
children.push(Arc::new(path));
|
||||
}
|
||||
children.extend(nest_at(doc, items, depth + 1));
|
||||
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
|
||||
let mut group = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() });
|
||||
group.visible = !c.hidden;
|
||||
Arc::new(group)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,3 +40,5 @@ crc32fast = { workspace = true }
|
|||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
# Zstandard streams for the Illustrator editing data fuzz tests.
|
||||
ruzstd = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path");
|
||||
if let Some(path) = path {
|
||||
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
|
||||
}
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let expanded = expand_presets(s, "document.export", p)?;
|
||||
let p = &*expanded;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,23 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
|||
load_with(name, bytes, &LoadOptions::default())
|
||||
}
|
||||
|
||||
/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its
|
||||
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
|
||||
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
||||
if opts.pages.is_some() || !opts.layers {
|
||||
return (doc, warnings);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings);
|
||||
// The note that the art outside the artboard is lost is the reason the layers weren't read.
|
||||
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
|
||||
return (doc, warnings);
|
||||
}
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
|
|
@ -171,7 +188,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
|
||||
.flatten()
|
||||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
|
||||
restore_or_import(editing, || {
|
||||
let (doc, warnings) = super::pdfimport::import(bytes, opts)?;
|
||||
Ok(through_editing_data(bytes, opts, doc, warnings))
|
||||
})?
|
||||
}
|
||||
"dxf" => {
|
||||
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
|
||||
|
|
@ -244,6 +264,9 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
|||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
|
||||
// What reading the file left out of the document, if anything.
|
||||
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
|
||||
let source_path = path.clone().filter(|_| !losses.is_empty());
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
|
|
@ -266,6 +289,8 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &Loa
|
|||
st.format = format.id;
|
||||
}
|
||||
st.converted = converted;
|
||||
st.imported_from = source_path;
|
||||
st.import_losses = losses;
|
||||
let title = s.documents()[index].title();
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
|
||||
}
|
||||
|
|
@ -360,3 +385,36 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
|||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Does this import note say that the file has something the document doesn't?
|
||||
fn is_loss(note: &str) -> bool {
|
||||
note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note)
|
||||
}
|
||||
|
||||
/// Writing the file `target` for document `st`: not over the file `st` was read from when reading
|
||||
/// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`.
|
||||
/// Another name keeps the original file, with all it has.
|
||||
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
|
||||
let Some(source) = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty()) else { return Ok(()) };
|
||||
if bool_or(p, "acknowledgeLoss", false) || !same_file(source, target) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut what = st.import_losses.iter().take(3).map(|n| format!("“{n}”")).collect::<Vec<_>>().join("; ");
|
||||
if st.import_losses.len() > 3 {
|
||||
what.push_str(&format!("; {} more", st.import_losses.len() - 3));
|
||||
}
|
||||
Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Do `a` and `b` name one file (spelled differently, or through a link)?
|
||||
fn same_file(a: &str, b: &str) -> bool {
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc
|
|||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
pub(crate) use export::isolated;
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::native_file;
|
||||
|
|
|
|||
|
|
@ -232,6 +232,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
|||
_ => st.path.as_deref().and_then(parent_folder),
|
||||
};
|
||||
let modified = date_param(p, "modified", cmd)?;
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
}
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,11 @@ pub struct DocState {
|
|||
pub print_tiling: bool,
|
||||
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
|
||||
pub layers_open: OpenRows,
|
||||
/// The file this document was read from, and what reading it left out (hidden text, art or
|
||||
/// layers it could not read): writing the document over that file would lose those for good,
|
||||
/// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved.
|
||||
pub imported_from: Option<String>,
|
||||
pub import_losses: Vec<String>,
|
||||
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
|
||||
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
|
||||
pub last_perspective: Option<Value>,
|
||||
|
|
@ -243,6 +248,8 @@ impl DocState {
|
|||
recovery: None,
|
||||
print_tiling: false,
|
||||
layers_open,
|
||||
imported_from: None,
|
||||
import_losses: vec![],
|
||||
last_perspective: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -1016,6 +1023,8 @@ impl Session {
|
|||
st.format = old.format;
|
||||
st.save_options = old.save_options.clone();
|
||||
st.converted = old.converted;
|
||||
st.imported_from = old.imported_from.clone();
|
||||
st.import_losses = old.import_losses.clone();
|
||||
st.view = old.view.clone();
|
||||
st.layers_open = old.layers_open.clone();
|
||||
let old = std::mem::replace(&mut self.docs[index], st);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
|
||||
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions).
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
|
|
@ -1584,3 +1585,125 @@ proptest! {
|
|||
perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the editing data of Illustrator files ----------
|
||||
|
||||
/// `data` as ASCII85, up to the `~>`.
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
|
||||
fn editing_text() -> String {
|
||||
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
|
||||
let body = [
|
||||
"u".to_string(),
|
||||
square(10),
|
||||
"U".into(),
|
||||
"*u".into(),
|
||||
square(20),
|
||||
square(30),
|
||||
"*U".into(),
|
||||
"q".into(),
|
||||
square(40),
|
||||
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
|
||||
"Q".into(),
|
||||
"1 Xw".into(),
|
||||
"u".into(),
|
||||
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
|
||||
"U".into(),
|
||||
"0 Xw".into(),
|
||||
"1 0 0 0 1 0 Bg".into(),
|
||||
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
|
||||
]
|
||||
.join("\n");
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
|
||||
};
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
|
||||
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
|
||||
layer("Two", 0, &square(80))
|
||||
)
|
||||
}
|
||||
|
||||
fn zstd(text: &str) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(text.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
|
||||
}
|
||||
|
||||
/// An EPS drawing a square whose private data holds `editing`.
|
||||
fn editing_eps(editing: &str) -> Vec<u8> {
|
||||
let lines: Vec<String> = ascii85(&zstd(editing)).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
|
||||
lines.join("\n")
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// The text document of a story of centred type with two styles, as an editing copy keeps it.
|
||||
fn text_document_text() -> String {
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\
|
||||
/1 << /1 [ << /0 << /0 (Hi\\rthere\\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 >> /6 0 >> >> /1 9 >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 9 >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||||
<< /99 /S /0 << /0 [ -5.0 0.0 ] >> /15 << /0 3 >> >> ] >> << /99 /L /0 << /0 [ 0.0 16.8 ] >> /6 [ << /99 /S /0 << /0 [ -9.0 0.0 ] >> /15 << /0 6 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// An EPS with a hidden text object whose story is in `document`.
|
||||
fn text_eps(document: &str) -> Vec<u8> {
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
let editing = format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
|
||||
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nLB\n%AI5_EndLayer--\n\
|
||||
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
|
||||
lines.join("\n")
|
||||
);
|
||||
editing_eps(&editing)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it.
|
||||
#[test]
|
||||
fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) {
|
||||
let text = mutate_text(&text_document_text(), cut, &edits);
|
||||
let bytes = text_eps(&text);
|
||||
survive("mutated EPS text document", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
|
||||
#[test]
|
||||
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
|
||||
let text = mutate_text(&editing_text(), cut, &edits);
|
||||
let bytes = editing_eps(&text);
|
||||
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing data is damaged or hostile.
|
||||
#[test]
|
||||
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
|
||||
let text = mutate_text(&editing_text(), cut, &edits);
|
||||
let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
for (at, b) in damage {
|
||||
let n = private.len();
|
||||
if let Some(x) = private.get_mut(at % n) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
//! A file whose import left something out isn't written over silently: its hidden text, art or
|
||||
//! layers would be gone for good.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// A square that is gradient-filled in the layers (which can't be read) and flat on the page: the
|
||||
/// file comes in as its page, and its hidden layer is left out.
|
||||
fn lossy_eps() -> Vec<u8> {
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
|
||||
};
|
||||
let editing = format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
|
||||
layer("Art", 1, "1 0 0 0 1 0 Bg\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
|
||||
layer("Hidden", 0, "0 0 1 0 k\n50 50 m\n54 50 L\n54 54 L\n50 54 L\nf"),
|
||||
);
|
||||
let packed = ruzstd::encoding::compress_to_vec(editing.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest);
|
||||
let lines: Vec<String> = ascii85(&packed).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
|
||||
lines.join("\n")
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-lossy-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_the_import_left_things_out_of_is_not_written_over_unasked() {
|
||||
let d = dir("a");
|
||||
let src = d.join("art.eps");
|
||||
let original = lossy_eps();
|
||||
std::fs::write(&src, &original).unwrap();
|
||||
let mut s = Session::new();
|
||||
let opened = s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
|
||||
assert!(opened["warnings"].to_string().contains("layers weren't read"), "{opened}");
|
||||
|
||||
// Over the same file: refused, and the file is as it was.
|
||||
let e = s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap_err().to_string();
|
||||
assert!(e.contains("left things out") && e.contains("acknowledgeLoss"), "{e}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), original);
|
||||
// However the path is spelled.
|
||||
let spelled = d.join(".").join("art.eps");
|
||||
assert!(s.execute("document.export", &json!({ "path": spelled.to_string_lossy(), "format": "eps" })).is_err());
|
||||
|
||||
// Another name is fine, and so is replacing the file when asked to.
|
||||
let other = d.join("art copy.eps");
|
||||
s.execute("document.export", &json!({ "path": other.to_string_lossy(), "format": "eps" })).unwrap();
|
||||
assert!(other.exists());
|
||||
s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps", "acknowledgeLoss": true })).unwrap();
|
||||
assert_ne!(std::fs::read(&src).unwrap(), original);
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_the_import_read_whole_is_written_over_as_before() {
|
||||
let d = dir("b");
|
||||
let src = d.join("plain.eps");
|
||||
std::fs::write(&src, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
|
||||
s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap();
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ vectorcraft-render = { workspace = true }
|
|||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,541 @@
|
|||
//! The text of an Illustrator editing copy.
|
||||
//!
|
||||
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
|
||||
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
|
||||
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
|
||||
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
|
||||
//!
|
||||
//! The format isn't documented. What is read here was worked out from the files of the project's
|
||||
//! users, used locally and never committed, by comparing it with the type their pages draw: the
|
||||
//! meaning of each key below was checked against the page's own type in more than a thousand text
|
||||
//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area
|
||||
//! type, type on a path, anything with a frame that has more than a point and a matrix) is left
|
||||
//! alone: it has no text, and the importer says so.
|
||||
//!
|
||||
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
|
||||
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
|
||||
//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of
|
||||
//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's
|
||||
//! `%AI3_TemplateBox`. See [`Story::place`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
/// Biggest text document read (bytes, decoded).
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
/// Deepest nesting read.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most values read.
|
||||
const MAX_VALUES: usize = 4_000_000;
|
||||
/// The centre of the app's canvas, in the units of its text document.
|
||||
const CANVAS_CENTRE: f64 = 8191.5;
|
||||
|
||||
/// A value of the text document.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(super) enum Val {
|
||||
Num(f64),
|
||||
Str(String),
|
||||
Name(String),
|
||||
Bool(bool),
|
||||
List(Vec<Val>),
|
||||
Dict(BTreeMap<String, Val>),
|
||||
}
|
||||
|
||||
impl Val {
|
||||
fn get(&self, key: &str) -> Option<&Val> {
|
||||
match self {
|
||||
Val::Dict(d) => d.get(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn at(&self, i: usize) -> Option<&Val> {
|
||||
match self {
|
||||
Val::List(l) => l.get(i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
Val::Num(n) if n.is_finite() => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Val::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn list(&self) -> &[Val] {
|
||||
match self {
|
||||
Val::List(l) => l,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn nums(&self) -> Option<Vec<f64>> {
|
||||
self.list().iter().map(Val::num).collect()
|
||||
}
|
||||
|
||||
/// The `name` entry of a dictionary that is a typed node (`/99 /name`).
|
||||
fn is_node(&self, name: &str) -> bool {
|
||||
matches!(self.get("99"), Some(Val::Name(n)) if n == name)
|
||||
}
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn skip_space(&mut self) {
|
||||
while self.b.get(self.at).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self, depth: usize) -> Option<Val> {
|
||||
self.skip_space();
|
||||
self.budget = self.budget.checked_sub(1)?;
|
||||
let c = *self.b.get(self.at)?;
|
||||
match c {
|
||||
b'<' if self.b.get(self.at + 1) == Some(&b'<') => {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
self.at += 2;
|
||||
let mut d = BTreeMap::new();
|
||||
loop {
|
||||
self.skip_space();
|
||||
if self.b.get(self.at..self.at + 2) == Some(b">>") {
|
||||
self.at += 2;
|
||||
return Some(Val::Dict(d));
|
||||
}
|
||||
let key = self.key()?;
|
||||
let v = self.value(depth + 1)?;
|
||||
d.insert(key, v);
|
||||
}
|
||||
}
|
||||
b'[' => {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
self.at += 1;
|
||||
let mut l = vec![];
|
||||
loop {
|
||||
self.skip_space();
|
||||
if self.b.get(self.at) == Some(&b']') {
|
||||
self.at += 1;
|
||||
return Some(Val::List(l));
|
||||
}
|
||||
l.push(self.value(depth + 1)?);
|
||||
}
|
||||
}
|
||||
b'(' => self.string(),
|
||||
b'/' => Some(Val::Name(self.key()?)),
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
|
||||
self.at += 1;
|
||||
}
|
||||
let word = std::str::from_utf8(self.b.get(start..self.at)?).ok()?;
|
||||
match word {
|
||||
"true" => Some(Val::Bool(true)),
|
||||
"false" => Some(Val::Bool(false)),
|
||||
"" => None,
|
||||
w => Some(Val::Num(w.parse().ok()?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/name`, without its slash.
|
||||
fn key(&mut self) -> Option<String> {
|
||||
self.skip_space();
|
||||
if self.b.get(self.at) != Some(&b'/') {
|
||||
return None;
|
||||
}
|
||||
self.at += 1;
|
||||
let start = self.at;
|
||||
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
|
||||
self.at += 1;
|
||||
}
|
||||
Some(String::from_utf8_lossy(self.b.get(start..self.at)?).into_owned())
|
||||
}
|
||||
|
||||
/// A string: up to the first `)` that isn't escaped; UTF-16 when it starts with a byte order mark.
|
||||
fn string(&mut self) -> Option<Val> {
|
||||
self.at += 1;
|
||||
let mut bytes = vec![];
|
||||
loop {
|
||||
let c = *self.b.get(self.at)?;
|
||||
self.at += 1;
|
||||
match c {
|
||||
b')' => break,
|
||||
b'\\' => {
|
||||
let e = *self.b.get(self.at)?;
|
||||
self.at += 1;
|
||||
bytes.push(match e {
|
||||
b'n' => b'\n',
|
||||
b'r' => b'\r',
|
||||
b't' => b'\t',
|
||||
b'b' => 8,
|
||||
b'f' => 12,
|
||||
b'0'..=b'7' => {
|
||||
let mut v = u32::from(e - b'0');
|
||||
for _ in 0..2 {
|
||||
match self.b.get(self.at) {
|
||||
Some(d @ b'0'..=b'7') => {
|
||||
v = v * 8 + u32::from(d - b'0');
|
||||
self.at += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(v & 0xff) as u8
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
c => bytes.push(c),
|
||||
}
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Val::Str(match bytes.strip_prefix(&[0xfe, 0xff]) {
|
||||
Some(rest) => {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
None => bytes.iter().map(|b| char::from(*b)).collect(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a text document: its top-level `/key value` pairs.
|
||||
fn read(bytes: &[u8]) -> Option<Val> {
|
||||
let mut r = Reader { b: bytes, at: 0, budget: MAX_VALUES };
|
||||
let mut d = BTreeMap::new();
|
||||
loop {
|
||||
r.skip_space();
|
||||
if r.at >= bytes.len() {
|
||||
return Some(Val::Dict(d));
|
||||
}
|
||||
let key = r.key()?;
|
||||
let v = r.value(0)?;
|
||||
d.insert(key, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a paragraph's lines sit against its anchor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Align {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The text of one story, in the units of the text document.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Story {
|
||||
/// The characters, with `\r` ending paragraphs and `\x03` ending lines.
|
||||
text: String,
|
||||
/// The anchor of the layout.
|
||||
anchor: (f64, f64),
|
||||
/// Where each line starts, from the anchor.
|
||||
lines: Vec<(f64, f64)>,
|
||||
/// Carries the layout onto the canvas.
|
||||
matrix: [f64; 6],
|
||||
align: Align,
|
||||
/// `(characters, style)` in order.
|
||||
runs: Vec<(usize, Style)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Style {
|
||||
font: Option<String>,
|
||||
size: f64,
|
||||
fill: Option<[f64; 4]>,
|
||||
stroke: Option<[f64; 4]>,
|
||||
stroke_width: f64,
|
||||
}
|
||||
|
||||
/// The text document of an editing copy.
|
||||
pub(super) struct Texts {
|
||||
stories: Vec<Val>,
|
||||
fonts: Vec<String>,
|
||||
/// The character style a story starts from.
|
||||
defaults: Val,
|
||||
frames: Vec<Val>,
|
||||
}
|
||||
|
||||
impl Texts {
|
||||
/// The text document in the editing copy `data`, if it has one that can be read.
|
||||
pub(super) fn read(data: &[u8]) -> Option<Texts> {
|
||||
fn find(h: &[u8], n: &[u8], from: usize) -> Option<usize> {
|
||||
h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from)
|
||||
}
|
||||
let start = find(data, b"%AI11_BeginTextDocument", 0)?;
|
||||
let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len();
|
||||
let end = find(data, b"~>", from)? + 2;
|
||||
// Each line after the first starts with a `%`.
|
||||
let packed: String = String::from_utf8_lossy(data.get(from..end)?)
|
||||
.split(['\r', '\n'])
|
||||
.enumerate()
|
||||
.map(|(i, l)| if i > 0 { l.strip_prefix('%').unwrap_or(l) } else { l })
|
||||
.collect::<String>()
|
||||
.trim_start_matches([' ', ','])
|
||||
.to_string();
|
||||
let bytes = crate::ps::ascii85_decode(&packed)?;
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let doc = read(&bytes)?;
|
||||
let fonts = doc
|
||||
.get("0")?
|
||||
.get("1")?
|
||||
.get("0")?
|
||||
.list()
|
||||
.iter()
|
||||
.map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string())
|
||||
.collect();
|
||||
let main = doc.get("1")?;
|
||||
Some(Texts {
|
||||
stories: main.get("1")?.list().to_vec(),
|
||||
fonts,
|
||||
defaults: main.get("2")?.clone(),
|
||||
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Story `i`, if it is one of point type this reads.
|
||||
pub(super) fn story(&self, i: usize) -> Option<Story> {
|
||||
let s = self.stories.get(i)?;
|
||||
let text = s.get("0")?.get("0")?.str()?.to_string();
|
||||
// The frame the story is in: a matrix and nothing else but its place.
|
||||
let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?;
|
||||
let carries = frame.get("2")?;
|
||||
if carries.get("2").is_none() || carries.as_dict().is_some_and(|d| d.keys().any(|k| k != "2")) {
|
||||
return None;
|
||||
}
|
||||
let matrix: [f64; 6] = carries.get("2")?.nums()?.try_into().ok()?;
|
||||
let f = find_node(s.get("1")?.get("2")?, "F")?;
|
||||
let anchor = f.get("0")?.get("0")?.nums()?;
|
||||
let anchor = (*anchor.first()?, *anchor.get(1)?);
|
||||
let mut lines = vec![];
|
||||
let mut found = vec![];
|
||||
find_nodes(f, "L", &mut found);
|
||||
for l in found {
|
||||
let off = l.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
|
||||
let seg = l.get("6")?.list().iter().find(|c| c.is_node("S"))?;
|
||||
let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
|
||||
lines.push((off.first()? + so.first()?, *off.get(1)?));
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let align = match s
|
||||
.get("0")
|
||||
.and_then(|t| t.get("5"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.at(0))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.get("5"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(Val::num)
|
||||
{
|
||||
Some(1.0) => Align::Right,
|
||||
Some(2.0) => Align::Center,
|
||||
_ => Align::Left,
|
||||
};
|
||||
let mut runs = vec![];
|
||||
for run in s.get("0")?.get("6")?.get("0")?.list() {
|
||||
let n = run.get("1")?.num()? as usize;
|
||||
let style = run.get("0")?.get("0")?.get("6")?;
|
||||
runs.push((n, self.style(style)));
|
||||
}
|
||||
Some(Story { text, anchor, lines, matrix, align, runs })
|
||||
}
|
||||
|
||||
/// A character style: its own keys, else the document's.
|
||||
fn style(&self, s: &Val) -> Style {
|
||||
let pick = |k: &str| s.get(k).or_else(|| self.defaults.get(k));
|
||||
let paint = |k: &str| -> Option<[f64; 4]> {
|
||||
let p = pick(k)?.get("0")?;
|
||||
// Kind 2 is CMYK: an opacity, then the four inks.
|
||||
(p.get("0")?.num()? == 2.0).then(|| p.get("1")?.nums()).flatten().and_then(|v| Some([*v.get(1)?, *v.get(2)?, *v.get(3)?, *v.get(4)?]))
|
||||
};
|
||||
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
|
||||
let font = pick("0").and_then(Val::num).and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
|
||||
Style {
|
||||
font,
|
||||
size: pick("1").and_then(Val::num).filter(|v| *v > 0.0).unwrap_or(12.0),
|
||||
fill: if on("56") { Some(paint("53").unwrap_or([0.0, 0.0, 0.0, 1.0])) } else { None },
|
||||
stroke: if matches!(pick("57"), Some(Val::Bool(true))) { paint("54") } else { None },
|
||||
stroke_width: pick("63").and_then(Val::num).filter(|v| *v >= 0.0).unwrap_or(1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Val {
|
||||
fn as_dict(&self) -> Option<&BTreeMap<String, Val>> {
|
||||
match self {
|
||||
Val::Dict(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_node<'a>(v: &'a Val, name: &str) -> Option<&'a Val> {
|
||||
let mut out = vec![];
|
||||
find_nodes(v, name, &mut out);
|
||||
out.into_iter().next()
|
||||
}
|
||||
|
||||
/// The typed nodes called `name` in `v`, in order, outermost first.
|
||||
fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
|
||||
match v {
|
||||
Val::Dict(d) => {
|
||||
if v.is_node(name) {
|
||||
out.push(v);
|
||||
}
|
||||
for x in d.values() {
|
||||
find_nodes(x, name, out);
|
||||
}
|
||||
}
|
||||
Val::List(l) => {
|
||||
for x in l {
|
||||
find_nodes(x, name, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives).
|
||||
impl Story {
|
||||
/// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given
|
||||
/// the centre `(tx, ty)` of the file's template box.
|
||||
pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) {
|
||||
let [a, b, c, d, e, f] = self.matrix;
|
||||
let (x, y) = (a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f);
|
||||
(x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)
|
||||
}
|
||||
|
||||
/// Each line's characters and where it starts (in the units of the art, y up): where the page
|
||||
/// draws the line when the type is shown.
|
||||
pub(super) fn line_starts(&self, template: (f64, f64)) -> Vec<(String, (f64, f64))> {
|
||||
let [a, b, c, d, e, f] = self.matrix;
|
||||
let text: Vec<&str> = self.text.split(['\r', '\x03']).collect();
|
||||
self.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (lx, ly))| {
|
||||
let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly);
|
||||
let (x, y) = (a * x + c * y + e, b * x + d * y + f);
|
||||
(text.get(i).copied().unwrap_or_default().to_string(), (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The story as a text object whose anchor is at `at` on the document (`to_doc` takes the art's
|
||||
/// units onto it), or `None` for type that is mirrored or has no size.
|
||||
pub(super) fn node(&self, id: NodeId, template: (f64, f64), to_doc: Affine) -> Option<Node> {
|
||||
let [a, b, c, d, ..] = self.matrix;
|
||||
let scale = (a * d - b * c).abs().sqrt();
|
||||
if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = self.place(template);
|
||||
let at = to_doc * Point::new(x, y);
|
||||
let text: String = self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect();
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut runs = vec![];
|
||||
let mut from = 0;
|
||||
let styles: Vec<&(usize, Style)> = self.runs.iter().collect();
|
||||
for (n, st) in styles {
|
||||
let to = (from + n).min(chars.len());
|
||||
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
|
||||
from = to;
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
runs.push(TextRun { text: piece, style: char_style(st, scale, leading) });
|
||||
}
|
||||
if from < chars.len() {
|
||||
let last = self.runs.last().map(|r| &r.1)?;
|
||||
runs.push(TextRun { text: chars.get(from..).unwrap_or_default().iter().collect(), style: char_style(last, scale, leading) });
|
||||
}
|
||||
if runs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, "", runs.first()?.style.clone());
|
||||
t.runs = runs;
|
||||
t.para = ParaStyle {
|
||||
justify: match self.align {
|
||||
Align::Left => Justify::Left,
|
||||
Align::Center => Justify::Center,
|
||||
Align::Right => Justify::Right,
|
||||
},
|
||||
..ParaStyle::default()
|
||||
};
|
||||
t.xf = Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]);
|
||||
Some(Node::new(id, NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
}
|
||||
|
||||
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
|
||||
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
|
||||
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
|
||||
let ink = |c: [f64; 4]| Paint::solid(Color::cmyk(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32));
|
||||
CharStyle {
|
||||
font_family: family,
|
||||
font_style: style,
|
||||
size: s.size * scale,
|
||||
leading,
|
||||
fill: s.fill.map_or(Paint::None, ink),
|
||||
stroke: s.stroke.map_or(Paint::None, ink),
|
||||
stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 },
|
||||
..CharStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
|
||||
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
|
||||
let one = v.get("1").unwrap();
|
||||
assert_eq!(one.get("0").unwrap().nums(), Some(vec![1.0, 2.5, -3.0]));
|
||||
assert_eq!(one.get("2").unwrap().str(), Some("hi"));
|
||||
assert_eq!(one.get("3"), Some(&Val::Bool(true)));
|
||||
assert!(one.is_node("Name"));
|
||||
assert_eq!(v.get("0").unwrap().str(), Some("a)b\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_documents_are_not_read() {
|
||||
for bad in [&b"/0 << /1"[..], b"/0 [ 1 2", b"/0 (abc", b"/0 nope", b"0 1"] {
|
||||
assert!(read(bad).is_none(), "{}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
let deep = format!("/0 {}", "[".repeat(200));
|
||||
assert!(read(deep.as_bytes()).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,18 @@ impl Default for GState {
|
|||
}
|
||||
}
|
||||
|
||||
/// The name of the empty group that stands for a text object (see [`Out::text_slot`]), followed by
|
||||
/// the number of its story in the text document when the file says.
|
||||
pub(crate) const TEXT_SLOT: &str = "\u{0}text";
|
||||
|
||||
/// Is `name` that of a text slot, and which story is it of?
|
||||
pub(crate) fn slot_of(name: Option<&str>) -> Option<Option<u32>> {
|
||||
name?.strip_prefix(TEXT_SLOT).map(|rest| rest.parse().ok())
|
||||
}
|
||||
|
||||
/// An object drawn, with its chain of clips and groups.
|
||||
type DrawnItem = (Vec<Arc<Clip>>, Node);
|
||||
|
||||
/// What the program draws.
|
||||
pub(crate) struct Out {
|
||||
/// The document being made: ids, images and swatches.
|
||||
|
|
@ -169,6 +181,14 @@ pub(crate) struct Out {
|
|||
/// goes in the innermost group kept).
|
||||
groups: Vec<Arc<Clip>>,
|
||||
too_deep: usize,
|
||||
/// Illustrator clipping groups open (`q` … `Q`): the group's id (none when it opened past
|
||||
/// [`MAX_NEST`]) and how many objects were drawn before it.
|
||||
open_clips: Vec<(Option<u32>, usize)>,
|
||||
/// Illustrator compound paths open (`*u` … `*U`): how many objects were drawn before each.
|
||||
compounds: Vec<(usize, bool)>,
|
||||
/// What the file says of the objects it draws now: hidden (Illustrator's `Xw`, a state it
|
||||
/// changes only when the next object differs).
|
||||
pub hidden: bool,
|
||||
/// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]).
|
||||
elements: usize,
|
||||
/// The path the last object was filled with: a stroke of the same path right after joins it.
|
||||
|
|
@ -204,6 +224,9 @@ impl Out {
|
|||
next_clip: 0,
|
||||
groups: vec![],
|
||||
too_deep: 0,
|
||||
open_clips: vec![],
|
||||
compounds: vec![],
|
||||
hidden: false,
|
||||
elements: 0,
|
||||
merge: None,
|
||||
cmyk: 0,
|
||||
|
|
@ -229,7 +252,7 @@ impl Out {
|
|||
return;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
|
||||
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None, hidden: self.hidden }));
|
||||
}
|
||||
|
||||
/// End the innermost group open (an end without a beginning ends nothing).
|
||||
|
|
@ -241,6 +264,76 @@ impl Out {
|
|||
}
|
||||
}
|
||||
|
||||
/// Open an Illustrator clipping group (`q`): a group that [`Self::end_clip_group`] clips with
|
||||
/// the clip path the file gives at its end.
|
||||
pub fn begin_clip_group(&mut self) {
|
||||
let deep = self.too_deep;
|
||||
self.begin_group();
|
||||
let id = if self.too_deep == deep { self.groups.last().map(|c| c.id) } else { None };
|
||||
self.open_clips.push((id, self.drawn.len()));
|
||||
}
|
||||
|
||||
/// End the innermost clipping group open (`Q`), clipped to `region` when the file gave one
|
||||
/// (without, it stays a plain group). An end without a beginning ends nothing.
|
||||
pub fn end_clip_group(&mut self, region: Option<(BezPath, FillRule)>) {
|
||||
let Some((id, start)) = self.open_clips.pop() else { return };
|
||||
let hidden = self.groups.last().is_some_and(|c| Some(c.id) == id && c.hidden);
|
||||
self.end_group();
|
||||
let (Some(id), Some(region)) = (id, region) else { return };
|
||||
// The objects drawn in the group hold the group's clip as it was when they were drawn,
|
||||
// without its region.
|
||||
let clip = Arc::new(Clip { id, region: Some(region), hidden });
|
||||
for (chain, _) in self.drawn.iter_mut().skip(start) {
|
||||
for c in chain.iter_mut().filter(|c| c.id == id) {
|
||||
*c = clip.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an Illustrator compound path (`*u`): the paths drawn until [`Self::end_compound`] are
|
||||
/// its parts.
|
||||
pub fn begin_compound(&mut self) {
|
||||
self.compounds.push((self.drawn.len(), self.hidden));
|
||||
}
|
||||
|
||||
/// End the innermost compound path open (`*U`): its parts become one compound path painted
|
||||
/// with the first part's appearance (what Illustrator does). One part stays a plain path.
|
||||
pub fn end_compound(&mut self) {
|
||||
let Some((start, hidden)) = self.compounds.pop() else { return };
|
||||
let parts = self.drawn.get(start..).unwrap_or_default();
|
||||
let same_chain = |a: &DrawnItem, b: &DrawnItem| a.0.iter().map(|c| c.id).eq(b.0.iter().map(|c| c.id));
|
||||
let Some(first) = parts.first() else { return };
|
||||
if parts.len() < 2 || !parts.iter().all(|p| matches!(p.1.kind, NodeKind::Path { .. }) && same_chain(first, p)) {
|
||||
return;
|
||||
}
|
||||
let parts = self.drawn.split_off(start);
|
||||
let chain = parts.first().map(|p| p.0.clone()).unwrap_or_default();
|
||||
let (mut rule, mut appearance) = (FillRule::NonZero, Appearance::default());
|
||||
let mut children = Vec::with_capacity(parts.len());
|
||||
for (i, (_, mut part)) in parts.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
appearance = std::mem::take(&mut part.appearance);
|
||||
if let NodeKind::Path { rule: r, .. } = &part.kind {
|
||||
rule = *r;
|
||||
}
|
||||
}
|
||||
part.appearance = Appearance::default();
|
||||
children.push(Arc::new(part));
|
||||
}
|
||||
let mut compound = Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule });
|
||||
compound.appearance = appearance;
|
||||
compound.visible = !hidden;
|
||||
self.drawn.push((chain, compound));
|
||||
}
|
||||
|
||||
/// Where a text object of the file's editing data goes: an empty group named [`TEXT_SLOT`],
|
||||
/// which the importer replaces with the text it read from the file's page.
|
||||
pub fn text_slot(&mut self, story: Option<u32>) {
|
||||
let mut n = Node::group(NodeId(0), vec![]);
|
||||
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}")));
|
||||
self.push(n, &[]);
|
||||
}
|
||||
|
||||
/// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the
|
||||
/// order they opened.
|
||||
pub fn chain(&self, clips: &[Arc<Clip>]) -> Vec<Arc<Clip>> {
|
||||
|
|
@ -272,6 +365,7 @@ impl Out {
|
|||
return None;
|
||||
}
|
||||
node.id = self.doc.alloc_id();
|
||||
node.visible = !self.hidden;
|
||||
let id = node.id;
|
||||
self.drawn.push((chain, node));
|
||||
Some(id)
|
||||
|
|
@ -326,7 +420,7 @@ impl Out {
|
|||
return None;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
|
||||
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule};
|
||||
|
||||
use super::graphics::{GState, Out};
|
||||
use super::lex::{IMMEDIATE, Lexer};
|
||||
|
|
@ -65,6 +65,11 @@ pub(crate) struct Interp<'a> {
|
|||
/// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top
|
||||
/// level, whatever its prolog defines them as) are groups (see the module docs of `import`).
|
||||
pub illustrator: bool,
|
||||
/// The program is the editing copy of an Illustrator file's art (see `native`): its clipping
|
||||
/// groups, compound paths and text objects have operators of their own too.
|
||||
pub native: bool,
|
||||
/// The clip path `W` set for the clipping group about to end.
|
||||
clip_region: Option<(BezPath, FillRule)>,
|
||||
}
|
||||
|
||||
/// Where a PostScript error was raised: the operator that raised it (none for an unknown name)
|
||||
|
|
@ -164,6 +169,8 @@ impl<'a> Interp<'a> {
|
|||
saved: vec![],
|
||||
out,
|
||||
illustrator: false,
|
||||
native: false,
|
||||
clip_region: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +179,9 @@ impl<'a> Interp<'a> {
|
|||
loop {
|
||||
let Some(o) = self.lex.next()? else { return Ok(()) };
|
||||
let o = self.scanned(o, self.lex.immediate)?;
|
||||
if self.native && self.native_op(&o)? {
|
||||
continue;
|
||||
}
|
||||
// A group begins before its `u` runs (`Some(true)`) and ends after its `U`
|
||||
// has (`Some(false)`).
|
||||
let group = match &o {
|
||||
|
|
@ -195,6 +205,32 @@ impl<'a> Interp<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The operators only the editing copy of an Illustrator file's art has (`native`), run
|
||||
/// instead of defined; `false` for any other token.
|
||||
fn native_op(&mut self, o: &Obj) -> Res<bool> {
|
||||
let Obj::Exec(name) = o else { return Ok(false) };
|
||||
match &**name {
|
||||
"q" => self.out.begin_clip_group(),
|
||||
"Q" => {
|
||||
let region = self.clip_region.take();
|
||||
self.out.end_clip_group(region);
|
||||
}
|
||||
"W" => {
|
||||
let path = self.take_path();
|
||||
self.clip_region = (!path.elements().is_empty()).then_some((path, FillRule::NonZero));
|
||||
}
|
||||
"*u" => self.out.begin_compound(),
|
||||
"*U" => self.out.end_compound(),
|
||||
"Xw" => self.out.hidden = self.pop_num()? != 0.0,
|
||||
"__txt" => {
|
||||
let story = self.pop_num()?;
|
||||
self.out.text_slot((story >= 0.0 && story < f64::from(u32::MAX)).then_some(story as u32));
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Empty the standard dictionaries, so the memory they hold is freed with the interpreter:
|
||||
/// systemdict holds itself, and programs link dictionaries into each other.
|
||||
pub fn release(&mut self) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
//! - `data`: data the program reads from itself through decoding filters, and images.
|
||||
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
|
||||
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
|
||||
//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries.
|
||||
//!
|
||||
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
|
||||
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
|
||||
|
|
@ -24,10 +25,12 @@
|
|||
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
|
||||
//! error is kept (with that warning), else the file is refused.
|
||||
|
||||
mod ate;
|
||||
mod data;
|
||||
mod graphics;
|
||||
mod interp;
|
||||
mod lex;
|
||||
mod native;
|
||||
mod obj;
|
||||
mod preview;
|
||||
mod shading;
|
||||
|
|
@ -43,6 +46,8 @@ mod tests_generators;
|
|||
mod tests_illustrator;
|
||||
#[cfg(test)]
|
||||
mod tests_images;
|
||||
#[cfg(test)]
|
||||
mod tests_native;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -55,6 +60,7 @@ use graphics::{GState, Out};
|
|||
use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub use native::{is_loss, layered_ai};
|
||||
pub use text::family_style;
|
||||
|
||||
/// The page of a PostScript file without a bounding box (US Letter).
|
||||
|
|
@ -182,7 +188,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
|||
}
|
||||
let drew = !out.drawn.is_empty();
|
||||
let why = match (&result, drew) {
|
||||
(Ok(()), true) => return Ok(finish(out)),
|
||||
(Ok(()), true) => return Ok(native::layered(ps, finish(out))),
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
};
|
||||
|
|
@ -211,6 +217,16 @@ fn finish(mut out: Out) -> Imported {
|
|||
if !out.shadings.is_empty() {
|
||||
graphics::collapse(&mut children, &out.shadings);
|
||||
}
|
||||
let mut layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
finish_with(out, vec![Arc::new(layer)])
|
||||
}
|
||||
|
||||
/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when
|
||||
/// most process colours were.
|
||||
fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
|
||||
let mut doc = out.doc;
|
||||
if out.cmyk > out.rgb {
|
||||
doc.color_mode = ColorMode::Cmyk;
|
||||
|
|
@ -227,10 +243,6 @@ fn finish(mut out: Out) -> Imported {
|
|||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||||
}
|
||||
}
|
||||
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
doc.layers = vec![Arc::new(layer)];
|
||||
doc.layers = layers;
|
||||
Imported { document: doc, warnings: out.warnings, preview: false }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,438 @@
|
|||
//! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file
|
||||
//! carries (see `native`). The files here are made from the public specification's operators.
|
||||
|
||||
use vectorcraft_doc::{Document, LayerColor, Node, NodeKind};
|
||||
|
||||
use crate::import::import;
|
||||
|
||||
/// `data` as ASCII85, up to the `~>`.
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let v = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
let mut x = v;
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
fn zstd(data: &str) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(data.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
|
||||
}
|
||||
|
||||
/// The editing copy of a file with `layers` (made by [`layer`]), 100 × 100 points.
|
||||
fn native(layers: &str) -> String {
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI5_NumLayers: 2\n{layers}%%Trailer\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// A layer as the format writes it.
|
||||
fn layer(name: &str, visible: bool, body: &str) -> String {
|
||||
format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible))
|
||||
}
|
||||
|
||||
/// A yellow square of 4 points at (`x`, `y`) in the format's operators.
|
||||
fn square(x: u32, y: u32) -> String {
|
||||
format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
|
||||
}
|
||||
|
||||
/// An EPS whose page draws `page` and whose private data is `native`.
|
||||
fn eps(page: &str, native: &str) -> Vec<u8> {
|
||||
let stream = ascii85(&zstd(native));
|
||||
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
|
||||
lines.join("\n")
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// The page's drawing of the square at (`x`, `y`).
|
||||
fn page_square(x: u32, y: u32) -> String {
|
||||
format!("0 0 1 0 setcmykcolor {x} {y} moveto {} {y} lineto {} {} lineto {x} {} lineto closepath fill", x + 4, x + 4, y + 4, y + 4)
|
||||
}
|
||||
|
||||
fn names(d: &Document) -> Vec<String> {
|
||||
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn kinds(l: &Node) -> Vec<&'static str> {
|
||||
l.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|n| match &n.kind {
|
||||
NodeKind::Group { clip: true, children } => {
|
||||
["clip[0]", "clip[1]", "clip[2]", "clip[3]"].get(children.len()).copied().unwrap_or("clip[many]")
|
||||
}
|
||||
NodeKind::Group { children, .. } => {
|
||||
["group[0]", "group[1]", "group[2]", "group[3]"].get(children.len()).copied().unwrap_or("group[many]")
|
||||
}
|
||||
NodeKind::Compound { children, .. } => {
|
||||
["compound[0]", "compound[1]", "compound[2]", "compound[3]"].get(children.len()).copied().unwrap_or("compound[many]")
|
||||
}
|
||||
NodeKind::Path { clipping: true, .. } => "clippath",
|
||||
NodeKind::Path { .. } => "path",
|
||||
NodeKind::Text(_) => "text",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_come_in_with_their_names_colours_and_hidden_state() {
|
||||
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(names(d), ["Back", "Hidden"], "{:?}", r.warnings);
|
||||
assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]);
|
||||
// What is on a hidden layer stays: the page doesn't have it.
|
||||
assert_eq!(kinds(&d.layers[1]), ["path"]);
|
||||
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Custom([79, 128, 255]), .. }));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
// The page is where the page's art was.
|
||||
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 86.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
|
||||
let body = [
|
||||
"u".to_string(),
|
||||
square(10, 10),
|
||||
"U".into(),
|
||||
"*u".into(),
|
||||
square(20, 10),
|
||||
square(30, 10),
|
||||
"*U".into(),
|
||||
// A clipping group: its objects, then its clip path.
|
||||
"q".into(),
|
||||
square(40, 10),
|
||||
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
|
||||
"Q".into(),
|
||||
// Hidden until the next `Xw`.
|
||||
"1 Xw".into(),
|
||||
"u".into(),
|
||||
square(70, 10),
|
||||
"U".into(),
|
||||
"0 Xw".into(),
|
||||
]
|
||||
.join("\n");
|
||||
let sub = layer("Inner", true, &square(80, 10));
|
||||
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
|
||||
// The page has what shows: the clip group's square is outside its clip path.
|
||||
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
let outer = &r.document.layers[0];
|
||||
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
|
||||
let children = outer.children().unwrap();
|
||||
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
|
||||
// The clip path comes first in its group.
|
||||
assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
// The sublayer is a layer of the layer.
|
||||
assert!(matches!(&children[4].kind, NodeKind::Layer { .. }));
|
||||
assert_eq!(children[4].name.as_deref(), Some("Inner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() {
|
||||
// A gradient (`Bg`) on a layer that shows: the layers would draw it as a flat colour.
|
||||
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Bg", square(10, 10))));
|
||||
let r = import(&eps(&page_square(10, 10), &shown)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"]);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Bg`")), "{:?}", r.warnings);
|
||||
// On a hidden layer it only costs that layer's look.
|
||||
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Bg")));
|
||||
let r = import(&eps(&page_square(10, 10), &hidden)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Old"]);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() {
|
||||
let page = format!(
|
||||
"{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke",
|
||||
page_square(10, 10)
|
||||
);
|
||||
let words = layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n\n0 0 Xd");
|
||||
let art = native(&(layer("Art", true, &square(10, 10)) + &words));
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
|
||||
assert_eq!(kinds(&r.document.layers[0]), ["path"]);
|
||||
// The text and the path of its stroke, together where the text object is.
|
||||
let group = &r.document.layers[1].children().unwrap()[0];
|
||||
assert_eq!(kinds(group), ["text", "path"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_editing_copy_leaves_the_page() {
|
||||
// Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art.
|
||||
let plain = |data: &str| {
|
||||
let lines = format!("%{}", data);
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{lines}\n%AI9_PrivateDataEnd\n",
|
||||
page_square(10, 10)
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
for bytes in [plain("this is not ascii85 {{{~>"), plain(&ascii85(b"not zstandard at all")), eps(&page_square(10, 10), "nothing of the art here")]
|
||||
{
|
||||
let r = import(&bytes).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.ai`'s editing data: its marker, then the Zstandard stream.
|
||||
fn private(native: &str) -> Vec<u8> {
|
||||
[b"%AI24_ZStandard_Data".as_slice(), &zstd(native)].concat()
|
||||
}
|
||||
|
||||
/// A page of 100 × 100 with a square at (`x`, `y`) in one layer, as a PDF part would give.
|
||||
fn pdf_part(x: f64, y: f64) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.alloc_id();
|
||||
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0));
|
||||
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
|
||||
let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
|
||||
let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()]);
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
|
||||
assert_eq!(warnings, ["a note"], "nothing else to say");
|
||||
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}");
|
||||
assert!(!d.layers[2].visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
// No editing data this reads.
|
||||
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![]);
|
||||
assert_eq!(d.layers.len(), pdf.layers.len());
|
||||
assert!(w.is_empty());
|
||||
// Editing data that isn't for this page.
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![]);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_is_the_black_ink_as_in_a_cmyk_document() {
|
||||
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says.
|
||||
let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"));
|
||||
let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color());
|
||||
assert_eq!(fill, Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 0.75)));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// `native` as a CS-era file keeps it: zlib, under `%AI9_DataStream`.
|
||||
fn older_eps(page: &str, native: &str) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
|
||||
z.write_all(native.as_bytes()).unwrap();
|
||||
let stream = ascii85(&z.finish().unwrap());
|
||||
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n{}\n%AI9_PrivateDataEnd\n",
|
||||
lines.join("\n")
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_from_before_zstandard_give_their_layers_too() {
|
||||
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
|
||||
let r = import(&older_eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Back", "Hidden"], "{:?}", r.warnings);
|
||||
assert_eq!([r.document.layers[0].visible, r.document.layers[1].visible], [true, false]);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_colours_open_paths_and_planar_group_marks_are_read() {
|
||||
// `x` and `X` are the custom colours of a fill and a stroke (four inks, a name, a tint), `Xx` one
|
||||
// with its Lab values; `H` ends an open path, `XP` marks a group the app made from a planar map.
|
||||
let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\
|
||||
0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF";
|
||||
let r = import(&older_eps(
|
||||
"0 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill",
|
||||
&native(&layer("Art", true, body)),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
assert_eq!(kinds(&r.document.layers[0]), ["path", "path"]);
|
||||
}
|
||||
|
||||
/// A text document of `stories` (each as the text, its alignment code, its anchor and the frame's
|
||||
/// translation, and the start of its first line from the anchor), one font and a 14 point default.
|
||||
/// A story: its text, alignment code, anchor, frame translation and first line's start.
|
||||
type Story<'a> = (&'a str, u8, (f64, f64), (f64, f64), f64);
|
||||
|
||||
fn text_document(stories: &[Story<'_>]) -> String {
|
||||
let story = |(text, align, anchor, _t, start): &Story<'_>| {
|
||||
let n = text.chars().count();
|
||||
format!(
|
||||
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 {align} >> /6 0 >> >> /1 {n} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ {} {} ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||||
<< /99 /S /0 << /0 [ {start} 0.0 ] >> /15 << /0 {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>",
|
||||
anchor.0, anchor.1
|
||||
)
|
||||
};
|
||||
let frame =
|
||||
|(_, _, _, t, _): &(&str, u8, (f64, f64), (f64, f64), f64)| format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1);
|
||||
format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n",
|
||||
stories.iter().map(frame).collect::<Vec<_>>().join(" "),
|
||||
stories.iter().map(story).collect::<Vec<_>>().join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is
|
||||
/// centred at (50, 50).
|
||||
fn native_with_text(layers: &str, document: &str) -> String {
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
|
||||
%AI5_NumLayers: 2\n{layers}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
|
||||
lines.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
/// A text object of story `story`, as the layers hold it.
|
||||
fn text_object(story: u32) -> String {
|
||||
format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n2 /TextAntialiasing ,\n;\n")
|
||||
}
|
||||
|
||||
fn text_of(l: &Node) -> Vec<&vectorcraft_doc::TextObject> {
|
||||
l.children().into_iter().flatten().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(&**t) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_hidden_layer_comes_from_the_text_document() {
|
||||
// Canvas centre 8191.5 is the template box's centre (50, 50): the anchor is 8.5 right of it and
|
||||
// 11.5 above it, so at (58.5, 61.5) in the art, which is 38.5 down the page.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
|
||||
let spare = &r.document.layers[1];
|
||||
assert!(!spare.visible);
|
||||
let texts = text_of(spare);
|
||||
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
|
||||
let [a, b] = [texts[0], texts[1]];
|
||||
assert_eq!(a.plain_text(), "Hi there");
|
||||
let [.., e, f] = a.xf.as_coeffs();
|
||||
assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", a.xf);
|
||||
assert_eq!(a.runs[0].style.size, 14.0);
|
||||
assert_eq!(a.para.justify, vectorcraft_doc::Justify::Left);
|
||||
// Centred type is anchored at its middle.
|
||||
assert_eq!(b.para.justify, vectorcraft_doc::Justify::Center);
|
||||
let [.., e, f] = b.xf.as_coeffs();
|
||||
assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", b.xf);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() {
|
||||
// A story whose frame says more than a matrix (a text area, say) isn't guessed at.
|
||||
let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert!(text_of(&r.document.layers[1]).is_empty());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("1 text objects on hidden layers")), "{:?}", r.warnings);
|
||||
assert!(crate::is_loss(r.warnings.iter().find(|w| w.contains("couldn't be read")).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document() {
|
||||
// Story 0 is where the page draws "Hi there"; story 1 is off the page, so the page has nothing of it.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
let texts = text_of(&r.document.layers[0]);
|
||||
// The page's own object is a group (its type and strokes), and the other is made from the file.
|
||||
let all: Vec<String> = {
|
||||
let mut v = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
v
|
||||
};
|
||||
assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings);
|
||||
assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}");
|
||||
assert_eq!(texts.len(), 1, "the type of the page sits in a group of its own");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_object_written_as_comments_is_read_like_any_other() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let art = native_with_text(&layer("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[0]);
|
||||
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
|
||||
assert_eq!(texts[0].plain_text(), "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_area_type_object_with_its_own_nested_art_doesnt_swallow_the_art_after_it() {
|
||||
let area = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n/Art :\n1 Ap\n0 0 m\n9 0 L\n9 9 L\nn\n; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
|
||||
let art = native(&layer("Art", true, &(square(10, 10) + "\n" + area + &square(30, 30))));
|
||||
let r = import(&eps(&(page_square(10, 10) + "\n" + &page_square(30, 30)), &art)).unwrap();
|
||||
let paths = r.document.layers[0].children().map_or(0, Vec::len);
|
||||
assert!(paths >= 2, "{paths} {:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
|
||||
// The page sets "Hi there" as "Hi" and " there" (as when kerning changes); the story is one line.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let group = &r.document.layers[0].children().unwrap()[0];
|
||||
assert_eq!(kinds(group), ["text", "text"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ mod ps;
|
|||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, family_style, import};
|
||||
pub use import::{Imported, family_style, import, is_loss, layered_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
|
|
|
|||
|
|
@ -700,7 +700,7 @@ impl Player {
|
|||
return self.warn(&deep_clips());
|
||||
}
|
||||
self.next_clip += 1;
|
||||
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }));
|
||||
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }));
|
||||
}
|
||||
|
||||
/// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read).
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ fn group_layer(
|
|||
|
||||
/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds
|
||||
/// only the art on its artboards (art on the pasteboard is in the private data alone).
|
||||
pub(crate) const OFF_ARTBOARD_NOTE: &str =
|
||||
pub const OFF_ARTBOARD_NOTE: &str =
|
||||
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
|
||||
|
||||
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ mod syntax;
|
|||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report, page_areas};
|
||||
pub use import::{import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
|
||||
pub use import::{OFF_ARTBOARD_NOTE, import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, illustrator_data, info, is_postscript};
|
||||
pub use post::{THUMBNAIL_SIZE, Thumbnail};
|
||||
pub use presets::*;
|
||||
pub use print::*;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
|
||||
//! is cropped to ([`CropTo`]) and [`info`].
|
||||
|
||||
use hayro_syntax::object::Dict;
|
||||
use hayro_syntax::object::{Dict, Stream};
|
||||
use hayro_syntax::page::Page;
|
||||
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
|
@ -128,6 +128,30 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
|
|||
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
|
||||
}
|
||||
|
||||
/// The `PieceInfo` key such files keep their private data under.
|
||||
const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use
|
||||
|
||||
/// Most bytes of an editor's private data read.
|
||||
const MAX_PRIVATE: usize = 512 << 20;
|
||||
|
||||
/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData`
|
||||
/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read).
|
||||
/// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`).
|
||||
pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>> {
|
||||
let pdf = open(bytes, password).ok()?;
|
||||
let page = pdf.pages().first()?;
|
||||
let private = page.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(b"Private")?;
|
||||
let mut data = vec![];
|
||||
for n in 1..=100_000u32 {
|
||||
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{n}").as_bytes()) else { break };
|
||||
data.extend_from_slice(&stream.decoded().ok()?);
|
||||
if data.len() > MAX_PRIVATE {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(!data.is_empty()).then_some(data)
|
||||
}
|
||||
|
||||
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
|
||||
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
|
||||
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
|
||||
|
|
|
|||
|
|
@ -366,3 +366,23 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
|
|||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order.
|
||||
#[test]
|
||||
fn an_ai_files_private_data_is_its_streams_joined() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
let page = |ai: bool| PdfPage {
|
||||
entries: if ai {
|
||||
format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {a} 0 R /AIPrivateData2 {b} 0 R /NumBlock 2 >> >> >>")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
|
||||
};
|
||||
let (one, two) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"));
|
||||
let ai = pdf_with_catalog(&[page(true)], &[&one, &two], "", None);
|
||||
assert_eq!(illustrator_data(&ai, None).unwrap(), b"%AI24_ZStandard_Data first second");
|
||||
// A PDF without them has none; neither has bytes that aren't a PDF.
|
||||
assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none());
|
||||
assert!(illustrator_data(b"not a pdf", None).is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3118,6 +3118,16 @@ axis Both Ambos
|
|||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg a layer couldn't be read no se pudo leer una capa
|
||||
@msg it has more than one artboard tiene más de una mesa de trabajo
|
||||
@msg it has no page no tiene página
|
||||
@msg its editing data doesn't say where its artboard is sus datos de edición no indican dónde está su mesa de trabajo
|
||||
@msg its editing data has no layers sus datos de edición no tienen capas
|
||||
@msg its editing data is damaged sus datos de edición están dañados
|
||||
@msg its editing data is for a different page sus datos de edición son de otra página
|
||||
@msg its layers use {_1}, which aren't read yet sus capas usan {_1}, que aún no se leen
|
||||
@msg its page has text, and its layers have no text objects to put it in su página tiene texto y sus capas no tienen objetos de texto donde ponerlo
|
||||
@msg the art on them differs from the page's in {_1}% of it el arte de las capas difiere del de la página en el {_1} % de su superficie
|
||||
@msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth"
|
||||
@msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray
|
||||
@msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3118,6 +3118,16 @@ axis Both Entrambi
|
|||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg a layer couldn't be read non è stato possibile leggere un livello
|
||||
@msg it has more than one artboard ha più di una tavola da disegno
|
||||
@msg it has no page non ha pagine
|
||||
@msg its editing data doesn't say where its artboard is i suoi dati di modifica non indicano dove si trova la tavola da disegno
|
||||
@msg its editing data has no layers i suoi dati di modifica non hanno livelli
|
||||
@msg its editing data is damaged i suoi dati di modifica sono danneggiati
|
||||
@msg its editing data is for a different page i suoi dati di modifica sono di un'altra pagina
|
||||
@msg its layers use {_1}, which aren't read yet i suoi livelli usano {_1}, che non vengono ancora letti
|
||||
@msg its page has text, and its layers have no text objects to put it in la sua pagina ha del testo e i suoi livelli non hanno oggetti di testo in cui metterlo
|
||||
@msg the art on them differs from the page's in {_1}% of it l'illustrazione dei livelli differisce da quella della pagina per il {_1}% della sua superficie
|
||||
@msg `to` must be "corner" or "smooth" `to` deve essere "corner" o "smooth"
|
||||
@msg BMP files are RGB or grayscale: colorModel rgb or gray I file BMP sono RGB o in scala di grigi: colorModel rgb o gray
|
||||
@msg Blend colors: select at least three objects with filled colours Fondi colori: seleziona almeno tre oggetti con riempimento colore
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3114,6 +3114,16 @@ axis Both Оба
|
|||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg a layer couldn't be read не удалось прочитать слой
|
||||
@msg it has more than one artboard в нём несколько монтажных областей
|
||||
@msg it has no page в нём нет страницы
|
||||
@msg its editing data doesn't say where its artboard is его данные для редактирования не указывают, где находится монтажная область
|
||||
@msg its editing data has no layers в его данных для редактирования нет слоёв
|
||||
@msg its editing data is damaged его данные для редактирования повреждены
|
||||
@msg its editing data is for a different page его данные для редактирования относятся к другой странице
|
||||
@msg its layers use {_1}, which aren't read yet его слои используют {_1}, что пока не читается
|
||||
@msg its page has text, and its layers have no text objects to put it in на его странице есть текст, а на слоях нет текстовых объектов, куда его поместить
|
||||
@msg the art on them differs from the page's in {_1}% of it изображение на слоях отличается от изображения на странице на {_1} % площади
|
||||
@msg `to` must be "corner" or "smooth" `to` должно быть "corner" или "smooth"
|
||||
@msg BMP files are RGB or grayscale: colorModel rgb or gray файлы BMP бывают RGB или в оттенках серого: colorModel rgb или gray
|
||||
@msg Blend colors: select at least three objects with filled colours Смешивание цветов: выберите не менее трёх объектов с цветными заливками
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Loading…
Reference in New Issue