mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Merge main into feat/ai-editing-data
This commit is contained in:
Generated
+12
@@ -1081,6 +1081,17 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -6282,6 +6293,7 @@ dependencies = [
|
||||
name = "vectorcraft-format"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-vectorcraft-format",
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.6",
|
||||
|
||||
@@ -19,6 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
# internal
|
||||
|
||||
@@ -23,6 +23,7 @@ Affinity files.
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||
| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||
@@ -33,7 +34,7 @@ Affinity files.
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
|
||||
@@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Pat
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful.
|
||||
const MAX_PRESSURE_SEGMENTS: usize = 256;
|
||||
|
||||
/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its
|
||||
/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling
|
||||
/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`]
|
||||
/// points; the document's limit counts all of them.
|
||||
const PRESSURE_POINTS: usize = 512;
|
||||
|
||||
/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read
|
||||
/// counts this many against the document's limit.
|
||||
pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS;
|
||||
|
||||
/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of
|
||||
/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as
|
||||
/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most
|
||||
/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more
|
||||
/// points), so a dip between samples is kept → (position, width) in order along the path,
|
||||
/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the
|
||||
/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments.
|
||||
pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option<Vec<(f64, f64)>> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvd, b"Data")?;
|
||||
let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
|
||||
// Every segment takes at least an anchor record.
|
||||
if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
|
||||
return None;
|
||||
}
|
||||
let curve = subpath(&anchors(records)?, false, None)?;
|
||||
if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
|
||||
let mut samples = vec![(curve.start.x, curve.start.y)];
|
||||
let mut from = curve.start;
|
||||
for [c1, c2, to] in &curve.segments {
|
||||
let mut ts: Vec<f64> = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
|
||||
ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
for t in ts {
|
||||
let u = 1.0 - t;
|
||||
let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
|
||||
samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
|
||||
}
|
||||
from = *to;
|
||||
}
|
||||
let mut last = 0.0f64;
|
||||
Some(
|
||||
samples
|
||||
.into_iter()
|
||||
.map(|(x, y)| {
|
||||
last = x.clamp(last, 1.0);
|
||||
(last, y.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
|
||||
/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
|
||||
/// -1..1 first, so huge ones can't overflow the discriminant.
|
||||
fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option<f64>; 2] {
|
||||
let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return [None, None];
|
||||
}
|
||||
let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
|
||||
let (d0, d1, d2) = (b - a, c - b, d - c);
|
||||
let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
|
||||
let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
|
||||
if qa.abs() < 1e-12 {
|
||||
return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
|
||||
}
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
return [None, None];
|
||||
}
|
||||
let root = disc.sqrt();
|
||||
[inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
p: Point,
|
||||
inh: Option<Point>,
|
||||
@@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn turning_points_of_huge_control_values_are_found() {
|
||||
let small = turning_points(1.0, -3.0, 3.0, 1.0);
|
||||
let huge = turning_points(1e200, -3e200, 3e200, 1e200);
|
||||
assert!(small.iter().all(Option::is_some), "{small:?}");
|
||||
for (s, h) in small.iter().zip(&huge) {
|
||||
let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
|
||||
assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
|
||||
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
|
||||
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
@@ -39,10 +42,18 @@ pub(crate) struct Budget {
|
||||
pub(crate) chars: usize,
|
||||
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||
pub(crate) const FULL: Budget = Budget {
|
||||
nodes: MAX_NODES,
|
||||
pixels: MAX_LAYER_PIXELS,
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
|
||||
@@ -121,6 +121,9 @@ pub struct Stroke {
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
/// Painted below the fill instead of above it.
|
||||
pub behind: bool,
|
||||
/// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
|
||||
/// order along the path, `None` for a stroke of the same width all along.
|
||||
pub profile: Option<Vec<(f64, f64)>>,
|
||||
}
|
||||
|
||||
/// Gradient stops per gradient.
|
||||
@@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
|
||||
r.warn("brush and pressure strokes (imported as plain strokes)");
|
||||
if s.obj(lsty, b"Brus").is_some() {
|
||||
r.warn("brush strokes (imported as plain strokes)");
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
// The pressure curve sets the width along the path; one at full width all along is a plain
|
||||
// stroke. Every curve read counts against the document's points: one at full width counts its
|
||||
// points, and one that can't be read counts the most a curve may have. Once the points are
|
||||
// used up the rest are not read.
|
||||
const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
|
||||
let ldep = s.obj(ldsc, b"LDeP");
|
||||
if ldep.is_some() && r.budget.pressure == 0 {
|
||||
r.warn(BEYOND);
|
||||
}
|
||||
let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
|
||||
Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
|
||||
Some(left) => {
|
||||
r.budget.pressure = left;
|
||||
(!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
|
||||
}
|
||||
None => {
|
||||
r.budget.pressure = 0;
|
||||
r.warn(BEYOND);
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(None) => {
|
||||
r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
|
||||
r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
@@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1,
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Symbol",
|
||||
[],
|
||||
None,
|
||||
"{name, ids?|id?} swap the symbol of the selected instances (they keep their appearance changes to the objects at paths a dynamic new symbol's art has) → {ids}",
|
||||
"{name, ids?|id?} swap the symbol of the selected instances (an instance given a different symbol drops its appearance changes; one given its own symbol keeps them) → {ids}",
|
||||
has_selection,
|
||||
symbol_replace
|
||||
),
|
||||
|
||||
@@ -590,14 +590,19 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Replace Symbol", |d, _| {
|
||||
let new = natural_size(d, &name);
|
||||
for id in &ids {
|
||||
let old = match d.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol),
|
||||
let (old, other) = match d.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::SymbolInstance { symbol, .. }) => (natural_size(d, symbol), *symbol != name),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let NodeKind::SymbolInstance { symbol, xf } = &mut n.kind {
|
||||
*symbol = name.clone();
|
||||
*xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1);
|
||||
}
|
||||
// An override's path names an object of the old symbol's art.
|
||||
if other && !n.extras().symbol_overrides.is_empty() {
|
||||
n.extras_mut().symbol_overrides.clear();
|
||||
}
|
||||
}
|
||||
fit_overrides(d, &ids);
|
||||
Ok(())
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::json;
|
||||
use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels};
|
||||
use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join};
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::geom::{FillRule, PathData, shapes};
|
||||
use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign};
|
||||
use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask};
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::text::Placement;
|
||||
use super::{
|
||||
AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind,
|
||||
GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer,
|
||||
TextObject, Vec2, af, ap, json,
|
||||
TextObject, Vec2, WidthProfile, af, ap, json,
|
||||
};
|
||||
|
||||
impl Builder {
|
||||
@@ -232,6 +232,7 @@ impl Builder {
|
||||
offset: phase * self.k,
|
||||
align_corners: false,
|
||||
});
|
||||
l.profile = s.profile.as_ref().map(|p| WidthProfile { points: p.iter().map(|&(t, w)| (t, w, w)).collect() });
|
||||
Some(l)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
|
||||
//! left out, with `visible` they show where they are. Type is written as text the format's reader
|
||||
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
|
||||
//! 8-bit text can't hold always is).
|
||||
//! 8-bit text can't hold and type with rotated characters always are).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -300,6 +300,176 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// A version-12 document holding an open curve with an 8 px stroke whose pressure curve (`LDeP`)
|
||||
/// is made of `records`.
|
||||
fn pressure_document(records: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
pressure_documents(records, 1)
|
||||
}
|
||||
|
||||
/// [`pressure_document`] with `copies` curves, all linking the first one's fill and stroke.
|
||||
fn pressure_documents(records: Vec<Vec<u8>>, copies: u32) -> Vec<u8> {
|
||||
let mut style = Some((
|
||||
solid(24, rgba(0.0, 0.0, 0.0, 1.0)),
|
||||
F::Def(
|
||||
27,
|
||||
vec![tag(b"LDsc")],
|
||||
vec![
|
||||
(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])),
|
||||
(tag(b"LDSc"), F::Bool(false)),
|
||||
(tag(b"LDeP"), F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))])),
|
||||
],
|
||||
),
|
||||
));
|
||||
let lines = (0..copies)
|
||||
.map(|i| {
|
||||
let (fill, line) = style.take().unwrap_or((F::Ref(24), F::Ref(27)));
|
||||
F::Def(
|
||||
if i == 0 { 20 } else { 1000 + i },
|
||||
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
|
||||
vec![
|
||||
(
|
||||
tag(b"Crvs"),
|
||||
F::Obj(
|
||||
tag(b"PCvD"),
|
||||
vec![(
|
||||
tag(b"Data"),
|
||||
F::Pos(vec![
|
||||
F::U8(0),
|
||||
F::U32(1),
|
||||
F::Bool(false),
|
||||
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)]),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"LIFl"), F::Shared(vec![fill])),
|
||||
(tag(b"LILn"), F::Shared(vec![line])),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(lines))]);
|
||||
let doc = synth::stream(&[
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(72.0))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Zstd)], Some(&png()))
|
||||
}
|
||||
|
||||
/// The width profile of the only stroked path in `d`.
|
||||
fn stroke_of(d: &vectorcraft_doc::Document) -> vectorcraft_doc::appearance::StrokeLayer {
|
||||
let mut found = None;
|
||||
d.walk(|n| {
|
||||
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
found = Some(k.clone());
|
||||
}
|
||||
});
|
||||
found.unwrap_or_else(|| panic!("no stroke in {:?}", d.layers))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pressure_curve_becomes_the_strokes_width_profile() {
|
||||
// Thin at the start, full width from half way: anchor (0, 0), handles (0.25, 0.5) and
|
||||
// (0.5, 1), anchor (0.5, 1), then straight on to anchor (1, 1).
|
||||
let curve = vec![node(0.0, 0.0, 4, 0), node(0.25, 0.5, 0, 1), node(0.5, 1.0, 0, 2), node(0.5, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
|
||||
let r = load("pressure.af", &pressure_document(curve)).unwrap();
|
||||
assert!(!r.preview_only);
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
|
||||
let k = stroke_of(&r.doc);
|
||||
assert_eq!(k.width, 8.0);
|
||||
let profile = k.profile.unwrap_or_else(|| panic!("the pressure curve is read: {:?}", r.warnings));
|
||||
assert_eq!(profile.points.first(), Some(&(0.0, 0.0, 0.0)));
|
||||
assert_eq!(profile.points.last(), Some(&(1.0, 1.0, 1.0)));
|
||||
assert!(profile.points.windows(2).all(|w| w[0].0 <= w[1].0), "positions go forward: {:?}", profile.points);
|
||||
let (left, right) = profile.at(0.25);
|
||||
assert!(left > 0.25 && left < 1.0 && left == right, "{left} {right}");
|
||||
assert_eq!(profile.at(0.75), (1.0, 1.0));
|
||||
|
||||
// Widths past the full weight are held at it.
|
||||
let huge = vec![node(0.0, 0.0, 4, 0), node(0.5, 1e300, 4, 0), node(1.0, 0.0, 4, 0)];
|
||||
let profile = stroke_of(&load("huge.af", &pressure_document(huge)).unwrap().doc).profile.unwrap();
|
||||
assert!(profile.points.iter().all(|p| (0.0..=1.0).contains(&p.1)), "{:?}", profile.points);
|
||||
assert_eq!(profile.at(0.5), (1.0, 1.0));
|
||||
|
||||
// Each segment is sampled inside too: anchors at full width whose handles dip to nothing
|
||||
// still narrow the stroke, with 256 segments.
|
||||
let mut dips = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
dips.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let profile = stroke_of(&load("dips.af", &pressure_document(dips)).unwrap().doc).profile.unwrap();
|
||||
assert!(profile.points.iter().any(|p| p.1 < 0.5), "{:?}", profile.points);
|
||||
|
||||
// A curve of more than 256 segments is left out, with a warning.
|
||||
let long: Vec<Vec<u8>> = (0..=300).map(|i| node(f64::from(i) / 300.0, f64::from(i % 2), 4, 0)).collect();
|
||||
let r = load("long.af", &pressure_document(long)).unwrap();
|
||||
assert!(stroke_of(&r.doc).profile.is_none());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
|
||||
|
||||
// A pressure curve at full width all along leaves the stroke plain.
|
||||
let flat = vec![node(0.0, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
|
||||
let r = load("flat.af", &pressure_document(flat)).unwrap();
|
||||
assert!(stroke_of(&r.doc).profile.is_none());
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dip_between_the_samples_of_a_long_pressure_curve_is_kept() {
|
||||
// 256 segments at full width, except one whose handles sit at widths 0 and 2: its width is
|
||||
// 1 at its middle and end but 0.71875 a quarter of the way along.
|
||||
let mut curve = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
let (h1, h2) = if i == 100 { (0.0, 2.0) } else { (1.0, 1.0) };
|
||||
curve.extend([node(a + (b - a) / 3.0, h1, 0, 1), node(b - (b - a) / 3.0, h2, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let profile = stroke_of(&load("dip.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the dip is kept"));
|
||||
let narrowest = profile.points.iter().map(|p| p.1).fold(1.0, f64::min);
|
||||
assert!((narrowest - 0.71).abs() < 0.02, "{narrowest}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pressure_curve_just_under_full_width_is_kept() {
|
||||
let curve = vec![node(0.0, 0.9995, 4, 0), node(1.0, 0.9995, 4, 0)];
|
||||
let profile = stroke_of(&load("near.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the curve is kept"));
|
||||
assert_eq!(profile.at(0.5), (0.9995, 0.9995));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_curves_stay_within_the_points_of_one_document() {
|
||||
// One 256-segment curve linked from 2100 strokes: more than a million points in all.
|
||||
let mut curve = vec![node(0.0, 1.0, 4, 0)];
|
||||
for i in 1..=256 {
|
||||
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
|
||||
curve.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
|
||||
}
|
||||
let r = load("many.af", &pressure_documents(curve, 2100)).unwrap();
|
||||
let mut profiles = Vec::new();
|
||||
r.doc.walk(|n| {
|
||||
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
profiles.push(k.profile.as_ref().map_or(0, |p| p.points.len()));
|
||||
}
|
||||
});
|
||||
assert_eq!(profiles.len(), 2100);
|
||||
assert!(profiles.iter().sum::<usize>() <= 1 << 20, "{}", profiles.iter().sum::<usize>());
|
||||
assert!(profiles.iter().filter(|n| **n > 0).count() > 1000);
|
||||
assert!(profiles.contains(&0));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
|
||||
// Curves at full width count too, so linking one from many strokes can't repeat its work
|
||||
// without bound.
|
||||
let flat: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, 1.0, if i % 3 == 0 { 4 } else { 0 }, (i % 3) as u8)).collect();
|
||||
let r = load("flat-many.af", &pressure_documents(flat, 2100)).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
|
||||
// So do curves that can't be read: 768 segments, more than a curve may have.
|
||||
let long: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, f64::from(i % 2), 4, 0)).collect();
|
||||
let r = load("long-many.af", &pressure_documents(long, 2100)).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_documents_save_reopen_export_place_and_template() {
|
||||
let bytes = document(Method::Zstd);
|
||||
|
||||
@@ -111,6 +111,28 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
|
||||
assert_eq!(overrides(&s, inst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_symbol_drops_the_changes_made_for_the_old_symbol() {
|
||||
let mut s = session();
|
||||
// "Other": two blue squares in the same places, the instance taken off the canvas.
|
||||
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
|
||||
let first = id(&s.execute("symbol.new", &json!({"name": "Other", "ids": [a.0, b.0], "type": "dynamic"})).unwrap());
|
||||
s.execute("edit.clear", &json!({"ids": [first.0]})).unwrap();
|
||||
let inst = pair(&mut s, "dynamic");
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap();
|
||||
// The same symbol: nothing changes.
|
||||
s.execute("symbol.replace", &json!({"name": "Pair", "ids": [inst.0]})).unwrap();
|
||||
assert_eq!(overrides(&s, inst), 1);
|
||||
assert_eq!(pixel(&s, 80, 30), RED);
|
||||
s.execute("symbol.replace", &json!({"name": "Other", "ids": [inst.0]})).unwrap();
|
||||
assert_eq!(overrides(&s, inst), 0, "the change named an object of Pair's art");
|
||||
assert_eq!(pixel(&s, 80, 30), BLUE, "Other's art draws as the symbol has it");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Pair"));
|
||||
assert_eq!(overrides(&s, inst), 1);
|
||||
assert_eq!(pixel(&s, 80, 30), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_and_static_drop_the_changes_and_undo_brings_them_back() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -140,6 +140,35 @@ fn spot_gradients_export_as_separation_shadings() {
|
||||
assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_stops_linked_to_process_swatches_are_paper_in_spot_gradients() {
|
||||
let (mut s, _) = setup();
|
||||
run(&mut s, "swatch.new", json!({"name": "Paper", "color": "#ffffff", "global": true}));
|
||||
let ink_to_paper = json!({"stops": [{"offset": 0, "swatch": "Ink", "tint": 80}, {"offset": 1, "swatch": "Paper"}]});
|
||||
// As Ink → unlinked white: a Separation shading from 80 % to 0 %.
|
||||
run(&mut s, "paint.editGradient", ink_to_paper.clone());
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "a Separation shading");
|
||||
assert!(!pdf.contains("/DeviceN") && pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints");
|
||||
assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
|
||||
// A Perceptual one too.
|
||||
run(&mut s, "paint.editGradient", with(ink_to_paper, json!({"method": "perceptual"})));
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(!pdf.contains("/Subtype/Image") && !pdf.contains("/DeviceN") && pdf.contains("/Separation/Ink/DeviceCMYK"), "Perceptual");
|
||||
// With process yellow, the linked white stop adds no ink: Yellow and Ink only.
|
||||
let yellow = json!({"c": 0, "m": 0, "y": 0.6, "k": 0});
|
||||
let three =
|
||||
json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 0.5, "swatch": "Paper"}, {"offset": 1, "color": yellow}], "method": "classic"});
|
||||
run(&mut s, "paint.editGradient", three);
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK") && pdf.contains("/C0[0 1]/C1[0 0]"), "Yellow and Ink");
|
||||
// A white spot ink is an ink, not paper: a DeviceN shading of both spot inks.
|
||||
run(&mut s, "swatch.new", json!({"name": "Opaque", "color": "#ffffff", "spot": true}));
|
||||
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Opaque"}]}));
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(pdf.contains("/DeviceN[/Ink/Opaque]/DeviceCMYK"), "a white spot ink keeps its plate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
|
||||
let (mut s, _) = setup();
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
|
||||
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
|
||||
//! Windows-1252, vertical type, ruby, inline graphics, gradient or pattern fills) is written as its
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
|
||||
//! outlines, and so is type with rotated characters (a text object has no character rotation), and
|
||||
//! all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
|
||||
//! names and the title are written in Windows-1252 too, and a character outside it is written as
|
||||
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
|
||||
//!
|
||||
@@ -76,9 +77,9 @@ mod encoding;
|
||||
mod prolog;
|
||||
mod writer;
|
||||
|
||||
#[cfg(test)]
|
||||
use encoding::writable;
|
||||
use encoding::{gradient_def, latin_string};
|
||||
#[cfg(test)]
|
||||
use encoding::{rotated, writable};
|
||||
pub(crate) use prolog::PROCSET;
|
||||
use prolog::{PROLOG, PS_HEADER, fonts};
|
||||
use writer::Writer;
|
||||
@@ -94,7 +95,8 @@ const HEX_LINE: usize = 60;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out";
|
||||
pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out";
|
||||
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
|
||||
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
|
||||
@@ -78,10 +78,25 @@ pub(super) fn latin_string(name: &str) -> String {
|
||||
ps::bytes(&bytes)
|
||||
}
|
||||
|
||||
/// Can type `t` be written as text: horizontal, without ruby (which a text object can't hold), its
|
||||
/// Can type `t` be written as text: 8-bit text can hold it ([`holds_as_text`]) and no character
|
||||
/// is rotated ([`rotated`])?
|
||||
pub(super) fn writable(t: &TextObject) -> bool {
|
||||
holds_as_text(t) && !rotated(t)
|
||||
}
|
||||
|
||||
/// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character
|
||||
/// attributes have none that turns a character (§15), so such type is written as its outlines.
|
||||
pub(super) fn rotated(t: &TextObject) -> bool {
|
||||
t.runs.iter().any(|r| {
|
||||
let turn = r.style.rotation;
|
||||
turn.is_finite() && turn.rem_euclid(360.0) != 0.0 && r.text.chars().any(|c| !c.is_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
/// Can 8-bit text hold type `t`: horizontal, without ruby (which a text object can't hold), its
|
||||
/// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with
|
||||
/// colours?
|
||||
pub(super) fn writable(t: &TextObject) -> bool {
|
||||
pub(super) fn holds_as_text(t: &TextObject) -> bool {
|
||||
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
|
||||
!t.vertical
|
||||
&& t.ruby.is_empty()
|
||||
@@ -101,7 +116,6 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool {
|
||||
|| s.strikethrough
|
||||
|| s.small_caps.is_some()
|
||||
|| s.position != CharPosition::Normal
|
||||
|| s.rotation != 0.0
|
||||
|| !s.features.is_empty()
|
||||
|| !s.font_axes.is_empty()
|
||||
};
|
||||
|
||||
@@ -1150,6 +1150,72 @@ fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() {
|
||||
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A legacy `.ai` text object has no character rotation: point type and area type with a rotated
|
||||
/// character between ordinary ones (turned either way, shifted off the baseline and scaled) are
|
||||
/// written as their outlines, with the warning, and read back as paths where the laid-out glyphs
|
||||
/// are, the rotated character turned. A full turn writes text.
|
||||
#[test]
|
||||
fn type_with_rotated_characters_is_outlines_that_keep_the_rotation() {
|
||||
use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun};
|
||||
let typed = |turn: f64, area: bool| {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "HE", style(30.0, red()));
|
||||
let turned = CharStyle { rotation: turn, baseline_shift: 4.0, h_scale: 120.0, v_scale: 80.0, ..style(30.0, red()) };
|
||||
t.runs.push(TextRun::new("L", turned));
|
||||
t.runs.push(TextRun::new("TH", style(30.0, red())));
|
||||
if area {
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)) };
|
||||
t.xf = Affine::translate((20.0, 40.0));
|
||||
}
|
||||
t
|
||||
};
|
||||
// Each run's laid-out glyphs' box, in the document.
|
||||
let boxes = |t: &TextObject| -> Vec<kurbo::Rect> {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
(0..t.runs.len())
|
||||
.map(|i| {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
for g in lay.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
bp.apply_affine(t.xf);
|
||||
bp.bounding_box()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let near = |a: kurbo::Rect, b: kurbo::Rect| (a.x0 - b.x0).abs().max((a.y0 - b.y0).abs()).max((a.x1 - b.x1).abs()).max((a.y1 - b.y1).abs());
|
||||
for (turn, area) in [(30.0, false), (-45.0, true)] {
|
||||
let t = typed(turn, area);
|
||||
assert!(rotated(&t));
|
||||
let want = boxes(&t);
|
||||
let unturned = boxes(&typed(0.0, area));
|
||||
assert!(near(want[1], unturned[1]) > 1.0, "the turn moves the glyph: {:?} {:?}", want[1], unturned[1]);
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w == ROTATED_TYPE), "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
|
||||
assert!(!String::from_utf8_lossy(&out.bytes).contains(" To\n"), "not a text object");
|
||||
let back = crate::import(&out.bytes).unwrap().document;
|
||||
assert!(texts(&back).is_empty(), "no type reads back");
|
||||
let parts = children(&children(&back.layers[0])[0]);
|
||||
assert_eq!(parts.len(), 3, "one compound path for each run");
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let b = part.visual_bounds().unwrap();
|
||||
assert!(near(b, want[i]) < 0.05, "run {i} at {turn}°: {b:?}, laid out at {:?}", want[i]);
|
||||
}
|
||||
}
|
||||
// A full turn draws the character upright: text.
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(typed(360.0, false))));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write(&doc);
|
||||
assert!(String::from_utf8_lossy(&out.bytes).contains(" To\n"), "a text object");
|
||||
assert!(!out.warnings.iter().any(|w| w == ROTATED_TYPE), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// Ruby (furigana), which a text object can't hold, makes type 8-bit text could hold outlines,
|
||||
/// with the reading's glyphs among them, where the canvas draws them (the file prints as the
|
||||
/// document draws).
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use super::encoding::{
|
||||
Ink, average, direct_fill, direct_stroke, finite, font_of, invertible, latin_string, lost_attributes, segment_point, unit, writable,
|
||||
Ink, average, direct_fill, direct_stroke, finite, font_of, holds_as_text, invertible, latin_string, lost_attributes, rotated, segment_point,
|
||||
unit, writable,
|
||||
};
|
||||
use super::{
|
||||
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
|
||||
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
|
||||
LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection,
|
||||
ParaStyle, PathData, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
|
||||
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE,
|
||||
TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
};
|
||||
|
||||
pub(super) struct Writer<'a> {
|
||||
@@ -658,7 +659,8 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- type ----------
|
||||
|
||||
/// Type: a text object when the format can hold it as text, else its outlines. A run whose
|
||||
/// Type: a text object when the format can hold it as text, else its outlines (type with
|
||||
/// rotated characters among them, which keeps their rotation). A run whose
|
||||
/// font size measures a height other than the em box is written at the em size it is drawn
|
||||
/// at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
|
||||
fn text(&mut self, t: &TextObject) {
|
||||
@@ -671,7 +673,12 @@ impl<'a> Writer<'a> {
|
||||
if writable(t) {
|
||||
return self.text_object(t);
|
||||
}
|
||||
self.warn(TYPE);
|
||||
if !holds_as_text(t) {
|
||||
self.warn(TYPE);
|
||||
}
|
||||
if rotated(t) {
|
||||
self.warn(ROTATED_TYPE);
|
||||
}
|
||||
}
|
||||
self.outlines(t);
|
||||
}
|
||||
|
||||
+45
-17
@@ -142,8 +142,9 @@ pub(crate) struct Scene<'a> {
|
||||
cms: Arc<vectorcraft_color::cms::Cms>,
|
||||
body: String,
|
||||
setup: String,
|
||||
/// Spot colour spaces defined in the setup (`VCsp<i>`): swatch name and CMYK equivalent.
|
||||
spots: Vec<(String, [f32; 4])>,
|
||||
/// Spot colour spaces defined in the setup (`VCsp<i>`): swatch name, colorant name
|
||||
/// ([`Scene::colorant_name`]) and CMYK equivalent.
|
||||
spots: Vec<(String, String, [f32; 4])>,
|
||||
/// DeviceN color spaces defined in the setup (`VCdn<i>`), as their definitions.
|
||||
device_n: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
@@ -191,7 +192,12 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
|
||||
fn page(self) -> Page {
|
||||
let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect();
|
||||
let custom = self
|
||||
.spots
|
||||
.iter()
|
||||
.filter(|(n, ..)| n != vectorcraft_color::swatch::REGISTRATION)
|
||||
.map(|(_, colorant, cmyk)| (colorant.clone(), *cmyk))
|
||||
.collect();
|
||||
Page { setup: self.setup, body: self.body, custom, warnings: self.warnings }
|
||||
}
|
||||
|
||||
@@ -235,7 +241,7 @@ impl<'a> Scene<'a> {
|
||||
/// The index of the Separation colour space of spot swatch (or Registration) `name`, defined
|
||||
/// in the setup the first time; `None` when `name` is neither.
|
||||
fn spot_space(&mut self, name: &str) -> Option<usize> {
|
||||
if let Some(i) = self.spots.iter().position(|(n, _)| n == name) {
|
||||
if let Some(i) = self.spots.iter().position(|(n, ..)| n == name) {
|
||||
return Some(i);
|
||||
}
|
||||
let registration = name == vectorcraft_color::swatch::REGISTRATION;
|
||||
@@ -249,17 +255,37 @@ impl<'a> Scene<'a> {
|
||||
};
|
||||
let i = self.spots.len();
|
||||
let _ = write!(self.setup, "/VCsp{i} [/Separation ");
|
||||
if registration {
|
||||
let colorant = if registration {
|
||||
self.setup.push_str("/All /DeviceCMYK {dup dup dup}");
|
||||
"All".to_string()
|
||||
} else {
|
||||
let colorant = self.colorant_name(name);
|
||||
let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v)));
|
||||
let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(name));
|
||||
}
|
||||
let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(&colorant));
|
||||
colorant
|
||||
};
|
||||
self.setup.push_str("] def\n");
|
||||
self.spots.push((name.to_string(), cmyk));
|
||||
self.spots.push((name.to_string(), colorant, cmyk));
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The colorant name spot swatch `name` is written under: its own name, except that `All`
|
||||
/// (every plate in PostScript) and `None` (no plate) become `All Spot` and `None Spot`,
|
||||
/// numbered `All Spot 2`, `All Spot 3`, … past the names of the document's spot swatches.
|
||||
fn colorant_name(&self, name: &str) -> String {
|
||||
if name != "All" && name != "None" {
|
||||
return name.to_string();
|
||||
}
|
||||
let taken = |c: &str| self.doc.swatches_iter().any(|s| s.spot && s.name == c);
|
||||
let mut colorant = format!("{name} Spot");
|
||||
let mut n = 2;
|
||||
while taken(&colorant) {
|
||||
colorant = format!("{name} Spot {n}");
|
||||
n += 1;
|
||||
}
|
||||
colorant
|
||||
}
|
||||
|
||||
fn set_ink(&mut self, ink: Ink) {
|
||||
match ink {
|
||||
Ink::Process(space, v) => {
|
||||
@@ -819,26 +845,28 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
|
||||
/// A DeviceN color space of process inks `process` (indices into CMYK) and spot inks `names`,
|
||||
/// defined in the setup the first time, each spot ink with the CMYK equivalent its Separation
|
||||
/// space has. `None` when a spot ink has no Separation space, two colorants share a name, a
|
||||
/// spot ink has the name of a process ink, `All` or `None`, or there are more than
|
||||
/// [`MAX_DEVICE_N`] colorants.
|
||||
/// defined in the setup the first time, each spot ink under the colorant name and with the
|
||||
/// CMYK equivalent its Separation space has. `None` when a spot ink has no Separation space,
|
||||
/// two colorants share a name, a spot ink's colorant has the name of a process ink, `All` or
|
||||
/// `None`, or there are more than [`MAX_DEVICE_N`] colorants.
|
||||
fn device_n_space(&mut self, process: &[usize], names: &[String]) -> Option<GradientSpace> {
|
||||
const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
|
||||
if process.len() + names.len() > MAX_DEVICE_N {
|
||||
return None;
|
||||
}
|
||||
let mut colorants: Vec<&str> = process.iter().map(|&i| PROCESS[i]).collect();
|
||||
let mut colorants: Vec<String> = process.iter().map(|&i| PROCESS[i].to_string()).collect();
|
||||
let mut weights: Vec<[f32; 4]> = process.iter().map(|&i| [0, 1, 2, 3].map(|j| if i == j { 1.0 } else { 0.0 })).collect();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || names[..i].contains(name) {
|
||||
let spots: Vec<String> = names.iter().map(|n| self.colorant_name(n)).collect();
|
||||
for (i, name) in spots.iter().enumerate() {
|
||||
if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || spots[..i].contains(name) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for name in names {
|
||||
colorants.push(name);
|
||||
let i = self.spot_space(name)?;
|
||||
weights.push(self.spots.get(i)?.1);
|
||||
let (_, colorant, cmyk) = self.spots.get(i)?;
|
||||
colorants.push(colorant.clone());
|
||||
weights.push(*cmyk);
|
||||
}
|
||||
let mut def = String::from("[/DeviceN [");
|
||||
for (i, c) in colorants.iter().enumerate() {
|
||||
|
||||
+44
-2
@@ -381,8 +381,50 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_inks_named_like_process_inks_all_or_none_are_not_device_n_colorants() {
|
||||
for name in ["Cyan", "Black", "All", "None"] {
|
||||
fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() {
|
||||
let rect = |d: &mut Document, fill: &str, stroke: &str| {
|
||||
let solid = |name: &str| Paint::Solid { color: GOLD, swatch: Some(name.into()), tint: 1.0 };
|
||||
path(d, shapes::rectangle(Rect::new(50.0, 60.0, 150.0, 100.0)), solid(fill), solid(stroke), 2.0)
|
||||
};
|
||||
for (name, colorant) in [("All", "All Spot"), ("None", "None Spot")] {
|
||||
// A fill of the spot ink and a Registration stroke.
|
||||
let mut d = doc_with(|_| vec![]);
|
||||
d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true });
|
||||
let n = rect(&mut d, name, vectorcraft_color::swatch::REGISTRATION);
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
|
||||
assert_eq!(dsc(&ps, "DocumentCustomColors"), Some(format!("({colorant})")), "{ps}");
|
||||
assert_eq!(dsc(&ps, "CMYKCustomColor"), Some(format!("0 0.2 0.8 0.1 ({colorant})")), "{ps}");
|
||||
assert!(ps.contains(&format!("[/Separation ({colorant}) cvn /DeviceCMYK {{dup 0 mul")), "{name}: {ps}");
|
||||
assert!(ps.contains("[/Separation /All /DeviceCMYK {dup dup dup}]"), "Registration prints on every plate: {ps}");
|
||||
assert!(!ps.contains(&format!("({name}) cvn")), "{name}: {ps}");
|
||||
checked_page(&ps);
|
||||
|
||||
// In a gradient with another spot ink, the DeviceN space has the same colorant.
|
||||
let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]);
|
||||
d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(LEAF), global: true, spot: true });
|
||||
let out = export(&d, &opts(&d), None, None).unwrap();
|
||||
let ps = text(&out);
|
||||
assert!(ps.contains(&format!("/VCdn0 [/DeviceN [(Gold Ink) cvn ({colorant}) cvn ] /DeviceCMYK")), "{name}: {ps}");
|
||||
assert!(ps.contains(&format!("%%DocumentCustomColors: (Gold Ink)\n%%+ ({colorant})\n")), "{name}: {ps}");
|
||||
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
// A spot swatch already named All Spot keeps its name, and All is numbered past it.
|
||||
let mut d = doc_with(|_| vec![]);
|
||||
for name in ["All", "All Spot"] {
|
||||
d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true });
|
||||
}
|
||||
let n = rect(&mut d, "All", "All Spot");
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
|
||||
assert!(ps.contains("[/Separation (All Spot 2) cvn ") && ps.contains("[/Separation (All Spot) cvn "), "{ps}");
|
||||
assert!(ps.contains("%%DocumentCustomColors: (All Spot 2)\n%%+ (All Spot)\n"), "{ps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_inks_named_like_process_inks_are_not_device_n_colorants() {
|
||||
for name in ["Cyan", "Black"] {
|
||||
// Gold Ink to the spot ink, with no process ink in the stops.
|
||||
let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]);
|
||||
d.swatches.retain(|s| s.name != name);
|
||||
|
||||
@@ -10,7 +10,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
craft-vectorcraft-format = { workspace = true }
|
||||
serde ={ workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
+197
-213
@@ -37,33 +37,23 @@ mod legacy;
|
||||
mod stream;
|
||||
mod unpack;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{Read as _, Write};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail};
|
||||
use serde::Serialize;
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
|
||||
pub use craft_vectorcraft_format::base64::{decode as base64_decode, encode as base64_encode};
|
||||
pub use craft_vectorcraft_format::{
|
||||
BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part,
|
||||
sniff,
|
||||
};
|
||||
use stream::{Gz, Indent, KeyScan, Out};
|
||||
|
||||
/// v4: large data after the JSON (v1 to v3 files still load).
|
||||
pub const VERSION: u32 = 4;
|
||||
/// Data this large or larger goes after the JSON (v4), not into it as base64.
|
||||
pub const INLINE_MAX: usize = 8 << 10;
|
||||
/// What separates the JSON from the data after it (v4).
|
||||
pub const BLOB_MAGIC: &[u8] = b"\n\0VCBLOBS\0";
|
||||
/// The oldest version [`save_with`] writes.
|
||||
pub const MIN_VERSION: u32 = 1;
|
||||
/// The first version whose readers open compressed files.
|
||||
pub const COMPRESSED_SINCE: u32 = 3;
|
||||
/// Most pixels on the longer side of an embedded preview.
|
||||
pub const PREVIEW_MAX: u32 = 256;
|
||||
pub const EXTENSION: &str = "vectorcraft";
|
||||
/// Extension and format name from before the project was renamed (DrawCraft): still opened.
|
||||
pub const LEGACY_EXTENSION: &str = "drawcraft";
|
||||
/// Most bytes the JSON of a compressed file may unpack to (a guard against decompression bombs,
|
||||
/// as are [`MAX_BLOB`] and [`MAX_BLOBS`]). A compressed save refuses a document past any of them,
|
||||
/// so every file VectorCraft writes reads back. On wasm32 the cap is a quarter of the native
|
||||
@@ -72,7 +62,7 @@ pub const LEGACY_EXTENSION: &str = "drawcraft";
|
||||
pub const MAX_JSON: u64 = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 1 << 30 };
|
||||
/// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a
|
||||
/// profile) may have in a compressed file.
|
||||
pub const MAX_BLOB: u64 = 1 << 30;
|
||||
pub const MAX_BLOB: u64 = craft_vectorcraft_format::MAX_ENTRY;
|
||||
/// Most bytes the entries after the JSON may have together in a compressed file: 1 GiB in the web
|
||||
/// build, whose 4 GiB address space also holds the JSON, the document and the app, so 4 GiB of
|
||||
/// entries failed to allocate rather than being refused.
|
||||
@@ -111,53 +101,17 @@ pub enum FormatError {
|
||||
Encode(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Image {
|
||||
mime: String,
|
||||
/// The bytes as base64, unless they come after the JSON (`blob`).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
data: String,
|
||||
/// Where the bytes are in the data after the JSON (v4): `[offset, length]`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
blob: Option<[u64; 2]>,
|
||||
/// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
proxy: bool,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Its bytes when they are in the JSON (base64 `data`); `None` when they come after it
|
||||
/// ([`Image::blob`]) or don't decode.
|
||||
fn inline(&self) -> Option<Vec<u8>> {
|
||||
match self.blob {
|
||||
Some(_) => None,
|
||||
None => base64_decode(&self.data),
|
||||
impl From<craft_vectorcraft_format::Error> for FormatError {
|
||||
fn from(e: craft_vectorcraft_format::Error) -> Self {
|
||||
match e {
|
||||
craft_vectorcraft_format::Error::Format(format) => FormatError::NotVectorcraft(format!("format is `{format}`")),
|
||||
craft_vectorcraft_format::Error::TooNew(version) => FormatError::TooNew(version),
|
||||
// The crate's other errors (`TooBig`: entries past the data after the JSON) keep their message.
|
||||
e => FormatError::NotVectorcraft(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries written for one file: large bytes go to the data after the JSON when the version keeps
|
||||
/// them there (in the order they are added, each at the offset it is given).
|
||||
struct Blobs<'a> {
|
||||
/// The bytes, and whether they are compressed already ([`is_packed`]).
|
||||
tail: Vec<(&'a [u8], bool)>,
|
||||
at: u64,
|
||||
outside: bool,
|
||||
}
|
||||
|
||||
impl<'a> Blobs<'a> {
|
||||
fn image(&mut self, mime: &str, bytes: &'a [u8], proxy: bool) -> Image {
|
||||
if self.outside && bytes.len() >= INLINE_MAX {
|
||||
let len = bytes.len() as u64;
|
||||
let at = self.at;
|
||||
self.at += len;
|
||||
self.tail.push((bytes, is_packed(mime)));
|
||||
return Image { mime: mime.into(), data: String::new(), blob: Some([at, len]), proxy };
|
||||
}
|
||||
Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is data of this MIME type compressed already (no use compressing it again)?
|
||||
fn is_packed(mime: &str) -> bool {
|
||||
matches!(mime, "image/jpeg" | "image/png" | "image/gif" | "image/webp")
|
||||
@@ -168,52 +122,6 @@ fn preview_mime(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
|
||||
}
|
||||
|
||||
/// The JSON of a native file without the data after it (version 4 keeps images there): a
|
||||
/// complete JSON value whose `images` give each image's MIME type and where its data was, without
|
||||
/// the data.
|
||||
pub fn json_part(file: &[u8]) -> &[u8] {
|
||||
split_tail(file).0
|
||||
}
|
||||
|
||||
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
|
||||
/// value.
|
||||
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
|
||||
// JSON holds no raw NUL, so the marker (which has one) starts at the first NUL, if the file
|
||||
// has one. The writer puts it right after the JSON's last byte; found there, it is where the
|
||||
// first JSON value ends, without parsing the JSON twice. Anything else takes the parse.
|
||||
let Some(nul) = text.iter().position(|&b| b == 0) else {
|
||||
// No NUL, so no marker: the parse below would find none either.
|
||||
return (text, &[]);
|
||||
};
|
||||
if let Some(at) = nul.checked_sub(1)
|
||||
&& text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC))
|
||||
&& at.checked_sub(1).and_then(|k| text.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"'))
|
||||
{
|
||||
return (text.get(..at).unwrap_or(text), text.get(at + BLOB_MAGIC.len()..).unwrap_or_default());
|
||||
}
|
||||
let mut values = serde_json::Deserializer::from_slice(text).into_iter::<serde::de::IgnoredAny>();
|
||||
if values.next().is_some_and(|v| v.is_ok()) {
|
||||
let end = values.byte_offset();
|
||||
if let Some(rest) = text.get(end..)
|
||||
&& rest.starts_with(BLOB_MAGIC)
|
||||
{
|
||||
return (text.get(..end).unwrap_or(text), rest.get(BLOB_MAGIC.len()..).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
(text, &[])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
format: String,
|
||||
version: u32,
|
||||
document: Document,
|
||||
#[serde(default)]
|
||||
images: BTreeMap<String, Image>,
|
||||
#[serde(default)]
|
||||
profiles: BTreeMap<String, Image>,
|
||||
}
|
||||
|
||||
/// How [`save_with`] writes a document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SaveOptions {
|
||||
@@ -239,9 +147,6 @@ impl Default for SaveOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The MIME type of embedded ICC profiles.
|
||||
pub const ICC_MIME: &str = "application/vnd.iccprofile";
|
||||
|
||||
impl SaveOptions {
|
||||
/// Can a file be written this way (a version [`save_with`] writes, compressed only for readers
|
||||
/// that open compressed files)?
|
||||
@@ -369,7 +274,7 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For
|
||||
let pretty = o.pretty && !o.compress;
|
||||
let Body { d, v1, blobs, linked } = body(doc, o)?;
|
||||
let entries = Entries { blobs: &blobs, linked: &linked, include_linked: o.include_linked };
|
||||
let mut out_blobs = Blobs { tail: vec![], at: 0, outside: o.version >= 4 };
|
||||
let mut out_blobs = Tail::new(o.version);
|
||||
// Images are only referenced by image objects' `key` (wherever the objects are: layers,
|
||||
// symbols, patterns, masks, or foreign data). The data after the JSON has the images first,
|
||||
// then the profiles and the PDF, which the file lists before the document: with those, the
|
||||
@@ -382,7 +287,6 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For
|
||||
}
|
||||
let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), out_blobs.image(ICC_MIME, icc, false))).collect();
|
||||
let pdf = o.pdf.as_deref().map(|p| out_blobs.image("application/pdf", p, false));
|
||||
let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy: false };
|
||||
let mut out = if o.compress { Out::Gz(Box::new(Gz::new(w).map_err(encode_err)?)) } else { Out::Plain(w) };
|
||||
let mut env = Envelope { pretty, first: true };
|
||||
// Older apps only know the name from before the rename.
|
||||
@@ -390,7 +294,7 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For
|
||||
env.field(&mut out, "version", &o.version)?;
|
||||
env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?;
|
||||
if let Some(png) = &o.preview {
|
||||
env.field(&mut out, "preview", &blob("image/png", png))?;
|
||||
env.field(&mut out, "preview", &Image::inline("image/png", png))?;
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
env.field(&mut out, "profiles", &profiles)?;
|
||||
@@ -412,22 +316,22 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For
|
||||
env.finish(&mut out)?;
|
||||
if o.compress {
|
||||
let json = out.written().unwrap_or(0);
|
||||
let tail: Vec<u64> = out_blobs.tail.iter().map(|(b, _)| b.len() as u64).collect();
|
||||
let tail: Vec<u64> = out_blobs.parts().iter().map(|p| p.bytes.len() as u64).collect();
|
||||
if json > MAX_JSON {
|
||||
return Err(FormatError::TooBigToCompress(format!("{} MB of document, at most {} MB", json >> 20, MAX_JSON >> 20)));
|
||||
}
|
||||
if let Some(big) = tail.iter().find(|&&n| n > MAX_BLOB) {
|
||||
return Err(FormatError::TooBigToCompress(format!("an image of {} MB, at most {} MB", big >> 20, MAX_BLOB >> 20)));
|
||||
}
|
||||
if out_blobs.at > MAX_BLOBS {
|
||||
return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.at >> 20, MAX_BLOBS >> 20)));
|
||||
if out_blobs.len() > MAX_BLOBS {
|
||||
return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.len() >> 20, MAX_BLOBS >> 20)));
|
||||
}
|
||||
}
|
||||
if !out_blobs.tail.is_empty() {
|
||||
if !out_blobs.is_empty() {
|
||||
out.write_all(BLOB_MAGIC).map_err(encode_err)?;
|
||||
for &(bytes, packed) in &out_blobs.tail {
|
||||
out.packed(packed).map_err(encode_err)?;
|
||||
out.write_all(bytes).map_err(encode_err)?;
|
||||
for part in out_blobs.parts() {
|
||||
out.packed(is_packed(&part.mime)).map_err(encode_err)?;
|
||||
out.write_all(part.bytes).map_err(encode_err)?;
|
||||
}
|
||||
}
|
||||
out.finish().map_err(encode_err)
|
||||
@@ -443,7 +347,7 @@ struct Entries<'a> {
|
||||
|
||||
impl<'a> Entries<'a> {
|
||||
/// The entries of the images named in `used`, in the order of their names.
|
||||
fn images(&self, used: &HashSet<String>, out: &mut Blobs<'a>) -> BTreeMap<&'a str, Image> {
|
||||
fn images(&self, used: &HashSet<String>, out: &mut Tail<'a>) -> BTreeMap<&'a str, Image> {
|
||||
self.blobs
|
||||
.iter()
|
||||
.filter(|(k, _)| used.contains(k.as_str()))
|
||||
@@ -508,11 +412,6 @@ impl Envelope {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this gzip data (a compressed file)?
|
||||
pub fn is_compressed(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
load_info(bytes).map(|(doc, _)| doc)
|
||||
}
|
||||
@@ -563,22 +462,22 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
|
||||
let file = unpack::Unpacked::new(bytes)?;
|
||||
// The JSON reader stops at 128 levels (a document's objects use three each), the paths read
|
||||
// at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the
|
||||
// fields read before an object's type at `MAX_BUFFERED_VALUES` values.
|
||||
// fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the
|
||||
// PDF are skipped without being kept.
|
||||
let read = || {
|
||||
vectorcraft_doc::node::with_node_budget(max_nodes, || {
|
||||
vectorcraft_doc::node::with_buffered_value_budget(max_values, || serde_json::from_slice::<File>(file.json()))
|
||||
vectorcraft_doc::node::with_buffered_value_budget(max_values, || Contents::<Document>::read(file.json()))
|
||||
})
|
||||
};
|
||||
let f: File = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| {
|
||||
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
|
||||
FormatError::NotVectorcraft(why)
|
||||
let f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e {
|
||||
ReadError::Json(e) => {
|
||||
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
|
||||
FormatError::NotVectorcraft(why)
|
||||
}
|
||||
ReadError::Envelope(e) => e.into(),
|
||||
e => FormatError::NotVectorcraft(e.to_string()),
|
||||
})?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
||||
}
|
||||
if f.version > VERSION {
|
||||
return Err(FormatError::TooNew(f.version));
|
||||
}
|
||||
let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION };
|
||||
// The bytes after the JSON, read in one pass: the images' first, then the profiles'.
|
||||
let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect();
|
||||
let tail = file.entries(&entries)?;
|
||||
@@ -589,7 +488,7 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
|
||||
let mut tail = tail.into_iter();
|
||||
let mut bytes_of = |img: &Image| match img.blob {
|
||||
Some(_) => tail.next().flatten(),
|
||||
None => img.inline().map(Arc::new),
|
||||
None => base64_decode(&img.data).map(Arc::new),
|
||||
};
|
||||
let mut doc = f.document;
|
||||
for (k, img) in f.images {
|
||||
@@ -652,14 +551,7 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
|
||||
}
|
||||
})
|
||||
.collect::<Result<_, FormatError>>()?;
|
||||
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
/// The members of a native file besides its document (which is skipped, not decoded).
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
preview: Option<Image>,
|
||||
pdf: Option<Image>,
|
||||
Ok(NativeFile { doc, depth_notes, info, profiles })
|
||||
}
|
||||
|
||||
/// The bytes of the member of the file's head `pick` picks (only that entry of the data after
|
||||
@@ -670,7 +562,7 @@ fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option<Image>) -> Option
|
||||
let img = pick(head)?;
|
||||
match img.blob {
|
||||
Some(at) => file.entries(&[at]).ok()?.pop().flatten().map(Arc::unwrap_or_clone),
|
||||
None => img.inline(),
|
||||
None => base64_decode(&img.data),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,71 +577,6 @@ pub fn pdf_content(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
head_member(bytes, |h| h.pdf)
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file (compressed or not)?
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
const HEAD: usize = 256;
|
||||
let head: Cow<'_, [u8]> = if is_compressed(bytes) {
|
||||
let mut head = Vec::with_capacity(HEAD);
|
||||
// A damaged stream may still unpack its start: judge what came out.
|
||||
let _ = flate2::read::GzDecoder::new(bytes).take(HEAD as u64).read_to_end(&mut head);
|
||||
Cow::Owned(head)
|
||||
} else {
|
||||
Cow::Borrowed(bytes.get(..HEAD).unwrap_or(bytes))
|
||||
};
|
||||
// The cut may split a character.
|
||||
let s = String::from_utf8_lossy(&head);
|
||||
s.trim_start().starts_with('{') && (s.contains("\"vectorcraft\"") || s.contains("\"drawcraft\""))
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
out.push(B64[(n >> 18) as usize & 63] as char);
|
||||
out.push(B64[(n >> 12) as usize & 63] as char);
|
||||
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
|
||||
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if !clean.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
let groups = clean.len() / 4;
|
||||
for (i, c) in clean.chunks(4).enumerate() {
|
||||
let mut n = 0u32;
|
||||
let mut pad = 0;
|
||||
for &b in c {
|
||||
n <<= 6;
|
||||
if b == b'=' {
|
||||
pad += 1;
|
||||
} else if pad > 0 {
|
||||
return None;
|
||||
} else {
|
||||
n |= val(b)?;
|
||||
}
|
||||
}
|
||||
if pad > 2 || (pad > 0 && i + 1 != groups) {
|
||||
return None;
|
||||
}
|
||||
out.push((n >> 16) as u8);
|
||||
if pad < 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if pad < 1 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -778,6 +605,163 @@ mod tests {
|
||||
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
|
||||
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
|
||||
}
|
||||
|
||||
/// A file is native when its top-level `format` member names VectorCraft, read as JSON reads
|
||||
/// it; the word elsewhere in the head does not count.
|
||||
#[test]
|
||||
fn sniff_reads_the_format_member() {
|
||||
// `vectorcraft` with its second `c` written as a JSON escape.
|
||||
let escaped = [br#"{"format":"vector\"#.as_slice(), br#"u0063raft","version":4,"document":{}}"#].concat();
|
||||
assert!(sniff(&escaped));
|
||||
assert!(!sniff(br#"{"format":"other","generator":"vectorcraft"}"#));
|
||||
assert!(!sniff(br#"{"names":["vectorcraft"],"format":"svg"}"#));
|
||||
}
|
||||
|
||||
/// Every layout VectorCraft has written puts `format` first: the version 1 and 2 files from
|
||||
/// before the rename (a struct of `format`, `version`, `generator`, `document` and `images`,
|
||||
/// compact or pretty), and each version `save_with` writes, compact, pretty and compressed.
|
||||
#[test]
|
||||
fn sniff_takes_every_layout_written() {
|
||||
let before_rename = [
|
||||
r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{},"images":{}}"#,
|
||||
"{\n \"format\": \"drawcraft\",\n \"version\": 2,\n \"generator\": \"DrawCraft 0.1.0\",\n \"document\": {},\n \"images\": {}\n}",
|
||||
];
|
||||
for text in before_rename {
|
||||
assert!(sniff(text.as_bytes()), "{text}");
|
||||
}
|
||||
let d = Document::new(10.0, 10.0);
|
||||
for version in MIN_VERSION..=VERSION {
|
||||
for (pretty, compress) in [(false, false), (true, false), (false, true)] {
|
||||
let o = SaveOptions { pretty, compress, version, ..SaveOptions::default() };
|
||||
if o.check().is_err() {
|
||||
continue;
|
||||
}
|
||||
let bytes = save_with(&d, &o).unwrap();
|
||||
assert!(sniff(&bytes), "version {version}, pretty {pretty}, compress {compress}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An envelope error other than the format and version keeps the crate's message.
|
||||
#[test]
|
||||
fn envelope_errors_keep_their_message() {
|
||||
let e = FormatError::from(craft_vectorcraft_format::Error::TooBig);
|
||||
assert!(matches!(e, FormatError::NotVectorcraft(_)), "{e}");
|
||||
assert!(e.to_string().contains(&craft_vectorcraft_format::Error::TooBig.to_string()), "{e}");
|
||||
let e = FormatError::from(craft_vectorcraft_format::Error::Format("x".into()));
|
||||
assert_eq!(e.to_string(), "not a VectorCraft file: format is `x`");
|
||||
assert!(matches!(FormatError::from(craft_vectorcraft_format::Error::TooNew(9)), FormatError::TooNew(9)));
|
||||
}
|
||||
|
||||
/// `save_to` writes the file member by member and streams the document in. For every version
|
||||
/// it writes, compact, pretty and compressed, its bytes are those of the crate's `File`
|
||||
/// holding the same members, serialized with `serde_json::to_vec` or `to_vec_pretty` and
|
||||
/// followed by the crate's `Tail`.
|
||||
#[test]
|
||||
fn save_to_writes_the_bytes_of_the_crates_file() {
|
||||
use std::io::Read;
|
||||
|
||||
/// The document as `save_to` writes it: its version 1 form, or the document.
|
||||
struct Written<'a>(&'a Body);
|
||||
impl Serialize for Written<'_> {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match &self.0.v1 {
|
||||
Some(v) => v.serialize(s),
|
||||
None => self.0.d.serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
||||
// An image large enough to go after the JSON from version 4, a small one, and one that
|
||||
// nothing uses.
|
||||
let big: Vec<u8> = (0..INLINE_MAX as u32 + 5).map(|i| (i % 251) as u8).collect();
|
||||
for (key, bytes) in [("big", big), ("small", vec![1, 2, 3, 250]), ("unused", vec![9])] {
|
||||
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
|
||||
}
|
||||
for (i, key) in ["big", "small"].into_iter().enumerate() {
|
||||
let img = d.alloc_id();
|
||||
let im =
|
||||
ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(Some(l), i + 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
}
|
||||
let png = vec![0x89, b'P', b'N', b'G'];
|
||||
let icc = vec![7u8; INLINE_MAX];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
pdf.resize(INLINE_MAX + 1, b' ');
|
||||
let full = SaveOptions {
|
||||
preview: Some(png.clone()),
|
||||
profiles: [("Gray".to_string(), icc.clone())].into(),
|
||||
pdf: Some(pdf.clone()),
|
||||
..SaveOptions::default()
|
||||
};
|
||||
let mut checked = 0;
|
||||
for version in MIN_VERSION..=VERSION {
|
||||
for members in [&full, &SaveOptions::default()] {
|
||||
for pretty in [false, true] {
|
||||
let o = SaveOptions { version, pretty, ..members.clone() };
|
||||
let b = body(&d, &o).unwrap();
|
||||
let mut file = craft_vectorcraft_format::File::new(Written(&b));
|
||||
file.format = if version < 3 { LEGACY_EXTENSION } else { EXTENSION }.into();
|
||||
file.version = version;
|
||||
file.generator = Some(format!("VectorCraft {}", env!("CARGO_PKG_VERSION")));
|
||||
file.preview = o.preview.as_deref().map(|p| Image::inline("image/png", p));
|
||||
// The images come first in the data after the JSON, then the profiles and the PDF.
|
||||
let mut tail = file.tail();
|
||||
for key in ["big", "small"] {
|
||||
file.images.insert(key.into(), tail.image("image/png", b.blobs[key].bytes.as_slice(), false));
|
||||
}
|
||||
for (name, icc) in &o.profiles {
|
||||
file.profiles.insert(name.clone(), tail.image(ICC_MIME, icc, false));
|
||||
}
|
||||
file.pdf = o.pdf.as_deref().map(|p| tail.image("application/pdf", p, false));
|
||||
let mut expected = if pretty { serde_json::to_vec_pretty(&file) } else { serde_json::to_vec(&file) }.unwrap();
|
||||
tail.write_to(&mut expected).unwrap();
|
||||
assert_eq!(tail.is_empty(), version < 4, "version {version}");
|
||||
assert!(save_with(&d, &o).unwrap() == expected, "version {version}, pretty {pretty}, {} profiles", o.profiles.len());
|
||||
checked += 1;
|
||||
if version >= COMPRESSED_SINCE && !pretty {
|
||||
let gz = save_with(&d, &SaveOptions { compress: true, ..o.clone() }).unwrap();
|
||||
let mut unpacked = Vec::new();
|
||||
flate2::read::GzDecoder::new(gz.as_slice()).read_to_end(&mut unpacked).unwrap();
|
||||
assert!(unpacked == expected, "version {version}, compressed, {} profiles", o.profiles.len());
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 20);
|
||||
}
|
||||
|
||||
/// Files are read through the crate's `Contents` with the messages VectorCraft gave before,
|
||||
/// and a top-level array holds the format, version, document, images and profiles in that
|
||||
/// order.
|
||||
#[test]
|
||||
fn files_read_through_the_crates_contents_as_before() {
|
||||
let message = |json: &str| load(json.as_bytes()).err().map(|e| e.to_string());
|
||||
assert_eq!(
|
||||
message(r#""x""#).as_deref(),
|
||||
Some(r#"not a VectorCraft file: invalid type: string "x", expected struct File at line 1 column 3"#)
|
||||
);
|
||||
assert!(
|
||||
message(r#"{"format":"vectorcraft","version":4}"#).is_some_and(|m| m.starts_with("not a VectorCraft file: missing field `document`"))
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&save(&Document::new(10.0, 10.0), false)).unwrap();
|
||||
let doc = v["document"].to_string();
|
||||
let file = |format: &str, version: u32| format!(r#"{{"format":"{format}","version":{version},"document":{doc}}}"#);
|
||||
assert_eq!(message(&file("svg", 4)).as_deref(), Some("not a VectorCraft file: format is `svg`"));
|
||||
assert_eq!(message(&file("vectorcraft", 9)).as_deref(), Some("file version 9 is newer than this VectorCraft supports (4)"));
|
||||
// A document that does not read is reported first, as before.
|
||||
assert!(
|
||||
message(r#"{"format":"vectorcraft","version":9,"document":7}"#).is_some_and(|m| m.starts_with("not a VectorCraft file: invalid type"))
|
||||
);
|
||||
for json in [format!(r#"["vectorcraft",3,{doc},{{}},{{}}]"#), format!(r#"["vectorcraft",3,{doc}]"#)] {
|
||||
assert_eq!(load(json.as_bytes()).map(|d| d.artboards.len()).ok(), Some(1), "{json}");
|
||||
}
|
||||
assert!(load(format!(r#"["vectorcraft",3,{{}},{doc},{{}}]"#).as_bytes()).is_err());
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
|
||||
+19
-31
@@ -7,9 +7,10 @@ use std::collections::HashMap;
|
||||
use std::io::{self, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_vectorcraft_format::{Entries, marker_at, split_tail};
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED, is_compressed, split_tail};
|
||||
use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED, is_compressed};
|
||||
|
||||
/// Bytes unpacked at a time while looking for the end of the JSON.
|
||||
const CHUNK: u64 = 64 << 10;
|
||||
@@ -87,19 +88,16 @@ impl<'a> Unpacked<'a> {
|
||||
return Err(FormatError::TooBig);
|
||||
}
|
||||
}
|
||||
let Some(z) = nul else {
|
||||
if nul.is_none() {
|
||||
// No NUL, so no marker: all of it is JSON.
|
||||
if buf.len() as u64 > max_json {
|
||||
return Err(FormatError::TooBig);
|
||||
}
|
||||
return Ok(Self { json: Json::Owned(buf), tail: Tail::None });
|
||||
};
|
||||
// JSON holds no raw NUL, so the marker (which has one) starts right before the first NUL;
|
||||
// the writer puts it right after the JSON's last byte.
|
||||
if let Some(end) = z.checked_sub(1)
|
||||
&& buf.get(end..).is_some_and(|r| r.starts_with(BLOB_MAGIC))
|
||||
&& end.checked_sub(1).and_then(|k| buf.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"'))
|
||||
{
|
||||
}
|
||||
// The marker where the writer puts it, right after the JSON's last byte: what is unpacked
|
||||
// so far reaches past it.
|
||||
if let Some(end) = marker_at(&buf) {
|
||||
if end as u64 > max_json {
|
||||
return Err(FormatError::TooBig);
|
||||
}
|
||||
@@ -143,24 +141,17 @@ impl<'a> Unpacked<'a> {
|
||||
/// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them.
|
||||
fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Entry>, FormatError> {
|
||||
let mut out: Vec<Entry> = vec![None; wanted.len()];
|
||||
// The entries read so far, by `[offset, length]`, and the bytes copied for them.
|
||||
let mut done: HashMap<[u64; 2], Entry> = HashMap::new();
|
||||
let mut copied = 0u64;
|
||||
let mut in_memory = |tail: &[u8], out: &mut Vec<Entry>| {
|
||||
// Data in memory is read through the crate's `Entries`: entries naming the same bytes
|
||||
// share them, and the bytes copied stay within the data.
|
||||
let in_memory = |tail: &[u8], out: &mut Vec<Entry>| {
|
||||
let mut entries = Entries::new(tail);
|
||||
for (slot, &w) in out.iter_mut().zip(wanted) {
|
||||
if let Some(e) = done.get(&w) {
|
||||
*slot = e.clone();
|
||||
continue;
|
||||
}
|
||||
let bytes = range(w[0], w[1]).and_then(|(a, b)| tail.get(a..b));
|
||||
copied = copied.saturating_add(bytes.map_or(0, |b| b.len() as u64));
|
||||
if copied > tail.len() as u64 {
|
||||
return Err(FormatError::TooBig);
|
||||
}
|
||||
*slot = bytes.map(|b| Arc::new(b.to_vec()));
|
||||
done.insert(w, slot.clone());
|
||||
*slot = entries.blob(w).map_err(|e| match e {
|
||||
craft_vectorcraft_format::Error::TooBig => FormatError::TooBig,
|
||||
e => e.into(),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
Ok::<(), FormatError>(())
|
||||
};
|
||||
match self.tail {
|
||||
Tail::Slice(tail) => {
|
||||
@@ -179,6 +170,9 @@ impl<'a> Unpacked<'a> {
|
||||
Tail::Stream { file, at, read, rest } => {
|
||||
within(wanted, MAX_BLOB, MAX_BLOBS)?;
|
||||
drop(self.json);
|
||||
// The entries read so far, by `[offset, length]`, and the bytes copied for them.
|
||||
let mut done: HashMap<[u64; 2], Entry> = HashMap::new();
|
||||
let mut copied = 0u64;
|
||||
let mut order: Vec<usize> = (0..wanted.len()).collect();
|
||||
order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0]));
|
||||
let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work };
|
||||
@@ -220,12 +214,6 @@ fn within(wanted: &[[u64; 2]], each: u64, all: u64) -> Result<(), FormatError> {
|
||||
if total > all { Err(FormatError::TooBig) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// `offset..offset + len` as indices, when they fit.
|
||||
fn range(offset: u64, len: u64) -> Option<(usize, usize)> {
|
||||
let a = usize::try_from(offset).ok()?;
|
||||
Some((a, a.checked_add(usize::try_from(len).ok()?)?))
|
||||
}
|
||||
|
||||
/// The data after a compressed file's JSON, being unpacked.
|
||||
struct Stream<'a> {
|
||||
r: Box<dyn Read + 'a>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `.vectorcraft` v4: large data after the JSON (images, the PDF, profiles), small files still plain
|
||||
//! JSON, older versions inline, damaged offsets refused.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -87,3 +88,81 @@ fn damaged_offsets_are_refused_not_a_crash() {
|
||||
assert!(load_file(json_only).is_err());
|
||||
let _ = Arc::new(());
|
||||
}
|
||||
|
||||
/// A `preview` of the wrong shape reads as missing without hiding the file's PDF.
|
||||
#[test]
|
||||
fn a_damaged_preview_leaves_the_pdf_readable() {
|
||||
let pdf = b"%PDF-1.7 small".to_vec();
|
||||
let o = SaveOptions { version: 3, preview: Some(vec![5; 30]), pdf: Some(pdf.clone()), ..SaveOptions::default() };
|
||||
let mut v: Value = serde_json::from_slice(&save_with(&doc_with_image("small", 100), &o).unwrap()).unwrap();
|
||||
v["preview"] = Value::from(5);
|
||||
let damaged = serde_json::to_vec(&v).unwrap();
|
||||
assert_eq!(vectorcraft_format::preview(&damaged), None);
|
||||
assert_eq!(pdf_content(&damaged), Some(pdf));
|
||||
assert!(load_file(&damaged).is_ok());
|
||||
}
|
||||
|
||||
/// A repeated `generator`, `preview` or `pdf` opens, of any shape; any other repeated member is
|
||||
/// refused. The crate's `Contents` and `File` accept and refuse the same files.
|
||||
#[test]
|
||||
fn repeated_members_open_only_when_skipped() {
|
||||
let o = SaveOptions { version: 3, ..SaveOptions::default() };
|
||||
let text = String::from_utf8(save_with(&doc_with_image("small", 100), &o).unwrap()).unwrap();
|
||||
let document = serde_json::from_str::<Value>(&text).unwrap()["document"].to_string();
|
||||
let with = |members: &str| text.replacen('{', &format!("{{{members},"), 1);
|
||||
let contents_reads = |s: &str| {
|
||||
let contents = craft_vectorcraft_format::Contents::<serde::de::IgnoredAny>::read(s.as_bytes()).is_ok();
|
||||
assert_eq!(craft_vectorcraft_format::File::<serde::de::IgnoredAny>::read(s.as_bytes()).is_ok(), contents, "{s}");
|
||||
contents
|
||||
};
|
||||
let skipped = with(r#""preview":5,"preview":{"mime":"image/png","data":"AA=="},"generator":[],"generator":"x","pdf":{},"pdf":null"#);
|
||||
let back = load_file(skipped.as_bytes()).unwrap();
|
||||
assert_eq!(back.doc.images["small"].bytes.len(), 100);
|
||||
assert!(contents_reads(&skipped));
|
||||
let document = format!(r#""document":{document}"#);
|
||||
for member in [document.as_str(), r#""format":"vectorcraft""#, r#""version":3"#, r#""images":{}"#, r#""profiles":{}"#] {
|
||||
let repeated = with(&format!("{member},{member}"));
|
||||
let err = load_file(repeated.as_bytes()).err().unwrap_or_else(|| panic!("{member}: a repeated member opened"));
|
||||
assert!(err.to_string().contains("duplicate field"), "{member}: {err}");
|
||||
assert!(!contents_reads(&repeated), "{member}: the crate's Contents read a repeated member");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file read back and saved again with the same options gives the same bytes, in every version,
|
||||
/// compressed or not, with a preview, profiles and a PDF inline and after the JSON.
|
||||
#[test]
|
||||
fn a_file_read_back_saves_to_the_same_bytes() {
|
||||
let mut d = doc_with_image("big", INLINE_MAX + 10);
|
||||
d.images.insert("small".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: "small".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(Some(l), 1, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let profiles: BTreeMap<String, Vec<u8>> = [("Small".to_string(), vec![7; 40]), ("Large".to_string(), vec![9; INLINE_MAX])].into_iter().collect();
|
||||
for version in 1..=4 {
|
||||
for compress in [false, true].into_iter().filter(|&c| !c || version >= 3) {
|
||||
for pretty in [false, true] {
|
||||
let o = SaveOptions {
|
||||
version,
|
||||
compress,
|
||||
pretty,
|
||||
preview: Some(vec![5; 300]),
|
||||
profiles: profiles.clone(),
|
||||
pdf: Some(vec![b'%'; INLINE_MAX * 2]),
|
||||
..SaveOptions::default()
|
||||
};
|
||||
let first = save_with(&d, &o).unwrap();
|
||||
if !compress {
|
||||
let json = vectorcraft_format::json_part(&first);
|
||||
let env = craft_vectorcraft_format::File::<serde::de::IgnoredAny>::read(json).unwrap();
|
||||
assert_eq!((env.version, env.images.len(), env.profiles.len()), (version, 2, 2));
|
||||
assert!(env.preview.is_some() && env.pdf.is_some());
|
||||
}
|
||||
let back = load_file(&first).unwrap();
|
||||
assert_eq!(back.profiles, profiles, "v{version} compress {compress} pretty {pretty}");
|
||||
let again = save_with(&back.doc, &SaveOptions { profiles: back.profiles, ..o }).unwrap();
|
||||
assert!(first == again, "v{version} compress {compress} pretty {pretty}: the second save differs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,21 +210,27 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) ->
|
||||
PathData::single(SubPath::new(anchors, closed))
|
||||
}
|
||||
|
||||
/// Archimedean-style spiral approximating Illustrator's Spiral tool (decay-based, `segments` quarter turns).
|
||||
/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns).
|
||||
pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData {
|
||||
let decay = (decay_percent / 100.0).clamp(0.05, 0.9999);
|
||||
let dir = if clockwise { 1.0 } else { -1.0 };
|
||||
// The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ.
|
||||
let k = decay.ln() / (PI / 2.0);
|
||||
// Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r
|
||||
// changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the
|
||||
// radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay.
|
||||
let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln();
|
||||
let inner = outer / decay.sqrt();
|
||||
let mut anchors = Vec::new();
|
||||
let mut r = radius;
|
||||
for i in 0..=segments {
|
||||
let a = dir * (i as f64) * PI / 2.0;
|
||||
let p = Point::new(center.x + r * a.cos(), center.y + r * a.sin());
|
||||
// Tangent direction for a quarter arc of radius r.
|
||||
let t = Vec2::new(-a.sin(), a.cos()) * dir;
|
||||
let r_prev = r / decay;
|
||||
let r_next = r * decay;
|
||||
let h_in = p - t * (KAPPA * (r + r_prev) / 2.0);
|
||||
let h_out = p + t * (KAPPA * (r + r_next) / 2.0);
|
||||
// The spiral's derivative by the angle turned: the radius changes along the radial
|
||||
// direction while the point turns along the circle's tangent.
|
||||
let d = Vec2::new(a.cos(), a.sin()) * (r * k) + Vec2::new(-a.sin(), a.cos()) * (r * dir);
|
||||
let h_in = p - d * inner;
|
||||
let h_out = p + d * outer;
|
||||
anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth });
|
||||
r *= decay;
|
||||
}
|
||||
@@ -460,6 +466,34 @@ mod tests {
|
||||
assert!(!p.subpaths[0].closed);
|
||||
}
|
||||
|
||||
/// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by
|
||||
/// `decay` each quarter turn, for strong and weak decay and both directions.
|
||||
#[test]
|
||||
fn spiral_segments_follow_the_spiral() {
|
||||
let center = Point::new(40.0, -25.0);
|
||||
for decay in [20.0, 30.0, 50.0, 80.0, 95.0] {
|
||||
for clockwise in [true, false] {
|
||||
let p = spiral(center, 100.0, decay, 8, clockwise);
|
||||
let anchors = &p.subpaths[0].anchors;
|
||||
let k = (decay / 100.0_f64).ln() / (PI / 2.0);
|
||||
let mut worst = 0.0_f64;
|
||||
for pair in anchors.windows(2) {
|
||||
let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p);
|
||||
let r0 = (pair[0].p - center).hypot();
|
||||
let a0 = (pair[0].p - center).atan2();
|
||||
for step in 1..20 {
|
||||
let q = kurbo::ParamCurve::eval(&c, f64::from(step) / 20.0) - center;
|
||||
// The angle turned from the segment's start, toward the outer end.
|
||||
let turned = (q.atan2() - a0 + 3.0 * PI).rem_euclid(TAU) - PI;
|
||||
let want = r0 * (-k * turned.abs()).exp();
|
||||
worst = worst.max((q.hypot() - want).abs() / want);
|
||||
}
|
||||
}
|
||||
assert!(worst < 0.008, "decay {decay} clockwise {clockwise}: radius off by {:.2}%", worst * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids() {
|
||||
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
@@ -69,13 +69,12 @@ impl Exporter<'_> {
|
||||
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink.
|
||||
fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
|
||||
let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?;
|
||||
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
|
||||
let tints: Option<Vec<f32>> = g
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| match s.swatch.as_deref() {
|
||||
Some(n) if n == ink => Some(s.tint),
|
||||
None if paper(&s.color) => Some(0.0),
|
||||
_ if self.paper_stop(s) => Some(0.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -106,7 +105,6 @@ impl Exporter<'_> {
|
||||
/// the gradient is then written in the colors of its stops.
|
||||
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
|
||||
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
|
||||
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
|
||||
let doc = self.doc;
|
||||
fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> {
|
||||
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot))
|
||||
@@ -128,7 +126,7 @@ impl Exporter<'_> {
|
||||
let i = spots.iter().position(|(m, _)| m == n)?;
|
||||
v[4 + i] = s.tint;
|
||||
}
|
||||
None if s.swatch.is_none() && paper(&s.color) => {}
|
||||
None if self.paper_stop(s) => {}
|
||||
None => match self.page_color(&s.color) {
|
||||
Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]),
|
||||
Color::Gray { k } => v[3] = k,
|
||||
@@ -348,25 +346,30 @@ impl Exporter<'_> {
|
||||
Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into())
|
||||
}
|
||||
|
||||
/// The spot swatch whose tints are every stop of `g` that isn't paper white, unlinked; `None`
|
||||
/// when no stop is linked to a spot swatch or a stop has another color.
|
||||
/// Is stop `s` paper white: white, unlinked or linked to a swatch that isn't a spot color (a
|
||||
/// white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading.
|
||||
fn paper_stop(&self, s: &vectorcraft_color::GradientStop) -> bool {
|
||||
!s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && s.color.to_rgba8(1.0)[..3] == [255; 3]
|
||||
}
|
||||
|
||||
/// The spot swatch whose tints are every stop of `g` that isn't paper white
|
||||
/// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has
|
||||
/// another color.
|
||||
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
|
||||
let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
|
||||
let ink = g.stops.iter().find_map(spot)?;
|
||||
let paper = |s: &vectorcraft_color::GradientStop| s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3];
|
||||
g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink)
|
||||
g.stops.iter().all(|s| spot(s) == Some(ink) || self.paper_stop(s)).then_some(ink)
|
||||
}
|
||||
|
||||
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
|
||||
/// linked to a spot swatch, and is every other stop paper white, unlinked, or written in CMYK
|
||||
/// or gray?
|
||||
/// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or
|
||||
/// written in CMYK or gray?
|
||||
fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool {
|
||||
let doc = self.doc;
|
||||
let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
|
||||
let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot));
|
||||
let inks = |s: &vectorcraft_color::GradientStop| {
|
||||
(s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3])
|
||||
|| matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
|
||||
self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
|
||||
};
|
||||
g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s))
|
||||
}
|
||||
|
||||
@@ -587,6 +587,15 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
|
||||
pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent, scheme: Scheme) {
|
||||
let t = Tokens::for_scheme(scheme, b, accent);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
||||
// egui keeps a style for each system theme and by default follows the system's setting. The
|
||||
// chosen brightness sets egui's theme, so these colors stay in force when the system's setting
|
||||
// differs or changes. A change of theme starts from the current style, which keeps settings
|
||||
// such as the tool tip delay.
|
||||
let theme = if t.dark { egui::Theme::Dark } else { egui::Theme::Light };
|
||||
if ctx.theme() != theme {
|
||||
ctx.set_style_of(theme, ctx.global_style());
|
||||
}
|
||||
ctx.set_theme(theme);
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.panel;
|
||||
v.window_fill = t.panel;
|
||||
@@ -669,4 +678,86 @@ mod tests {
|
||||
assert_eq!(Scheme::parse(Scheme::Classic.id()), Some(Scheme::Classic));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3818: the brightness chosen in Preferences stays in force when the system's light or dark
|
||||
/// setting differs from it or changes while the app runs. Tool tips keep the theme's panel fill
|
||||
/// and text color instead of egui's own white box and gray text.
|
||||
#[test]
|
||||
fn the_theme_holds_whatever_the_system_theme() {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0));
|
||||
for b in Brightness::ALL {
|
||||
for scheme in Scheme::ALL {
|
||||
for (first, then) in [(egui::Theme::Dark, egui::Theme::Light), (egui::Theme::Light, egui::Theme::Dark)] {
|
||||
let t = Tokens::for_scheme(scheme, b, Accent::default());
|
||||
let ctx = egui::Context::default();
|
||||
let mut time = 0.0;
|
||||
let mut tip = None;
|
||||
for i in 0..5 {
|
||||
time += 0.5;
|
||||
let system_theme = Some(if i == 0 { first } else { then });
|
||||
let events = if i == 1 { vec![egui::Event::PointerMoved(egui::pos2(30.0, 15.0))] } else { vec![] };
|
||||
let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, system_theme, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
if i == 0 {
|
||||
apply_with(ui.ctx(), b, Accent::default(), scheme);
|
||||
ui.ctx().global_style_mut(|s| s.interaction.tooltip_delay = 0.0);
|
||||
}
|
||||
ui.button("Hover me").on_hover_text("Tip text");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
// The tool tip's text color, and the fill of the box drawn behind it.
|
||||
let mut shapes: Vec<&egui::Shape> = Vec::new();
|
||||
let mut open: Vec<&egui::Shape> = out.shapes.iter().rev().map(|c| &c.shape).collect();
|
||||
while let Some(shape) = open.pop() {
|
||||
match shape {
|
||||
egui::Shape::Vec(inner) => open.extend(inner.iter().rev()),
|
||||
shape => shapes.push(shape),
|
||||
}
|
||||
}
|
||||
tip = shapes.iter().enumerate().find_map(|(n, shape)| match shape {
|
||||
egui::Shape::Text(s) if s.galley.text() == "Tip text" => {
|
||||
let fill = shapes[..n].iter().rev().find_map(|behind| match behind {
|
||||
egui::Shape::Rect(r) if r.rect.contains(s.pos) && r.fill.a() > 0 => Some(r.fill),
|
||||
_ => None,
|
||||
});
|
||||
Some((s.fallback_color, fill))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let v = ctx.global_style().visuals.clone();
|
||||
let what = format!("{scheme:?} {b:?}, system {first:?} then {then:?}");
|
||||
assert_eq!((v.window_fill, v.override_text_color), (t.panel, Some(t.text)), "{what}");
|
||||
assert_eq!(tip, Some((t.text, Some(t.panel))), "{what}: the tool tip's text and box");
|
||||
assert!(contrast(t.text, t.panel) >= 4.5, "{what}: the tool tip's text against its box, {}", contrast(t.text, t.panel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The contrast ratio of two opaque colors, from 1 (the same) to 21 (black on white), as WCAG 2
|
||||
/// defines it.
|
||||
fn contrast(a: egui::Color32, b: egui::Color32) -> f32 {
|
||||
let luminance = |c: egui::Color32| {
|
||||
let channel = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
|
||||
};
|
||||
let (a, b) = (luminance(a), luminance(b));
|
||||
(a.max(b) + 0.05) / (a.min(b) + 0.05)
|
||||
}
|
||||
|
||||
/// Changing brightness keeps the interface settings made after the theme was applied, such as
|
||||
/// General › Show Tool Tips' delay.
|
||||
#[test]
|
||||
fn changing_brightness_keeps_other_interface_settings() {
|
||||
let ctx = egui::Context::default();
|
||||
apply(&ctx, Brightness::Dark);
|
||||
ctx.global_style_mut(|s| s.interaction.tooltip_delay = f32::INFINITY);
|
||||
apply(&ctx, Brightness::Light);
|
||||
assert_eq!(ctx.global_style().interaction.tooltip_delay, f32::INFINITY);
|
||||
assert_eq!(ctx.global_style().visuals.window_fill, Tokens::for_brightness(Brightness::Light).panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ private editing data. VectorCraft:
|
||||
`transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type
|
||||
on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and
|
||||
scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline
|
||||
graphics and gradient or pattern fills make that type outlines, and Text: Outlines (`outlineText: true`) writes
|
||||
graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation,
|
||||
which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text
|
||||
in the file), and Text: Outlines (`outlineText: true`) writes
|
||||
all type as outlines. The format can hide only layers: hidden objects go to a hidden layer of their own above
|
||||
their layer, are omitted, or are made visible (Hidden Objects, `hiddenObjects: "layer"|"omit"|"visible"`).
|
||||
Sublayers become groups; appearances, effects and brushes are expanded; object names are left out; layer
|
||||
|
||||
+6
-2
@@ -843,7 +843,10 @@ PostScript form, which keeps layers, groups, compound paths and clipping groups
|
||||
of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with
|
||||
`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode,
|
||||
`hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and
|
||||
`outlineText: true` to write type as outlines instead of text objects. An image that can't be decoded is left out of
|
||||
`outlineText: true` to write type as outlines instead of text objects. Type with rotated characters (Character
|
||||
Rotation) is always written as outlines, which keep the rotation; without `outlineText: true` this gives the warning "type with rotated characters is
|
||||
written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate
|
||||
characters)". An image that can't be decoded is left out of
|
||||
the file and does not show in art that is flattened or whose raster effects are expanded; `warnings` carries "an image
|
||||
couldn't be decoded and is missing from the picture" once for all such images. From the CLI: `vectorcraft-cli convert in.svg
|
||||
out.ai --ai-version 7 [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] [--outline-text]
|
||||
@@ -1586,7 +1589,8 @@ already walks Image Trace. Short agent entry points:
|
||||
a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties
|
||||
panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the
|
||||
new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own
|
||||
art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them.
|
||||
art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and
|
||||
so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"symbol.instance.setAppearance","params":{"id":42,"path":[1],"fill":"#d0021b","opacity":80}}}
|
||||
|
||||
@@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. |
|
||||
| 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. |
|
||||
| 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. |
|
||||
| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. |
|
||||
@@ -1064,6 +1065,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`).
|
||||
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`).
|
||||
SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them.
|
||||
Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`).
|
||||
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
|
||||
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`).
|
||||
PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9).
|
||||
@@ -1082,7 +1084,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`).
|
||||
`document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`).
|
||||
- EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`).
|
||||
EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow, Black, All or None, are also written in process colors with a warning (`crates/eps/src/tests.rs`).
|
||||
EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`).
|
||||
- Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`).
|
||||
- PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`).
|
||||
- JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. The last valid `resc` or `resd` resolution value wins on each axis; an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`).
|
||||
@@ -1280,6 +1282,8 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi
|
||||
|
||||
PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`.
|
||||
|
||||
In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`.
|
||||
|
||||
## Artwork Cleanup add-on (2026-10-30)
|
||||
|
||||
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its
|
||||
@@ -1454,7 +1458,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov
|
||||
|
||||
## Dynamic symbols (2026-11-01)
|
||||
|
||||
Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8.
|
||||
Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8.
|
||||
|
||||
## Document Tools add-on: Export All Open Documents (2026-11-01)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user