Merge main into fix/ai-os23-2831

This commit is contained in:
dotCooCoo
2026-11-08 19:44:24 -08:00
215 changed files with 13490 additions and 901 deletions
Generated
+190
View File
@@ -2844,6 +2844,182 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jxl-bitstream"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-coding"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c"
dependencies = [
"jxl-bitstream",
"tracing",
]
[[package]]
name = "jxl-color"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-image",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-frame"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-grid"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-image"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565"
dependencies = [
"jxl-bitstream",
"jxl-grid",
"jxl-oxide-common",
"tracing",
]
[[package]]
name = "jxl-jbr"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46"
dependencies = [
"brotli-decompressor",
"jxl-bitstream",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-modular"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-oxide"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3"
dependencies = [
"brotli-decompressor",
"jxl-bitstream",
"jxl-color",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-jbr",
"jxl-oxide-common",
"jxl-render",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "jxl-oxide-common"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b"
dependencies = [
"jxl-bitstream",
]
[[package]]
name = "jxl-render"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19"
dependencies = [
"bytemuck",
"jxl-bitstream",
"jxl-coding",
"jxl-color",
"jxl-frame",
"jxl-grid",
"jxl-image",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"jxl-vardct",
"tracing",
]
[[package]]
name = "jxl-threadpool"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3"
dependencies = [
"tracing",
]
[[package]]
name = "jxl-vardct"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511"
dependencies = [
"jxl-bitstream",
"jxl-coding",
"jxl-grid",
"jxl-modular",
"jxl-oxide-common",
"jxl-threadpool",
"tracing",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
@@ -6292,6 +6468,9 @@ dependencies = [
"hayro-jpeg2000",
"heic-rs",
"image",
"jxl-color",
"jxl-grid",
"jxl-oxide",
"kurbo 0.13.1",
"log",
"proptest",
@@ -6323,6 +6502,8 @@ dependencies = [
"vectorcraft-tools",
"vectorcraft-trace",
"wat",
"zune-core 0.5.3",
"zune-jpegxl",
]
[[package]]
@@ -8098,6 +8279,15 @@ dependencies = [
"zune-core 0.5.3",
]
[[package]]
name = "zune-jpegxl"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
dependencies = [
"zune-core 0.5.3",
]
[[package]]
name = "zvariant"
version = "5.15.1"
+10
View File
@@ -108,6 +108,16 @@ gif = { version = "0.14", default-features = false, features = ["std", "raii_no_
# decoders, read directly (the image crate converts CMYK to RGB).
tiff = "0.11"
zune-jpeg = "0.5"
# JPEG XL decoder (pure Rust, MIT OR Apache-2.0): File › Open and Place read `.jxl` images. It is
# built without its default `rayon` feature and decodes on one thread.
jxl-oxide = { version = "0.12.6", default-features = false }
# jxl-oxide's color conversions, run on colors after their alpha is divided out.
jxl-color = "0.11.0"
# jxl-oxide's buffer allocation error, told apart from other decoding errors.
jxl-grid = "0.6.2"
# Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files.
zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] }
zune-core = "0.5"
# ui
egui = "0.37"
+1 -1
View File
@@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
+3 -2
View File
@@ -115,8 +115,9 @@ USAGE:
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
even where that draws differently from its PDF part or page (which can be older), as long as
it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data
that aren't UTF-8 (layers, objects, colors, swatches) in windows-1252 (the default),
macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is
that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles,
swatches, brushes, symbols, variables and data sets, and data sets' text values) in
windows-1252 (the default), macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is
given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the
attachment is written the result's warnings say so (it is left out, with its own warning, for
--artboard or a --range without every artboard).
+47 -5
View File
@@ -395,6 +395,15 @@ impl Source {
}
}
/// The centers of a `cols` × `rows` grid of cells over `b`, row by row.
fn cell_centers(b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = Point> {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)
})
}
/// A freeform gradient ready to sample ([`Freeform::field`]).
#[derive(Clone, Debug)]
pub struct Field {
@@ -429,11 +438,7 @@ impl Field {
/// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and
/// opacity (as [`Self::sample`]).
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch))
})
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`.
@@ -503,6 +508,27 @@ impl ModelField {
};
(c, a)
}
/// [`Self::sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by row.
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = (Color, f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`: the display color of [`Self::sample`], or the display RGB
/// the points mix in when their models differ.
pub fn display_sample(&self, p: Point) -> ([f32; 3], f32) {
if self.model.is_none() {
return self.first.sample(p);
}
let (c, a) = self.sample(p);
(c.to_rgb(), a)
}
/// [`Self::display_sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by
/// row.
pub fn display_grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.display_sample(p))
}
}
#[cfg(test)]
@@ -530,6 +556,22 @@ mod tests {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
#[test]
fn the_model_field_grid_and_display_samples_match_its_sample() {
let mut f = two(0.0);
(f.points[0].color, f.points[1].color) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let field = f.model_field(50.0);
let b = Rect::new(0.0, 0.0, 100.0, 20.0);
let (cells, shown): (Vec<_>, Vec<_>) = (field.grid(b, 5, 1).collect(), field.display_grid(b, 5, 1).collect());
let (mid, a) = field.sample(Point::new(50.0, 10.0));
assert_eq!(cells[2], (mid, a));
assert_eq!(shown[2], (mid.to_rgb(), a));
// Points in different models give their display mix.
(f.points[0].color, f.points[1].color) = (red(), Color::gray(0.5));
let (rgb, _) = f.model_field(50.0).display_sample(Point::new(50.0, 0.0));
assert!(close(rgb, [0.75, 0.25, 0.25]), "{rgb:?}");
}
#[test]
fn the_sample_at_a_point_is_its_colour() {
let f = two(0.0);
+6 -5
View File
@@ -84,8 +84,9 @@ impl Default for HitOptions {
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
/// above this crate, so the engine registers one with [`set_image_paint`].
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
/// above this crate, so the engine registers one with [`set_image_paint`]. It looks up swatches and
/// patterns in the document it is given.
pub type ImagePaint = fn(Option<&Document>, &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
@@ -98,8 +99,8 @@ pub fn set_image_paint(f: ImagePaint) {
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
/// nothing is registered.
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
pub(crate) fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(doc, n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
@@ -871,7 +872,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
if opt.outline {
return None;
}
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
image_paint(Some(symbols.document()), n).iter().rev().find_map(|(bp, rule, item)| match item {
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
+3 -3
View File
@@ -117,9 +117,9 @@ pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
WrapShape,
JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign,
RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
};
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
pub use trace::TraceView;
+6 -4
View File
@@ -239,10 +239,12 @@ impl Node {
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
// the farthest it can reach from its shape.
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
NodeKind::Image(_) => {
crate::hit::image_paint(symbols.map(SymbolBoxes::document), self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
})
}
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
+2 -2
View File
@@ -23,8 +23,8 @@ pub use composite::{
CompositeParts,
};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, SingleWordJustify,
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
pub use tabs_lists::{
+28
View File
@@ -234,6 +234,9 @@ pub struct ParaStyle {
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
/// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start
/// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku.
pub kinsoku_type: KinsokuShoriType,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
@@ -322,6 +325,8 @@ struct ParaStyleFile {
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku_type: KinsokuShoriType,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
@@ -353,6 +358,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
kinsoku_type,
roman_hanging,
kashida,
..
@@ -377,6 +383,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
kinsoku_type,
roman_hanging,
kashida,
}
@@ -405,6 +412,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
kinsoku_type,
roman_hanging,
kashida,
..
@@ -428,6 +436,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
kinsoku_type,
roman_hanging,
kashida,
}
@@ -492,6 +501,25 @@ pub enum Kinsoku {
Soft,
}
/// How a line keeps the characters [`Kinsoku`] rules out of its start and end (Kinsoku Shori
/// Type). Pushing in sets the characters that would start the next line (or that an opening
/// bracket ending this line would pull with it) on this line, with the line's gaps next to CJK
/// characters closed to make room, each by the same share of the em of the character before it
/// and by at most a tenth of that em; pushing out moves the character before them to the next
/// line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum KinsokuShoriType {
/// Push in when the line can close up enough, else push out.
PushInFirst,
/// Push out, unless that would leave the line more than an em short (of the character before
/// the break): then push in when the line can close up enough.
PushOutFirst,
/// Always push out; never push in.
#[default]
PushOutOnly,
}
/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。)
/// stands outside the line's measure, in the space its punctuation spacing gives it (half width
/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown
+26 -23
View File
@@ -95,8 +95,9 @@ pub struct DataSet {
impl DataSet {
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
/// keeps it, and applying it again would skip it too.
/// says. A value the art can't report (its object is gone, or is locked or in a locked layer
/// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and
/// applying it again would skip it too.
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
}
@@ -193,33 +194,35 @@ impl Document {
self.variables.bindings = bindings;
}
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
/// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a
/// visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
self.variable_target_with(&self.locked_artboards(), id)
/// Whether a variable of `kind` may be bound to the object `id`: it is in the document,
/// nothing above it is hidden, it is not on a locked artboard ([`Document::on_locked_artboard`]),
/// and, unless `kind` is visibility, neither it nor anything above it is locked. Its own
/// visibility doesn't count, as that is what a visibility variable drives.
pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool {
self.variable_target_with(&self.locked_artboards(), id, kind)
}
/// [`Document::variable_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool {
let locking = kind != VariableKind::Visibility;
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked) && (n.visible || *i == id))))
&& !locks.holds_id(id)
}
/// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither
/// it nor anything above it is locked, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden
/// layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
self.dataset_target_with(&self.locked_artboards(), id)
/// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it
/// is in the document, it is not on a locked artboard ([`Document::on_locked_artboard`]) and,
/// unless `kind` is visibility, neither it nor anything above it is locked. Hidden objects
/// count, and so do objects in a hidden layer or group.
pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool {
self.dataset_target_with(&self.locked_artboards(), id, kind)
}
/// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id)
pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool {
let locking = kind != VariableKind::Visibility;
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked)))) && !locks.holds_id(id)
}
/// The type objects of the story that the type object `id` belongs to, in thread order: the
@@ -253,21 +256,21 @@ impl Document {
/// [`Document::story_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f))
self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f, VariableKind::Text))
}
/// What the object `id` holds for the variable of `kind`: its characters (the whole story
/// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the
/// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked,
/// in a locked layer or group, or on a locked artboard, or for text when a frame of its story
/// is (the document's value is whatever it was left at).
/// object is gone, is not of that kind, has no link path yet (an embedded image), or, unless
/// `kind` is visibility, is locked or in a locked layer or group, or is on a locked artboard,
/// or for text when a frame of its story is (the document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
self.variable_value_with(&self.locked_artboards(), id, kind)
}
/// [`Document::variable_value`] with the locked artboards `locks` of this document.
fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.dataset_target_with(locks, id) {
if !self.dataset_target_with(locks, id, kind) {
return None;
}
let node = self.node(id)?;
+15 -14
View File
@@ -348,16 +348,16 @@ fn moved_type_group(n: &Node) -> Option<Node> {
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
/// them. `None` when no type stays type.
fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
if !matches!(c.kind, NodeKind::Text(_)) {
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
outline_nested_text(Arc::make_mut(c), clipping);
outline_nested_text(Some(d), Arc::make_mut(c), clipping);
} else if c.children().is_some() {
keep(Arc::make_mut(c), moves, clipping, kept);
keep(d, Arc::make_mut(c), moves, clipping, kept);
}
continue;
}
@@ -372,7 +372,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None => as_art(None, c, None),
None => as_art(Some(d), c, None),
};
if let Some(o) = art {
*c = Arc::new(o);
@@ -391,11 +391,11 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
}
let mut m = crate::reshape::evaluated(Some(d), n);
let mut kept = false;
keep(&mut m, &moves, false, &mut kept);
keep(d, &mut m, &moves, false, &mut kept);
if !kept {
return None;
}
reshape_leaves(&mut m, &fx);
reshape_leaves(&mut m, &fx, Some(d));
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
@@ -547,9 +547,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path
/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`]
/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor
/// a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
/// makes of it, and each copy is baked this way; the swatches and patterns that live-effect plug-ins
/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to
/// outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
}
@@ -560,9 +561,9 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
// patterns: the art Expand Appearance makes of it (each copy an object with its own strokes,
// patterns and gradients), each piece baked in turn.
if crate::has_transform_art(n)
&& let Some(mut m) = reshape(None, n, None)
&& let Some(mut m) = reshape(doc, n, None)
{
bake_appearance_pieces(&mut m);
bake_appearance_pieces(doc, &mut m);
return Some(m);
}
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
@@ -617,15 +618,15 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
}
/// [`bake_appearance`] every path and compound path in `n` that it changes, in place.
fn bake_appearance_pieces(n: &mut Node) {
fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
if let Some(m) = bake_appearance(n) {
if let Some(m) = bake_appearance(doc, n) {
*n = m;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
bake_appearance_pieces(Arc::make_mut(c));
bake_appearance_pieces(doc, Arc::make_mut(c));
}
}
+6 -6
View File
@@ -536,7 +536,7 @@ fn a_baked_stroke_keeps_its_raster_effects() {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
s.effects.push(fx("stylize.dropShadow", json!({})));
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
@@ -551,7 +551,7 @@ fn a_baked_stroke_leaves_dither_behind() {
g.gradient.dither = true;
s.paint = Paint::Gradient(Box::new(g));
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
@@ -567,7 +567,7 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
s.opacity = 0.5;
}
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
@@ -600,7 +600,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
// Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline
// is 204 pt wide.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
@@ -611,7 +611,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
fn a_baked_transform_copy_keeps_its_moved_pattern() {
let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let xs: Vec<f64> = baked_paths(&m)
.iter()
.map(|p| match p.appearance.fill_paint() {
@@ -626,7 +626,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() {
fn a_baked_transform_without_its_options_outlines_the_copies_together() {
// Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1}));
let m = bake_appearance(&n).unwrap();
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 1, "{w:?}");
assert!((w[0] - 202.0).abs() < 0.5, "{w:?}");
+6 -2
View File
@@ -3,6 +3,7 @@ use super::{
Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize,
util, warp,
};
use vectorcraft_doc::Document;
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -22,6 +23,9 @@ pub struct GeomContext<'a> {
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
/// the object's effects.
pub item: Option<usize>,
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
/// pieces are looked up in. Without one, a result that names any is refused.
pub doc: Option<&'a Document>,
}
impl<'a> GeomContext<'a> {
@@ -32,7 +36,7 @@ impl<'a> GeomContext<'a> {
_ => FillRule::NonZero,
};
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -374,7 +378,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont
use util::{join, num};
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p, max),
+60 -46
View File
@@ -67,25 +67,28 @@ pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
/// the art it draws, and type and symbol instances as their art through `hook`. Past
/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
/// instances add nothing.
pub fn member_leaves(n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc` ([`shape_art`]).
pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
let mut out = vec![];
leaves(n, hook, &SymbolNodes::default(), &mut out);
leaves(doc, n, hook, &SymbolNodes::default(), &mut out);
out
}
/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
fn leaves(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
if !n.visible {
return;
}
// A blend, an envelope or an object with a warp takes part as the art it draws.
if let Some(art) = shape_art_in(n, hook, nodes) {
if let Some(art) = shape_art_in(doc, n, hook, nodes) {
let first = out.len();
leaves(&art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art is drawn inside the
// object's opacity and blend mode.
if crate::needs_outline(n) {
leaves(doc, &art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art, and every piece of a path
// whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and
// blend mode.
let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. });
if crate::needs_outline(n) || pieces {
for l in out.iter_mut().skip(first) {
within(l, n);
}
@@ -95,11 +98,11 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
leaves(c, hook, nodes, out);
leaves(doc, c, hook, nodes, out);
}
}
},
@@ -114,7 +117,7 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
crate::reshape::outline_nested_text(doc, &mut art, false);
transparency_into_members(&mut art);
art
}
@@ -122,7 +125,7 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Ve
_ => art,
};
let first = out.len();
leaves(&art, None, nodes, out);
leaves(doc, &art, None, nodes, out);
for l in out.iter_mut().skip(first) {
within(l, n);
}
@@ -176,29 +179,37 @@ fn transparency_into_members(n: &mut Node) {
/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
/// for symbol instances without geometry effects, and for one without art from `hook`.
pub fn shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(n, hook, &SymbolNodes::default())
/// for symbol instances without geometry effects, and for one without art from `hook`. A
/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's
/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces
/// that name one are refused and the path stays as it is).
pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`shape_art`], with the symbol art given so far counted in `nodes`.
fn shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(n, hook, nodes)?;
fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(doc, n, hook, nodes)?;
transparency_into_members(&mut art);
Some(art)
}
/// [`shape_art_in`] before the groups inside the art pass on their transparency.
fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let fx = crate::reshape::geometry_effects(n);
let outline = hook.unwrap_or(&crate::outline_text);
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)),
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) {
Some(area) => Some(reshaped(&area, n)),
// A path with a live-effect plug-in that paints its pieces: the group of its pieces, each
// with its own paints, as the renderer draws it.
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => {
let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten();
Some(painted.unwrap_or_else(|| reshaped(n, n, doc)))
}
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) {
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
// The group's own fills and strokes follow the effects as its members do.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
@@ -213,8 +224,8 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
let h = hook?;
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx);
crate::reshape::outline_nested_text(doc, &mut art, false);
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
// The art's own fills and strokes follow the effects as its members do.
if carry.moves(&art.appearance) {
carry.apply(&mut art.appearance);
@@ -226,19 +237,21 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, n, None, outline, nodes)
crate::reshape::as_art_with(None, doc, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects.
fn reshaped(src: &Node, n: &Node) -> Node {
/// geometry effects of `n`, without those effects. The swatches and patterns a live-effect plug-in
/// that paints its pieces names are looked up in `doc`.
fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
let (g, carry) =
crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext { doc, ..crate::GeomContext::of(n) });
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
@@ -282,12 +295,12 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp {
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(n, hook, &SymbolNodes::default())
pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
if !n.is_compound_shape() {
return None;
}
@@ -296,7 +309,7 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod
let mut members: Vec<(po::BoolOp, usize)> = vec![];
for c in n.children()? {
let before = all.len();
leaves(c, hook, nodes, &mut all);
leaves(doc, c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
@@ -323,13 +336,13 @@ fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNod
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
/// opacity and blend mode: how it takes part in a Pathfinder operation.
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
compound_shape_art_in(n, hook, &SymbolNodes::default())
pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
compound_shape_art_in(doc, n, hook, &SymbolNodes::default())
}
/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
fn compound_shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(n, hook, nodes)?;
fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(doc, n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
@@ -435,15 +448,16 @@ fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
/// each effect) it returns the children as they are.
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
/// each effect) it returns the children as they are. The swatches and patterns that live-effect
/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]).
pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
}
let source: &[Arc<Node>] = group.children()?;
let mut stack: Vec<Arc<Node>> = if group.is_compound_shape() {
// The group's own appearance paints the area ([`evaluate_container`]), so the art has none.
match compound_shape_area(group, hook) {
match compound_shape_area(doc, group, hook) {
Some(area) if area.is_empty() => vec![],
Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))],
None => return Some(source.to_vec()),
@@ -455,7 +469,7 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if e.id == crate::trap::TRAP {
let (mut lv, nodes) = (vec![], SymbolNodes::default());
for c in &stack {
leaves(c, hook, &nodes, &mut lv);
leaves(doc, c, hook, &nodes, &mut lv);
}
if nodes.spent() || !within_budget(&lv) {
return Some(source.to_vec());
@@ -479,7 +493,7 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
.iter()
.map(|c| {
let mut lv = vec![];
leaves(c, hook, &nodes, &mut lv);
leaves(doc, c, hook, &nodes, &mut lv);
lv
})
.collect();
@@ -633,7 +647,7 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Group { .. } if n.is_compound_shape() => {
let hook = |c: &Node| crate::outline_object(doc, c);
match compound_shape_area(n, Some(&hook)) {
match compound_shape_area(doc, n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
@@ -715,7 +729,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
let mut m = n.clone();
if pathfinder {
let hook = |c: &Node| crate::outline_object(doc, c);
let result = pathfinder_children(n, Some(&hook)).unwrap_or_default();
let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
@@ -724,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(doc, &m);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
// The group's own fills and strokes paint the members as a whole: their gradients follow
// the effects as the members' do.
if paint && carry.moves(&m.appearance) {
+25 -25
View File
@@ -27,7 +27,7 @@ fn area(n: &Node) -> f64 {
}
fn overlap_cmyk(g: &Node) -> [f32; 4] {
let faces = pathfinder_children(g, None).unwrap();
let faces = pathfinder_children(None, g, None).unwrap();
assert_eq!(faces.len(), 3);
let overlap = faces
.iter()
@@ -61,7 +61,7 @@ fn trap_effect_adds_an_overprinting_strip_above_the_members() {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]);
let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100});
g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 3, "both squares and one trap");
assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2)));
let b = out[2].geometric_bounds().unwrap();
@@ -89,17 +89,17 @@ fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
#[test]
fn shape_modes_follow_the_pathfinder_panel() {
let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap();
let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap();
assert_eq!(add.len(), 1);
assert!((area(&add[0]) - 150.0).abs() < 1e-3);
// Add keeps the front object's paint, Subtract the back one's.
assert_eq!(add[0].id, NodeId(2));
let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap();
let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap();
assert_eq!(sub[0].id, NodeId(1));
assert!((area(&sub[0]) - 50.0).abs() < 1e-3);
let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap();
let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap();
assert!((area(&int[0]) - 50.0).abs() < 1e-3);
let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap();
let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap();
assert_eq!(div.len(), 3);
}
@@ -112,7 +112,7 @@ fn divide_and_outline_draw_nothing_for_unpainted_members() {
for effect in ["pathfinder.divide", "pathfinder.outline"] {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}");
for n in out.iter().filter(|n| n.id == NodeId(2)) {
assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}");
@@ -138,7 +138,7 @@ fn square_and(front: Option<Node>, effect: &str) -> Node {
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
fn results(g: &Node) -> Vec<Arc<Node>> {
pathfinder_children(g, crate::text_outliner()).unwrap()
pathfinder_children(None, g, crate::text_outliner()).unwrap()
}
#[test]
@@ -167,7 +167,7 @@ fn type_changes_the_result_of_every_pathfinder_effect() {
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
// Outline art nested one group deeper takes part as well.
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
}
@@ -243,7 +243,7 @@ fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it()
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
g.appearance.effects[0].visible = false;
assert!(pathfinder_children(&g, None).is_none());
assert!(pathfinder_children(None, &g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
@@ -320,12 +320,12 @@ fn shape_art_moves_gradients_with_a_transform_effect() {
};
let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0));
path.appearance.effects.push(moved());
let got = starts(&shape_art(&path, None).unwrap());
let got = starts(&shape_art(None, &path, None).unwrap());
assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}");
let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]);
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
g.appearance.effects.push(moved());
let got = starts(&shape_art(&g, None).unwrap());
let got = starts(&shape_art(None, &g, None).unwrap());
assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}");
}
@@ -343,7 +343,7 @@ fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let out = shape_art(&instance, Some(&hook)).unwrap();
let out = shape_art(None, &instance, Some(&hook)).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) };
assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom);
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
@@ -377,11 +377,11 @@ fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(warp());
check(&shape_art(&instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
check(&shape_art(None, &instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
let mut shape = compound_shape(ShapeMode::Add);
shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
shape.appearance.effects.push(warp());
check(&shape_art(&shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
check(&shape_art(None, &shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
}
}
@@ -714,7 +714,7 @@ fn pathfinder_past_its_budget_draws_the_source_art() {
// n² crossings past the cap: no evaluation (#3591), the source art as it is.
for n in [130, 800] {
let g = crossing_bars(n, "pathfinder.divide");
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 2 * n, "{n} x {n} bars");
let ids: Vec<NodeId> = out.iter().map(|c| c.id).collect();
let src: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
@@ -729,7 +729,7 @@ fn pathfinder_within_its_budget_still_divides() {
// n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly
// `PATHFINDER_MAX_OVERLAPS` pairs, must still divide.
for n in [8, 128] {
let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap();
let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap();
assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars");
}
}
@@ -747,7 +747,7 @@ fn pathfinder_on_many_stacked_bars_still_runs() {
.collect();
let mut g = Node::group(NodeId(10), children);
g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1);
}
@@ -768,9 +768,9 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] {
let g = compound_shape(mode);
assert!(has_pathfinder(&g), "{mode:?}");
let area_path = compound_shape_area(&g, None).unwrap();
let area_path = compound_shape_area(None, &g, None).unwrap();
assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}");
let out = pathfinder_children(&g, None).unwrap();
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1, "{mode:?}");
assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it");
let art = evaluate_container(None, &g).unwrap();
@@ -787,7 +787,7 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
back.extras_mut().shape_mode = ShapeMode::Subtract;
ch[0] = Arc::new(back);
}
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
// Hidden members take no part; the mark on a clip group is ignored.
let mut g = compound_shape(ShapeMode::Subtract);
if let Some(ch) = g.children_mut() {
@@ -795,10 +795,10 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() {
front.visible = false;
ch[1] = Arc::new(front);
}
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
let mut clip = compound_shape(ShapeMode::Add);
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none());
assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none());
}
/// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area,
@@ -811,10 +811,10 @@ fn nested_compound_shapes_take_part_as_their_area() {
let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]);
outer.extras_mut().compound_shape = true;
// (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high.
assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]);
effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(&effect, None).unwrap();
let out = pathfinder_children(None, &effect, None).unwrap();
assert_eq!(out.len(), 1);
assert!((area(&out[0]) - 50.0).abs() < 0.01);
assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape");
+4 -3
View File
@@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies,
has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
@@ -107,5 +107,6 @@ pub use geometry::{
#[cfg(test)]
pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within};
pub use params::{
default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params,
any_plugin_paints_output, default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params,
new_effect, plugin_revision, scale_effect, upgrade_params,
};
+2 -1
View File
@@ -43,7 +43,8 @@ fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
vectorcraft_text::register_envelope_ink();
let outline = |n: &Node| outline_object(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
let appearance = |n: &Node| bake_appearance(doc, n);
f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc })
}
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
+13
View File
@@ -148,6 +148,19 @@ pub fn has_plugin_effect(n: &Node) -> bool {
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose
/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's
/// swatches and patterns, which its pieces may name.
pub fn has_painted_plugin_effect(n: &Node) -> bool {
let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output);
n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted))
}
/// Is a live-effect plug-in that paints the pieces it returns installed?
pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
+263 -55
View File
@@ -103,10 +103,27 @@ fn reaches_appearance(e: &Effect) -> bool {
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// that art ([`reshape`]).
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose
/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and
/// expanded through that art ([`reshape`]).
pub fn has_transform_art(n: &Node) -> bool {
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n)
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n)
}
/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id,
/// else `None`.
fn painted_plugin(e: &Effect) -> Option<&str> {
let id = vectorcraft_plugins::effect::plugin_id(&e.id).filter(|_| e.visible)?;
vectorcraft_plugins::effect::paints_output(id).then_some(id)
}
/// Is `n` a path or compound path with a live-effect plug-in that paints what it returns
/// ([`painted_plugin`]), its own or on a fill or stroke that paints? Each piece the plug-in returns
/// is then an object of its own with its own paints ([`paint_leaf`], [`transform_item_leaf`]).
pub fn has_painted_effects(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.effects.iter().any(|e| painted_plugin(e).is_some())
|| n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(|e| painted_plugin(e).is_some())))
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
@@ -163,17 +180,18 @@ fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
/// them. Empty for anything but an image with such fills or strokes.
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that
/// live-effect plug-ins painting their pieces name are looked up in `doc`.
pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
return vec![];
}
let Some(art) = reshape(None, n, None) else { return vec![] };
let Some(art) = reshape(doc, n, None) else { return vec![] };
let mut out = vec![];
art.walk(&mut |c| {
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
let Some(g) = c.stroke_path() else { return };
let ctx = GeomContext::of(c);
let ctx = GeomContext { doc, ..GeomContext::of(c) };
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
let fx = item.effects();
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
@@ -286,14 +304,17 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
outline_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with
/// `apart`, also a path or compound path whose Transform effect makes copies
/// ([`has_transform_copies`]), as the one piece the copies are made from.
/// ([`has_transform_copies`]), as the one piece the copies are made from. `symbols` is the document
/// the symbols of the art come from; `paint_doc` is the one the swatches and patterns of painted
/// pieces are looked up in.
fn outline_art_with(
doc: Option<&Document>,
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
@@ -303,7 +324,7 @@ fn outline_art_with(
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { symbol, xf } => {
let a = match doc {
let a = match symbols {
Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
None => {
let mut a = symbol_art?.clone();
@@ -323,10 +344,10 @@ fn outline_art_with(
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes),
_ => return None,
};
outline_nested_text(&mut art, false);
outline_nested_text(paint_doc, &mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -433,21 +454,22 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook,
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting
/// their pieces name are looked up in `doc`.
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) => as_art(None, c, None),
NodeKind::Text(_) => as_art(doc, c, None),
_ => None,
};
if let Some(o) = art {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c), clipping);
outline_nested_text(doc, Arc::make_mut(c), clipping);
}
}
}
@@ -480,7 +502,7 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
// effect in a group of their own (copies of copies nest), as the reference app expands them
// (#5612).
if has_transform_art(n) || has_transform_copies(n) {
return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
return as_art_opts(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
}
let own_paint = match n.kind {
NodeKind::Text(_) => !n.appearance.items.is_empty(),
@@ -494,17 +516,34 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
as_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
as_art_opts(doc, n, symbol_art, outline, nodes, false)
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). `symbols` is the
/// document the symbols of the art come from; `paint_doc` is the one the swatches and patterns of
/// painted pieces are looked up in.
pub(crate) fn as_art_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Option<Node> {
as_art_opts(symbols, paint_doc, n, symbol_art, outline, nodes, false)
}
/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an
/// object of its own ([`copy_leaf`]) rather than one path of every copy.
fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option<Node> {
fn as_art_opts(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
apart: bool,
) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -532,13 +571,13 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?,
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart);
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
if apart {
hoist_copies(&mut art);
}
transform_items(&mut art);
transform_items(&mut art, paint_doc);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
@@ -596,20 +635,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape.
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
reshape_leaves_within(art, fx, Budget::EFFECTS);
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
/// ([`reshape_leaves_apart`]).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
}
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
/// strokes) follow the effects ([`crate::Carry`]).
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS)
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
}
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false)
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
reshape_leaves_apart(art, fx, budget, false, doc)
}
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
@@ -629,9 +670,11 @@ fn hoist_copies(art: &mut Node) {
}
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how
/// gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry {
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
@@ -658,6 +701,17 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
if let Some(plugin) = painted_plugin(e) {
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc) {
used = used.minus(before).plus(made);
}
});
continue;
}
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
// its bounds before the effect to its bounds after it. Under those and the other effects,
@@ -843,15 +897,118 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
made
}
/// Live-effect plug-in `plugin` (one that paints what it returns) with `params` run on `leaf`, a
/// path or compound path, as one of its object's effects with reference box `bounds`: `leaf`
/// becomes a group with its id and name of one object for each piece returned, in order, each with
/// the piece's geometry and the leaf's appearance with the piece's fills and strokes in place of
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
/// refused, or they would pass `budget`.
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
return None;
}
let children = pieces
.iter()
.map(|p| {
let mut one = leaf.clone();
one.name = None;
one.appearance = vectorcraft_plugins::painted::appearance(&leaf.appearance, p).into();
set_geometry(&mut one, p.path.clone(), p.rule);
Arc::new(one)
})
.collect();
let mut g = Node::group(leaf.id, children);
g.name = leaf.name.clone();
*leaf = g;
Some(made)
}
/// What a painted piece with geometry `path` costs: its anchors and lines, plus a share of the
/// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most
/// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths.
fn piece_cost(path: &PathData) -> Budget {
let max = vectorcraft_plugins::objects::MAX_OBJECTS;
Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max })
}
/// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of
/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference
/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that
/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first
/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's
/// other fills and strokes; one that gives none keeps the leaf's. Returns
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
/// `budget`.
fn paint_piece(
leaf: &mut Node,
item: &AppearanceItem,
plugin: &str,
params: &serde_json::Value,
bounds: Rect,
budget: Budget,
doc: Option<&Document>,
) -> Option<Budget> {
let (path, rule) = leaf_geometry(leaf)?;
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
if !budget.fits(made) {
return None;
}
let mut children = vec![];
for p in pieces.iter() {
let gives = match item {
AppearanceItem::Fill(_) => p.fills.is_some(),
AppearanceItem::Stroke(_) => p.strokes.is_some(),
};
let items = if gives {
let mut items = vectorcraft_plugins::painted::items(item, p);
items.extend_from_slice(leaf.appearance.items.get(1..).unwrap_or_default());
items
} else {
leaf.appearance.items.clone()
};
if items.is_empty() {
continue;
}
let mut one = leaf.clone();
one.appearance.items = items;
set_geometry(&mut one, p.path.clone(), p.rule);
children.push(Arc::new(one));
}
*leaf = Node::group(leaf.id, children);
Some(made)
}
/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose
/// own Transform effect has Transform Patterns or Scale Strokes & Effects on
/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]).
/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is
/// more).
fn transform_items(art: &mut Node) {
/// ([`item_transform_options`]), or whose own live-effect plug-in paints what it returns
/// ([`painted_plugin`]), made into a group of objects of its own ([`transform_item_leaf`]). The
/// new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is more).
/// The swatches and patterns painted pieces name must be in `doc`; without one they may name none.
fn transform_items(art: &mut Node, doc: Option<&Document>) {
let mut used = art_cost(art);
let max = Budget::EFFECTS.at_least(used);
for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used));
for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used, doc));
}
/// Is `e` an effect that makes a fill's or stroke's piece of a path an object of its own: a
/// Transform effect that [`reaches_appearance`], or a live-effect plug-in that paints what it
/// returns ([`painted_plugin`])?
fn makes_piece(e: &Effect) -> bool {
reaches_appearance(e) || painted_plugin(e).is_some()
}
/// Does `item` paint and carry an effect that [`makes_piece`]?
fn item_makes_piece(item: &AppearanceItem) -> bool {
crate::paints(item) && item.effects().iter().any(makes_piece)
}
/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform
@@ -866,8 +1023,17 @@ fn transform_items(art: &mut Node) {
/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's
/// geometry between them, with their own effects. `max` is the budget of the whole art and `used`
/// what it has spent.
fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) {
///
/// A fill or stroke whose first such effect is a live-effect plug-in that paints what it returns
/// ([`painted_plugin`]) becomes, in place of a transformed path, one path for each piece the
/// plug-in returns, painted by copies of the fill or stroke with the piece's paints
/// ([`vectorcraft_plugins::painted::items`]); the plug-in is given the geometry before it and told
/// which fill or stroke it is on. When it fails (or names a swatch or pattern not in `doc`, or any
/// without a document), the
/// fill or stroke paints that geometry as it is. The geometry effects after it then apply to all
/// the pieces as one ([`transform_piece`]).
fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Option<&Document>) {
if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_makes_piece) {
return;
}
let Some((path, rule)) = leaf_geometry(leaf) else { return };
@@ -895,12 +1061,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
let mut pieces: Vec<Arc<Node>> = vec![];
let mut plain: Vec<AppearanceItem> = vec![];
// Which items become a transformed piece of their own; every other run of items is one plain piece.
let transformed: Vec<bool> = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect();
let transformed: Vec<bool> = original.appearance.items.iter().map(item_makes_piece).collect();
// The number of pieces after item `i` in paint order: each costs at least the leaf's geometry.
let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count();
for (i, item) in original.appearance.items.iter().enumerate() {
let fx = item.effects();
let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else {
let Some(at) = fx.iter().position(makes_piece).filter(|_| crate::paints(item)) else {
plain.push(item.clone());
continue;
};
@@ -923,6 +1089,33 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
};
let Some(tb) = g.bounds() else { continue };
let params = merged_params(&fx[at].id, &fx[at].params);
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
*it.effects_mut() = rest;
let mut one = Appearance { items: vec![it.clone()], ..Default::default() };
if carry.moves(&one) {
carry.apply(&mut one);
}
let it = one.items.pop().unwrap_or(it);
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
let painted = vectorcraft_plugins::effect::apply_painted(plugin, &params, &g, tb, rule, paints, doc)
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
// A failed run draws the fill or stroke on the geometry it was given.
let returned = painted.unwrap_or_else(|| vec![(g.clone(), rule, vec![it])]);
let mut group: Vec<Arc<Node>> = vec![];
for (path, rule, items) in returned.into_iter().filter(|(_, _, items)| !items.is_empty()) {
*used = used.plus(piece_cost(&path));
let mut p = piece(items);
set_geometry(&mut p, path, rule);
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
transform_piece(&mut p, ctx.seed, limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
let (_, copies) = crate::distort::transform_map(tb, &params, ctx.seed);
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(tb, &params, ctx.seed);
@@ -936,7 +1129,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
set_geometry(&mut p, g.clone(), rule);
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, ctx.seed, limit, used);
transform_piece(&mut p, ctx.seed, limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -955,9 +1148,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep
/// to `max` together with the rest of the art (`used`).
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
@@ -973,6 +1168,12 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(plugin) = painted_plugin(&e) {
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
*used = used.minus(before).plus(made);
}
return;
}
if reaches_appearance(&e) {
let params = merged_params(&e.id, &e.params);
let (_, copies) = crate::distort::transform_map(bounds, &params, seed);
@@ -987,7 +1188,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
let mut carry = crate::Carry::default();
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
*used = used.minus(before).plus(Budget::of(&out));
@@ -1276,14 +1477,21 @@ fn placed_count(n: &Node) -> usize {
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
outlined_members_with(doc, n, &outline_text, &SymbolNodes::default())
outlined_members_with(doc, doc, n, &outline_text, &SymbolNodes::default())
}
/// [`outlined_members`] with live objects and symbol instances given as art through `outline`
/// ([`evaluated_with`]).
pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
let mut art = evaluated_with(doc, n, outline, nodes);
outline_nested_text(&mut art, false);
/// ([`evaluated_with`]), with the symbols of the art from `symbols` and the swatches and patterns
/// of painted pieces looked up in `paint_doc`.
pub(crate) fn outlined_members_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Node {
let mut art = evaluated_with(symbols, n, outline, nodes);
outline_nested_text(paint_doc, &mut art, false);
art
}
+1 -1
View File
@@ -482,7 +482,7 @@ mod tests {
let expanded = expand_revolve_art(&n).unwrap();
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface");
assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface");
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
assert_eq!(expand_revolve_art(&n), Some(live));
+2 -2
View File
@@ -330,7 +330,7 @@ fn export_and_expand_bake_faces_with_their_own_ids() {
let mut n = square_node();
n.id = d.alloc_id();
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
assert_eq!(crate::bake_appearance(None, &n), solid_art(&n), "{id}");
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
let mut ids = std::collections::HashSet::new();
@@ -356,7 +356,7 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
assert_eq!(crate::bake_appearance(None, &n), Some(art.clone()));
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
+11 -11
View File
@@ -412,7 +412,7 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() {
let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default()));
let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect());
let rough = fx("distort.roughen", json!({"size": 50, "detail": 100}));
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX });
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }, None);
let mut total = 0;
let mut reshaped = 0;
for c in art.children().into_iter().flatten() {
@@ -451,12 +451,12 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] {
let grow = fx("distort.transform", params.clone());
let mut art = Node::group(id, vec![circle()]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS, None);
let end = own_end(&carry).unwrap();
assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}");
// No room beyond what the circle costs: scaled up, it would be drawn with more lines.
let mut art = Node::group(id, vec![circle()]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None);
let b = art.geometric_bounds().unwrap();
assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}");
assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed");
@@ -465,7 +465,7 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
let mut inner = Node::group(NodeId(2), vec![square]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
let mut art = Node::group(id, vec![circle(), Arc::new(inner)]);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None);
assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays");
let inner = &art.children().unwrap()[1];
let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") };
@@ -509,7 +509,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]});
for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] {
let mut art = layer();
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None);
let circle = art.children().unwrap()[1].geometric_bounds().unwrap();
assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id);
let square = art.children().unwrap()[0].geometric_bounds().unwrap();
@@ -518,7 +518,7 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
carry.apply(&mut own);
assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id);
let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]);
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None);
assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id);
}
}
@@ -549,7 +549,7 @@ fn edited_freeform_points_follow_a_transform_effect_and_free_distort() {
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]);
art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS);
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None);
let members = art.children().unwrap();
assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id);
assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id);
@@ -625,7 +625,7 @@ fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() {
let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"}));
let start = std::time::Instant::now();
crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS, None);
let elapsed = start.elapsed();
assert!(elapsed < std::time::Duration::from_secs(2));
@@ -652,7 +652,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]);
let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2}));
let offset = fx("path.offsetPath", json!({"offset": 4.0}));
crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS, None);
let p = art_evenodd.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath");
@@ -660,7 +660,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
let mut art_round = Node::group(id, vec![leaf_round]);
let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0}));
crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS, None);
let p = art_round.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath");
@@ -668,7 +668,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() {
let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()));
let mut art_miter = Node::group(id, vec![leaf_miter]);
let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"}));
crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS);
crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS, None);
let p = art_miter.children().unwrap()[0].path_data().unwrap();
assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath");
}
+6
View File
@@ -40,6 +40,9 @@ vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
jxl-oxide = { workspace = true }
jxl-color = { workspace = true }
jxl-grid = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
# Pure Rust, no unsafe; pinned at the version photocraft-heif uses.
heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true }
@@ -77,5 +80,8 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
# JPEG XL files for the `.jxl` open and place tests.
zune-jpegxl = { workspace = true }
zune-core = { workspace = true }
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
vectorcraft-js = { workspace = true }
+1 -1
View File
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Image into Clipboard",
[],
None,
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PCX, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centered on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
has_doc,
import_image
),
+166 -13
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to CMYK",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk)
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Grayscale",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to RGB",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
has_selection,
|s, p| {
recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| {
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Saturate…",
["Edit", "Edit Colors"],
None,
"{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}",
"{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)",
has_selection,
saturate
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)",
has_selection,
adjust_balance
),
@@ -213,6 +213,48 @@ impl<'a> Recolor<'a> {
Self { links: Some(links), ..Self::new(scope, f) }
}
/// A pass that maps colors as this one does, with nothing recolored yet.
fn again(&self) -> Self {
Self { links: self.links, ..Self::new(self.scope, self.f) }
}
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
/// than by swatch links alone, its patterns' tiles included. The document stays as it is.
pub(crate) fn would_change<T: Copy>(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> {
let tried: Vec<(&Node, Node, T)> =
objects.iter().filter_map(|&(id, flag)| d.node(id).and_then(|n| Some((n, self.subtree(d, n)?, flag)))).collect();
let same = self.link_only_copies(d);
tried.into_iter().filter_map(|(n, m, flag)| (without_links(m, &same) != without_links(n.clone(), &same)).then_some((n.id, flag))).collect()
}
/// The patterns this pass has copied whose copy differs from the original only by swatch
/// links: the copy's name → the original's. A copy whose tile uses other copies counts when
/// those copies do.
fn link_only_copies(&self, d: &Document) -> HashMap<String, String> {
let art = |v: &[Arc<Node>], same: &HashMap<String, String>| v.iter().map(|n| without_links((**n).clone(), same)).collect::<Vec<_>>();
let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect();
let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self
.patterns
.iter()
.filter_map(|(old, new)| {
let new = new.as_ref()?;
Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?))
})
.collect();
let mut same = HashMap::new();
loop {
let found: Vec<(String, String)> = pairs
.iter()
.filter(|(new, _, original, copy)| !same.contains_key(*new) && art(&original.art, &same) == art(&copy.art, &same))
.map(|(new, old, _, _)| ((*new).clone(), (*old).clone()))
.collect();
if found.is_empty() {
return same;
}
same.extend(found);
}
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
/// pattern swatches, and return the number of paints, meshes and images changed.
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
@@ -313,6 +355,29 @@ impl<'a> Recolor<'a> {
}
}
/// `n` with the swatch links of its paints and its children's removed, each color kept as it
/// shows, and each pattern named in `same` (a copy → its original) by its original's name.
fn without_links(mut n: Node, same: &HashMap<String, String>) -> Node {
for p in vectorcraft_doc::swatches::node_paints_mut(&mut n) {
map_links(p, &|_, link, _| link.take().is_some());
match p {
Paint::Gradient(g) => g.swatch = None,
Paint::Pattern { pattern, .. } => {
if let Some(original) = same.get(pattern.as_str()) {
*pattern = original.clone();
}
}
_ => {}
}
}
if let Some(children) = n.children_mut() {
for c in children.iter_mut() {
*c = Arc::new(without_links((**c).clone(), same));
}
}
n
}
/// What a pass over document `d` does to each colour the shared visitor meets.
struct Visit<'p, 'a> {
pass: &'p mut Recolor<'a>,
@@ -342,19 +407,107 @@ pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str,
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
/// errors name the command `cmd`.
/// errors name the command `cmd`. The reply's `notChanged` counts the locked and hidden objects the
/// selection left out ([`left_out`]) that the pass would have changed.
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids", cmd)? {
Some(v) => v,
None => selected_roots(s)?,
let (ids, left) = match ids_param(p, "ids", cmd)? {
Some(v) => (v, vec![]),
None => {
let roots = selected_roots(s)?;
let st = s.doc()?;
let left = left_out(&st.doc, st.isolation, &roots);
(roots, left)
}
};
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let n = s.edit(label, |d, _| {
let (n, not_changed) = s.edit(label, |d, _| {
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
Ok((n, not_changed))
})?;
Ok(json!({ "changed": n }))
let mut out = json!({ "changed": n });
not_changed.report(&mut out);
Ok(out)
}
/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with
/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All
/// takes (in isolation mode, those of the isolated group), these are the objects beside them that
/// Select All skips: hidden or locked objects, the objects of hidden or locked layers and
/// sublayers, and, outside isolation mode, objects on locked artboards (counted as locked). The art
/// of template layers and the objects inside a group are not listed. With any other `roots`, none.
pub(crate) fn left_out(d: &Document, iso: Option<NodeId>, roots: &[NodeId]) -> Vec<(NodeId, bool)> {
fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet<NodeId>, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) {
for c in n.children().into_iter().flatten() {
if c.is_template() {
continue;
}
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
if c.is_layer() {
visit(c, hidden, locked, all, board_locked, out);
} else if !all.contains(&c.id) && (hidden || locked || board_locked(c)) {
out.push((c.id, hidden));
}
}
}
let all: HashSet<NodeId> = super::select::selectable(d, iso).into_iter().collect();
let picked: HashSet<NodeId> = roots.iter().copied().collect();
if picked.is_empty() || picked != all {
return vec![];
}
let mut out = vec![];
match iso.and_then(|i| d.node(i)) {
Some(c) => visit(c, false, false, &all, &|_| false, &mut out),
None => {
let boards = d.locked_artboards();
let board_locked = |n: &Node| !boards.is_empty() && boards.holds(n);
for l in d.layers.iter().filter(|l| !l.is_template()) {
visit(l, !l.visible, l.locked, &all, &board_locked, &mut out);
}
}
}
out
}
/// How many locked and hidden objects a recolor left out ([`left_out`]) although it would have
/// changed their colors. An object both hidden and locked counts as hidden.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct NotChanged {
pub locked: usize,
pub hidden: usize,
}
impl NotChanged {
/// The counts of objects given by whether each is hidden.
pub(crate) fn of(hidden: impl IntoIterator<Item = bool>) -> Self {
hidden.into_iter().fold(Self::default(), |n, h| if h { Self { hidden: n.hidden + 1, ..n } } else { Self { locked: n.locked + 1, ..n } })
}
/// Add `notChanged` to reply `out`, and a `warning`, which the app shows in its status bar,
/// when any object is counted.
pub(crate) fn report(self, out: &mut Value) {
out["notChanged"] = json!({ "locked": self.locked, "hidden": self.hidden });
if let Some(w) = self.warning() {
out["warning"] = json!(w);
}
}
fn warning(self) -> Option<String> {
let part = |n: usize, kind: &str| match n {
0 => None,
1 => Some(format!("1 {kind} object")),
n => Some(format!("{n} {kind} objects")),
};
match (part(self.locked, "locked"), part(self.hidden, "hidden")) {
(Some(locked), Some(hidden)) => Some(format!("Recolor completed; {locked} and {hidden} were left unchanged")),
(Some(objects), None) | (None, Some(objects)) if self.locked + self.hidden == 1 => {
Some(format!("Recolor completed; {objects} was left unchanged"))
}
(Some(objects), None) | (None, Some(objects)) => Some(format!("Recolor completed; {objects} were left unchanged")),
(None, None) => None,
}
}
}
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
+3 -3
View File
@@ -189,7 +189,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
/// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or
/// a warped group, also takes that object's opacity and blend mode.
fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style> {
if let Some(art) = effects::shape_art(n, Some(hook)) {
if let Some(art) = effects::shape_art(Some(d), n, Some(hook)) {
let mut lv = vec![];
pf_leaves(d, &art, &mut lv);
let inside = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !art.is_compound_shape();
@@ -199,7 +199,7 @@ fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
children.iter().rev().find_map(|c| top_style(d, c, hook)).map(|s| s.within(n))
}
NodeKind::SymbolInstance { .. } => effects::member_leaves(n, Some(hook)).last().map(style_of),
NodeKind::SymbolInstance { .. } => effects::member_leaves(Some(d), n, Some(hook)).last().map(style_of),
_ => {
let mut lv = vec![];
pf_leaves(d, n, &mut lv);
@@ -267,7 +267,7 @@ fn expand(s: &mut Session, _: &Value) -> Result<Value> {
for id in &shapes {
let shape = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let hook = |c: &Node| effects::outline_object(Some(d), c);
let area = effects::compound_shape_area(&shape, Some(&hook))
let area = effects::compound_shape_area(Some(d), &shape, Some(&hook))
.ok_or_else(|| EngineError::Other("the compound shape is too complex to expand".into()))?;
if area.is_empty() {
return Err(EngineError::Other(format!("{LABEL} produced an empty result")));
+3 -2
View File
@@ -283,6 +283,7 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T
/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint
/// only where an effect gives them a shape.
fn own_patterns<'a>(
d: &Document,
n: &'a Node,
members: &HashSet<*const Node>,
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
@@ -296,7 +297,7 @@ fn own_patterns<'a>(
// The patterns an image's shaped fills and strokes paint (their effects can transform the
// pattern, so they are matched by name).
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_)).then(|| {
vectorcraft_render::effects::image_paint(n)
vectorcraft_render::effects::image_paint(Some(d), n)
.into_iter()
.filter_map(|(_, _, item)| match item.paint() {
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
@@ -415,7 +416,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
// The patterns this object's fills and strokes use, each once.
let mut used_patterns: Vec<&str> = vec![];
let painted_runs = painted_runs(d, n);
for pattern in own_patterns(n, &members, painted_runs.as_ref()) {
for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) {
patterns.insert(pattern.to_string());
used_patterns.push(pattern);
}
+94 -26
View File
@@ -393,12 +393,21 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
/// gradient fill can be expanded.
/// gradient fill can be expanded. A path that live-effect plug-ins paint is expanded piece by piece
/// ([`expand_pieces`]).
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if vectorcraft_render::effects::has_painted_effects(l) {
return expand_pieces(d, l, usize::MAX, |d, piece, _| expand_piece_fills(d, piece, to));
}
expand_piece_fills(d, l, to)
}
/// [`expand_fills`] of a path with no live-effect plug-in that paints what it returns.
fn expand_piece_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -408,7 +417,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|_| {
let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?;
let (fl, path, bounds, moved) = fill_shape(d, l, item, &path, bounds)?;
let (f, g) = gradient_fill(&moved)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
@@ -467,16 +476,57 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
Some(art)
}
/// Path `l`, which live-effect plug-ins paint, as the art the canvas draws
/// ([`reshape`](vectorcraft_render::effects::reshape)) with ids of its own, each piece of the art
/// replaced by what `expand` makes of it (`expand` gets the piece and how many objects it may become;
/// `None` keeps the piece with its own paints). `None` when the art would hold more than `budget`
/// objects or no piece expands.
fn expand_pieces(d: &mut Document, l: &Node, budget: usize, mut expand: impl FnMut(&mut Document, &Node, usize) -> Option<Node>) -> Option<Node> {
let art = vectorcraft_render::effects::reshape(Some(d), l, None)?;
let mut art = d.reid(&art);
art.locked = l.locked;
let mut left = budget.checked_sub(art.count_with_masks())?;
let mut expanded = false;
expand_piece_leaves(d, &mut art, &mut left, &mut expanded, &mut expand);
expanded.then_some(art)
}
/// Replace each path and compound path under `n` (`n` included) by what `expand` makes of it, in
/// paint order. `left` is how many more objects the art may hold, and `expanded` is set when a path
/// is replaced.
fn expand_piece_leaves(
d: &mut Document,
n: &mut Node,
left: &mut usize,
expanded: &mut bool,
expand: &mut impl FnMut(&mut Document, &Node, usize) -> Option<Node>,
) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
let room = *left + n.count_with_masks();
if let Some(new) = expand(d, n, room) {
*left = room.saturating_sub(new.count_with_masks());
*n = new;
*expanded = true;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand);
}
}
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
pub(crate) fn reshaped(l: &Node) -> Option<Node> {
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
/// path that one paints goes through [`expand_pieces`] instead.
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
let (path, _) = node_path(l)?;
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext { doc: Some(doc), ..GeomContext::of(l) });
let mut m = with_path(l, g);
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
@@ -490,13 +540,19 @@ pub(crate) fn reshaped(l: &Node) -> Option<Node> {
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
/// with the outline.
pub(crate) fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
pub(crate) fn fill_shape(
doc: &Document,
l: &Node,
item: &AppearanceItem,
path: &PathData,
bounds: Rect,
) -> Option<(Node, PathData, Rect, AppearanceItem)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds, item.clone()));
}
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext { doc: Some(doc), ..GeomContext::of(l) }.item(item));
let b = outline.bounds()?;
let mut one = Appearance { items: vec![item.clone()], ..Default::default() };
if carry.moves(&one) {
@@ -698,7 +754,7 @@ fn expand_live_with_transparent_image_patterns(
let Some(n) = d.node(*id) else { continue };
// Random Transform effects take their seed from the ids: bake them first ([`bake_seeded`]),
// and leave the object live when that can't be done.
let Some(mut g) = bake_seeded(&vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue };
let Some(mut g) = bake_seeded(d, &vectorcraft_render::effects::expand_live_deep(Some(d), n)) else { continue };
// An object whose art would take the expanded art past the budget stays live.
let size = g.count_with_masks();
if size > budget - made.get() {
@@ -781,14 +837,23 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
}
/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other
/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects.
/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. A
/// path that live-effect plug-ins paint is expanded piece by piece ([`expand_pieces`]).
fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option<Node> {
if vectorcraft_render::effects::has_painted_effects(l) {
return expand_pieces(d, l, budget, |d, piece, room| expand_piece_patterns(d, piece, &which, room));
}
expand_piece_patterns(d, l, which, budget)
}
/// [`expand_patterns_where`] of a path with no live-effect plug-in that paints what it returns.
fn expand_piece_patterns(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|| !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i))
{
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -820,7 +885,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
later -= usize::from(fill.is_some());
let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left };
let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| {
let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?;
let (fl, _, bounds, _) = fill_shape(d, l, item, &path, bounds)?;
let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else {
full = true;
return None;
@@ -867,7 +932,7 @@ fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, b
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
return Some(vec![]);
}
let Some(def) = seeds_baked(def) else {
let Some(def) = seeds_baked(d, def) else {
return Some(vec![]);
};
let brushes = vectorcraft_brush::library(d);
@@ -900,13 +965,14 @@ fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect, b
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
/// other than a path has such an effect.
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
/// other than a path has such an effect. The swatches and patterns that live-effect plug-ins
/// painting their pieces name are looked up in `d`.
fn seeds_baked<'a>(d: &Document, def: &'a PatternDef) -> Option<std::borrow::Cow<'a, PatternDef>> {
if !def.art.iter().any(|a| has_seeded(a)) {
return Some(std::borrow::Cow::Borrowed(def));
}
let mut def = def.clone();
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
def.art = def.art.iter().map(|a| bake_seeded(d, a).map(Arc::new)).collect::<Option<_>>()?;
Some(std::borrow::Cow::Owned(def))
}
@@ -1015,8 +1081,9 @@ pub(crate) fn has_seeded(n: &Node) -> bool {
}
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
/// when something else has one.
fn bake_seeded(n: &Node) -> Option<Node> {
/// when something else has one. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `d`.
fn bake_seeded(d: &Document, n: &Node) -> Option<Node> {
if !has_seeded(n) {
return Some(n.clone());
}
@@ -1024,17 +1091,17 @@ fn bake_seeded(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
return None;
}
vectorcraft_render::effects::bake_appearance(n)?
vectorcraft_render::effects::bake_appearance(Some(d), n)?
} else {
n.clone()
};
if !matches!(n.kind, NodeKind::Compound { .. }) {
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(bake_seeded(c)?);
*c = Arc::new(bake_seeded(d, c)?);
}
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
m.art = Arc::new(bake_seeded(&m.art)?);
m.art = Arc::new(bake_seeded(d, &m.art)?);
}
Some(n)
}
@@ -1055,7 +1122,7 @@ fn has_unbakeable_effects(n: &Node) -> bool {
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
/// stretch would rebuild their corners round), in its opacity masks too.
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
let mut t = baked(n);
let mut t = baked(d, n);
outline_brushed(d, brushes, &mut t);
drop_live(&mut t);
prepare_masks(d, brushes, &mut t);
@@ -1096,14 +1163,15 @@ fn scales_evenly(a: Affine) -> bool {
}
/// `n` with the strokes and geometry effects of each path in it baked into filled art
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
fn baked(n: &Node) -> Node {
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)), with the swatches and
/// patterns that live-effect plug-ins painting their pieces name looked up in `d`.
fn baked(d: &Document, n: &Node) -> Node {
if let Some(b) = vectorcraft_render::effects::bake_appearance(Some(d), n) {
return b;
}
let mut n = n.clone();
for c in n.children_mut().into_iter().flatten() {
*c = Arc::new(baked(c));
*c = Arc::new(baked(d, c));
}
n
}
+10 -3
View File
@@ -17,6 +17,8 @@
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
//! built-in format reads).
//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`).
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
@@ -31,6 +33,8 @@ mod encode;
pub mod eps;
mod export;
pub(crate) mod imagemap;
mod importer;
pub mod jxlread;
mod legacy_ai;
mod load;
mod mesh3d;
@@ -135,9 +139,10 @@ pub(crate) use export_names::{
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, read_extensions,
readable_format, readable_format_for_name, titled_for_export, writable_format,
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters,
export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions,
open_filters, opens_as_other, opens_with_importer, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name,
titled_for_export, writable_format,
};
#[cfg(test)]
@@ -203,6 +208,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_jxlread;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
+2 -2
View File
@@ -728,12 +728,12 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Ve
#[cfg(not(target_arch = "wasm32"))]
fn is_openable(path: &str) -> bool {
use super::{FORMATS, OPEN_EXTS, extension};
use super::{FORMATS, extension, open_extensions};
let ext = extension(path);
if ext.is_empty() {
return false;
}
OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
open_extensions().iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
}
#[cfg(test)]
File diff suppressed because one or more lines are too long
+142 -25
View File
@@ -505,6 +505,8 @@ pub const FORMATS: &[Format] = &[
reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true),
// HEVC-coded HEIF still images, decoded to PNG; image sequences are not read.
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
// jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it.
reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -544,6 +546,7 @@ pub const OPEN_EXTS: &[&str] = &[
"psd",
"pdd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
@@ -558,12 +561,12 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif",
"heic", "heif", "jxl",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -632,6 +635,7 @@ pub const PLACE_EXTS: &[&str] = &[
"psd",
"pdd",
"psb",
"jxl",
"vctemplate",
"dxf",
"emf",
@@ -659,11 +663,12 @@ pub fn read_extensions(f: &Format) -> &'static [&'static str] {
READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts)
}
/// The readable format of extension `ext` (any case, leading dot allowed): [`format`]'s when it
/// reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP).
/// The built-in readable format of extension `ext` (any case, leading dot allowed): [`format`]'s
/// when it reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). Importer plug-ins are
/// not looked at ([`importer_formats`]).
pub fn readable_format(ext: &str) -> Option<&'static Format> {
let k = ext.trim_start_matches('.').to_ascii_lowercase();
format(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| format(id)))
builtin(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| builtin(id)))
}
/// The readable format a file name's extension names ([`readable_format`]).
@@ -676,18 +681,110 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, read_extensions(f)))
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF, print
/// and export presets (imported), scripts (checked without running them) and plug-ins (installed).
/// Whether a file named `name` can only be read by an importer plug-in: an installed importer lists
/// its extension and File › Place does not read that extension.
pub fn opens_with_importer(name: &str) -> bool {
let ext = extension(name);
!ext.is_empty() && !PLACE_EXTS.contains(&ext.as_str()) && !vectorcraft_plugins::import::for_extension(&ext).is_empty()
}
/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a
/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print,
/// perspective grid or export presets or composite fonts (imported), a Libraries panel library, or a
/// swatch or graphic style library.
pub fn opens_as_other(ext: &str) -> bool {
[
crate::cmd::plugin::EXTS,
crate::cmd::shortcutset::EXTS,
crate::cmd::scripts::EXTS,
crate::cmd::flatten::PRESET_EXTS,
crate::cmd::pdfcmds::PRESET_EXTS,
crate::cmd::printpresets::PRESET_EXTS,
crate::cmd::perspgrid::PRESET_EXTS,
screens::PRESET_FILE_EXTS,
crate::cmd::compositefonts::EXTS,
crate::cmd::library::LIBRARY_EXTS,
crate::cmd::swatchlib::LIBRARY_EXTS,
std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT),
]
.iter()
.any(|exts| exts.contains(&ext))
}
/// File › Open's extensions and filters for the installed importer plug-ins.
type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>);
/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files"
/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer
/// that has extensions left, under its name. An importer's extensions leave out those a built-in
/// format reads, which File › Open reads with that format, and those File › Open opens as something
/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as
/// the dialogs that hold it.
fn importer_open_part() -> ImporterPart {
use vectorcraft_plugins::registry;
static MADE: std::sync::Mutex<Option<(u64, ImporterPart)>> = std::sync::Mutex::new(None);
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
let revision = registry::revision();
if let Some((r, part)) = made.as_ref()
&& *r == revision
{
return part.clone();
}
let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec();
let mut filters = Vec::new();
for f in importer_formats() {
let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect();
if own.is_empty() {
continue;
}
for &e in &own {
if !exts.contains(&e) {
exts.push(e);
}
}
let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) };
filters.push((f.label, own));
}
let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) };
let part = (exts, filters);
*made = Some((revision, part.clone()));
part
}
/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the
/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open
/// as something else ([`opens_as_other`]), in plug-in id order.
pub fn open_extensions() -> &'static [&'static str] {
importer_open_part().0
}
/// File › Open's filters: "All readable files" first, then one per readable format, then one per
/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then
/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF,
/// print and export presets (imported), scripts (checked without running them) and plug-ins
/// (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
.chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)))
let (all, importers) = importer_open_part();
std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters())
}
/// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For
/// open dialogs whose files the built-in readers read (New from Template, Load Styles, a document
/// picked for its swatches, symbols, brushes or graphic styles).
pub fn builtin_open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS)).chain(format_filters()).chain(other_open_filters())
}
/// The open-dialog filters after the document formats: swatch libraries, Libraries panel
/// libraries, presets, composite fonts, shortcut sets, scripts and plug-ins.
fn other_open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))
.chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", crate::cmd::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Export presets", screens::PRESET_FILE_EXTS)))
.chain(std::iter::once(("Composite fonts", crate::cmd::compositefonts::EXTS)))
.chain(std::iter::once(("Shortcut sets", crate::cmd::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", crate::cmd::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", crate::cmd::plugin::EXTS)))
@@ -696,12 +793,13 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
/// match the default filter) still list `.ase` and the other library extensions; then the same
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
/// filters as [`builtin_open_filters`] so a document can be picked for its swatches or graphic
/// styles.
pub fn library_open_filters(
library_label: &'static str,
library_exts: &'static [&'static str],
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once((library_label, library_exts)).chain(open_filters())
std::iter::once((library_label, library_exts)).chain(builtin_open_filters())
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
@@ -715,26 +813,40 @@ pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its
/// extension, so an exporter that lists it too is found by its format id.
/// one, else one a plug-in adds ([`plugin_formats`]), an exporter's before an importer's for an
/// extension both list. A built-in format keeps its extension, so an exporter or importer that
/// lists it too is found by its format id.
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| {
builtin(&k).or_else(|| {
let added = plugin_formats();
added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied()
let by_ext = |f: &&&Format| f.extensions.contains(&k.as_str());
added
.iter()
.find(|f| f.id.eq_ignore_ascii_case(&k))
.or_else(|| added.iter().filter(|f| f.write).find(by_ext))
.or_else(|| added.iter().find(by_ext))
.copied()
})
}
/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in
/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for
/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues
/// A built-in format by id or extension (lower case, without the dot).
fn builtin(k: &str) -> Option<&'static Format> {
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k)))
}
/// The formats the installed exporter and importer plug-ins add ([`vectorcraft_plugins::export`],
/// [`vectorcraft_plugins::import`]), by plug-in id: an exporter's write only and an importer's read
/// only, with the plug-in's name as the label and its extensions. Each is made once for each
/// installation of its plug-in, its strings interned, so formats live as long as the catalogues
/// that hold them.
pub fn plugin_formats() -> Vec<&'static Format> {
use vectorcraft_plugins::{export, registry};
use vectorcraft_plugins::{Kind, export, registry};
static MADE: std::sync::Mutex<Vec<(u64, &'static Format)>> = std::sync::Mutex::new(Vec::new());
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
export::installed()
registry::list()
.iter()
.filter(|p| matches!(p.manifest().kind, Kind::Exporter | Kind::Importer))
.map(|p| {
if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) {
return *f;
@@ -746,8 +858,8 @@ pub fn plugin_formats() -> Vec<&'static Format> {
label: registry::intern(&m.name),
extensions: Box::leak(extensions.into_boxed_slice()),
mime: "application/octet-stream",
read: false,
write: true,
read: m.kind == Kind::Importer,
write: m.kind == Kind::Exporter,
raster: false,
options: &[],
}));
@@ -757,6 +869,11 @@ pub fn plugin_formats() -> Vec<&'static Format> {
.collect()
}
/// The formats the installed importer plug-ins add ([`plugin_formats`] that read), by plug-in id.
pub fn importer_formats() -> Vec<&'static Format> {
plugin_formats().into_iter().filter(|f| f.read).collect()
}
/// The lower-case extension of a file name or path (empty when it has none).
pub fn extension(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
@@ -845,7 +962,7 @@ pub(super) fn formats(_: &mut Session, _: &Value) -> Result<Value> {
"formats": all().map(listed).collect::<Vec<_>>(),
"readable": ids(|f| f.read),
"writable": ids(|f| f.write),
"openExtensions": OPEN_EXTS,
"openExtensions": open_extensions(),
"unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::<Vec<_>>(),
}))
}
+170
View File
@@ -0,0 +1,170 @@
//! `document.open` of the files importer plug-ins read ([`vectorcraft_plugins::import`]).
//!
//! A file opens with an importer when `importer` names one (its format id, `plugin.<id>`), which
//! must list the file's extension, or when no built-in format reads the file, by its content or by
//! its extension, and an importer lists its extension (the one whose id sorts first, when several
//! do). A built-in format that can't read a damaged file reports its own error: the file is not
//! handed to an importer instead. Place reads only the built-in formats.
//!
//! The importer is given the file's bytes, at most [`import::MAX_INPUT_BYTES`] of them: a file
//! named by `path` that only an importer can read is read no further than that. Its objects become
//! a new document whose artboard is their bounds, and nothing opens when the importer fails,
//! declines, is cancelled or returns objects that don't pass the checks a filter's output does.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::{Document, MAX_COORD};
use vectorcraft_geom::Rect;
use vectorcraft_plugins::{Plugin, export, import, objects};
use super::super::{Result, bad, str_param};
use super::load::{Loaded, detect, file_name};
use super::{err, extension, file_stem, format};
use crate::EngineError;
const C: &str = "document.open";
/// The importer `document.open`'s params `p` name (`importer`), if any; a value that isn't a
/// string is an error.
fn named(p: &Value) -> Result<Option<&str>> {
match p.get("importer").filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, "`importer` must be a plug-in format id (see document.formats)")),
}
}
/// The importer that opens the file `name` holding `bytes` with `document.open`'s params `p`: the
/// one `importer` names, which must list the file's extension; else, when no built-in format reads
/// the file, the first installed importer that lists its extension; else `None`.
pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result<Option<Arc<Plugin>>> {
let ext = extension(name);
if let Some(id) = named(p)? {
let plugin = import::named(id).map_err(|e| bad(C, e.to_string()))?;
let m = plugin.manifest();
if !m.extensions.contains(&ext) {
let shown = vectorcraft_plugins::permissions::display_text(&m.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
let exts: Vec<String> = m.extensions.iter().map(|e| format!(".{e}")).collect();
return Err(bad(C, format!("“{shown}” opens {} files, not `{}`", exts.join(", "), file_name(name))));
}
return Ok(Some(plugin));
}
if detect(name, bytes).is_some() {
return Ok(None);
}
Ok(import::for_extension(&ext).into_iter().next())
}
/// How much of a file [`detect`] is given: more than its content checks read.
const SNIFF_BYTES: usize = 64 << 10;
/// Refuses `document.open`'s `dataBase64` in `p` before it is decoded when it holds a file past
/// [`import::MAX_INPUT_BYTES`] that only an importer reads, by the rule of `read_path`.
pub(super) fn check_data(p: &Value) -> Result<()> {
let Some(b64) = str_param(p, "dataBase64") else {
return Ok(());
};
let name = str_param(p, "name").unwrap_or("Untitled");
let ext = extension(name);
let explicit = named(p)?.is_some();
let built_in = !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty());
if built_in || vectorcraft_format::base64_decoded_len(b64) <= import::MAX_INPUT_BYTES as u64 {
return Ok(());
}
if !explicit {
let start: Vec<u8> = b64.bytes().filter(|b| !b.is_ascii_whitespace()).take(SNIFF_BYTES / 3 * 4).collect();
let head = std::str::from_utf8(&start).ok().and_then(vectorcraft_format::base64_decode);
if head.is_some_and(|head| detect(name, &head).is_some()) {
return Ok(());
}
}
Err(err(format!("can't open `{}`: {}", file_name(name), import::too_large())))
}
/// The file at `path` for `document.open`'s params `p`. A file that only an importer reads (one
/// `importer` names, or an extension no built-in format reads that an importer lists) is read up
/// to [`import::MAX_INPUT_BYTES`]: past that, it is an error, unless the start of the file shows a
/// built-in format and no importer was named, which is read whole as any other file is. A file past
/// the limit that isn't read whole is reported from its size, without reading it.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn read_path(path: &str, p: &Value) -> Result<Vec<u8>> {
let ext = extension(path);
let explicit = named(p)?.is_some();
if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) {
return super::read_file(path);
}
let head = super::read_file_head(path, SNIFF_BYTES)?;
if head.len() < SNIFF_BYTES {
return Ok(head);
}
if !explicit && detect(path, &head).is_some() {
return super::read_file(path);
}
let too_large = || err(format!("can't open `{}`: {}", file_name(path), import::too_large()));
let size = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
if size > import::MAX_INPUT_BYTES as u64 {
return Err(too_large());
}
// The file can grow after the size is read.
let file = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?;
if file.len() > import::MAX_INPUT_BYTES {
return Err(too_large());
}
Ok(file)
}
#[cfg(target_arch = "wasm32")]
pub(super) fn read_path(path: &str, _: &Value) -> Result<Vec<u8>> {
super::read_file(path)
}
/// The file `name` holding `bytes` read by importer `plugin` with `document.open`'s params `p`
/// (`params`: the importer's own parameters) into a new document, its artboard the bounds of what
/// the importer returned.
pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Result<Loaded> {
let m = plugin.manifest();
let format = format(&export::format_id(&m.id)).filter(|f| f.read).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(m.id.clone())))?;
let (ext, stem) = (extension(name), file_stem(name));
let source = import::Source { extension: &ext, name: &stem };
let params = p.get("params").cloned().unwrap_or(Value::Null);
let imported = import::run(plugin, bytes, &params, source).map_err(|e| match e {
vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled,
vectorcraft_plugins::Error::Params(why) => bad(C, why),
e => err(format!("can't open `{}`: {e}", file_name(name))),
})?;
let mut doc = Document::new(1.0, 1.0);
let layer = doc.layers.first().map(|l| l.id);
let admit = |fx: &[vectorcraft_doc::Effect], n: &vectorcraft_doc::Node| crate::cmd::plugin::admit_effects_for(C, fx, n);
let ids =
objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) {
board.rect = artboard_rect(bounds);
}
doc.title = file_name(name);
Ok(Loaded {
doc,
format,
warnings: imported.warnings,
restored: false,
converted: false,
preview_only: false,
editing_data: false,
native_pdf_compatible: None,
})
}
/// The artboard of art with bounds `r`: `r` within ±[`MAX_COORD`], at least 1 pt wide and high
/// about its center (the artboard of a single line or point). A side that would pass the edge
/// moves inward, so art at the edge keeps an artboard the document's edits accept.
fn artboard_rect(r: Rect) -> Rect {
let side = |lo: f64, hi: f64| {
let (lo, hi) = (lo.max(-MAX_COORD), hi.min(MAX_COORD));
if hi - lo >= 1.0 {
return (lo, hi);
}
let mid = ((lo + hi) / 2.0).clamp(-MAX_COORD + 0.5, MAX_COORD - 0.5);
(mid - 0.5, mid + 0.5)
};
let ((x0, x1), (y0, y1)) = (side(r.x0, r.x1), side(r.y0, r.y1));
Rect::new(x0, y0, x1, y1)
}
+273
View File
@@ -0,0 +1,273 @@
//! Reading JPEG XL images (`.jxl`, ISO/IEC 18181) for File › Open and File › Place, with the
//! jxl-oxide decoder: the bare codestream and the ISOBMFF container. The first frame of an
//! animation is read, with the orientation its header gives applied.
//!
//! Colors come back as sRGB whenever the decoder converts them without a color management
//! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC
//! profiles that describe one of them. Any other image with an embedded RGB ICC profile comes
//! back with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and
//! with the profile. Gray images, and images whose profile is not an RGB one, are refused when the
//! decoder cannot convert their ICC profile. 16-bit and floating-point samples become 8 bits, alpha comes back straight,
//! and CMYK images are refused. The file is untrusted: the decoder is given only a container's
//! image boxes, the image's size is checked from its header before any pixels are decoded, and the
//! decoder's image buffers are capped.
use jxl_color::ColorTransform;
use jxl_oxide::color::{ColourEncoding, TransferFunction};
use jxl_oxide::{AllocTracker, ColorEncodingWithProfile, EnumColourEncoding, HdrType, InitializeResult, JxlImage, NullCms, RenderingIntent};
/// A bare JPEG XL codestream starts with these bytes.
const CODESTREAM: [u8; 2] = [0xff, 0x0a];
/// The signature box an ISOBMFF JPEG XL container starts with.
const CONTAINER: [u8; 12] = [0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
/// How many bytes the decoder is given first to read the header. Each later try gives it twice as
/// many bytes, and each try decodes the embedded ICC profile from its start again.
const HEAD: usize = 4096;
/// The most the decoder's buffers may take while it reads the header. Its `AllocTracker` counts the
/// image buffers only: an embedded ICC profile is decoded into buffers of its own, which jxl-color
/// limits to 256 MiB.
const HEADER_BUFFERS: usize = 16 << 20;
/// The most the decoder's buffers may take for the pixels: this many times the image's size as
/// RGBA, plus [`HEADER_BUFFERS`], and never more than the `max_alloc` of [`decode`], the limit the
/// `image` crate's decoders get in `raster_image`. The decoder keeps each channel as 32-bit
/// samples: a lossless 16-bit RGBA image of 1024 × 768 pixels took 12 times its size as RGBA.
const DECODER_BUFFERS: u64 = 16;
const TRUNCATED: &str = "the JPEG XL file is cut short";
/// Is `bytes` a JPEG XL image (a bare codestream or a container)?
pub fn is_jxl(bytes: &[u8]) -> bool {
bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER)
}
/// Resolution after the decoder applies the image header's orientation.
pub(crate) fn oriented_resolution(ppi: Option<(f64, f64)>, orientation: u32) -> Option<(f64, f64)> {
ppi.map(|(x, y)| if (5..=8).contains(&orientation) { (y, x) } else { (x, y) })
}
/// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors
/// are in when they are not converted to sRGB ([`output_profile`]), and the header orientation.
/// Fails, without decoding the
/// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too
/// large to draw when the decoder's buffers for its pixels need more than they are allowed. A
/// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater,
/// is refused with "the JPEG XL container is too large to read".
pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option<Vec<u8>>, u32), String> {
let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?;
let bytes = pixel_boxes.as_deref().unwrap_or(bytes);
// The decoder reads the header first, with buffers for the header alone.
let (head, _) = header(bytes, HEADER_BUFFERS, None)?;
let (w, h) = (head.width(), head.height());
let rgba = u64::from(w) * u64::from(h) * 4;
if rgba > max_alloc {
return Err(too_large(w, h));
}
if head.pixel_format().has_black() {
return Err("CMYK JPEG XL images can't be read".into());
}
// Then it reads the file again until the first frame is loaded, with buffers for the pixels.
let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc);
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
let size = Some((w, h));
let (mut image, mut at) = header(bytes, budget, size)?;
let mut end = at;
while image.num_loaded_keyframes() == 0 {
if end == bytes.len() {
return Err(TRUNCATED.into());
}
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += image.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?;
}
let metadata = &image.image_header().metadata;
let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true);
let intent = RenderingIntent::Relative;
let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) };
let profile = output_profile(metadata.xyb_encoded, metadata.grayscale(), image.original_icc())?;
let orientation = metadata.orientation;
// Premultiplied colors are divided by alpha in the encoding they are stored in, and converted
// to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when
// they are HDR), which the sRGB transfer curve alone turns into sRGB.
let (request, convert) = match (to_srgb_without_cms(metadata.xyb_encoded, image.original_icc()), associated) {
(false, _) => (None, None),
(true, false) => (Some(srgb), None),
(true, true) if metadata.xyb_encoded => (Some(EnumColourEncoding { tf: TransferFunction::Linear, ..srgb }), Some(ToSrgb::Curve)),
(true, true) => {
let stored = match (&metadata.colour_encoding, image.original_icc()) {
(ColourEncoding::Enum(e), _) => ColorEncodingWithProfile::new(e.clone()),
(_, Some(icc)) => ColorEncodingWithProfile::with_icc(icc).map_err(unreadable)?,
(_, None) => return Err(unreadable("no color encoding")),
};
let mut builder = ColorTransform::builder();
builder.from_pq(image.hdr_type() == Some(HdrType::Pq));
let to = ColorEncodingWithProfile::new(srgb);
let t = builder.build(&stored, &to, &metadata.opsin_inverse_matrix, &metadata.tone_mapping, &NullCms).map_err(unreadable)?;
(None, Some(ToSrgb::Transform(t)))
}
};
if let Some(encoding) = request {
image.request_color_encoding(encoding);
}
let render = image.render_frame(0).map_err(|e| failed(e, size))?;
let mut stream = render.stream();
let channels = stream.channels() as usize;
if !(1..=4).contains(&channels) {
return Err(unreadable(format!("{channels} channels")));
}
let mut out = image::RgbaImage::new(stream.width(), stream.height());
let width = out.width() as usize;
let mut row = vec![0f32; width * channels];
let mut colors = [vec![0f32; width], vec![0f32; width], vec![0f32; width]];
for line in out.chunks_exact_mut((width * 4).max(4)) {
if stream.write_to_buffer(&mut row) != row.len() {
return Err(TRUNCATED.into());
}
to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?;
}
Ok((out, profile, orientation))
}
/// How colors whose alpha was divided out become sRGB.
pub(crate) enum ToSrgb {
/// A conversion from the encoding the colors are stored in.
Transform(ColorTransform),
/// The sRGB transfer curve, for colors in linear sRGB.
Curve,
}
/// One row of samples, `channels` to a pixel (gray or RGB, then alpha when there are 2 or 4), as
/// straight RGBA bytes in `out`: colors divided by alpha when it is `associated`, then converted
/// with `convert`. `colors` holds a row of red, green and blue; a gray sample goes into all three,
/// as jxl-render's `clone_gray` does before its own conversions.
pub(crate) fn to_rgba(
row: &[f32],
channels: usize,
associated: bool,
convert: Option<&ToSrgb>,
colors: &mut [Vec<f32>; 3],
out: &mut [u8],
) -> Result<(), String> {
let alpha = |s: &[f32]| if matches!(channels, 2 | 4) { s.last().map_or(1.0, |a| a.clamp(0.0, 1.0)) } else { 1.0 };
let gray = channels < 3;
for (x, s) in row.chunks_exact(channels).enumerate() {
let a = alpha(s);
for (i, plane) in colors.iter_mut().enumerate() {
let v = s.get(if gray { 0 } else { i }).copied().unwrap_or(0.0);
if let Some(c) = plane.get_mut(x) {
*c = if associated && a > 0.0 { v / a } else { v };
}
}
}
// The conversion leaves gray in the first row when it gives one channel.
let gray = match convert {
Some(ToSrgb::Transform(t)) => {
let mut planes: Vec<&mut [f32]> = colors.iter_mut().map(Vec::as_mut_slice).collect();
t.run(&mut planes).map_err(unreadable)? < 3
}
Some(ToSrgb::Curve) => {
colors.iter_mut().flatten().for_each(|v| *v = if *v <= 0.003_130_8 { *v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 });
gray
}
None => gray,
};
for (x, (px, s)) in out.as_chunks_mut::<4>().0.iter_mut().zip(row.chunks_exact(channels)).enumerate() {
let c = |i: usize| colors.get(if gray { 0 } else { i }).and_then(|p| p.get(x)).copied().unwrap_or(0.0);
*px = [byte(c(0)), byte(c(1)), byte(c(2)), byte(alpha(s))];
}
Ok(())
}
/// A container's signature and the boxes the decoder reads pixels from (`ftyp`, `jxll`, `jxlc` and
/// `jxlp`). Every other box is left out: Exif, XMP, JPEG reconstruction data and Brotli-compressed
/// `brob` boxes, which the decoder would otherwise store and unpack without a size limit. A box
/// cut short ends the file. Selected boxes exceeding `max_copy` are refused with "the JPEG XL
/// container is too large to read". `None` for a bare codestream.
pub(super) fn pixel_boxes(bytes: &[u8], max_copy: u64) -> Result<Option<Vec<u8>>, String> {
let Some(mut rest) = bytes.strip_prefix(&CONTAINER) else { return Ok(None) };
let mut out = CONTAINER.to_vec();
while let (Some(size), Some(ty)) = (rest.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()), rest.get(4..8)) {
let (head, len) = match u32::from_be_bytes(size) {
0 => (8, rest.len() as u64),
1 => match rest.get(8..16).and_then(|l| l.try_into().ok()) {
Some(l) => (16, u64::from_be_bytes(l)),
None => break,
},
n => (8, u64::from(n)),
};
if len < head {
break;
}
let len = usize::try_from(len).unwrap_or(usize::MAX).min(rest.len());
let (b, after) = rest.split_at(len);
if matches!(ty, b"ftyp" | b"jxll" | b"jxlc" | b"jxlp") {
if (out.len() as u64).saturating_add(b.len() as u64) > max_copy {
return Err("the JPEG XL container is too large to read".into());
}
out.try_reserve_exact(b.len()).map_err(|_| "the JPEG XL container is too large to read")?;
out.extend_from_slice(b);
}
rest = after;
}
Ok(Some(out))
}
/// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read
/// the header → (the decoder, how many bytes it took). `size` is the image's size when it is
/// known, for the error when the buffers need more ([`failed`]).
fn header(bytes: &[u8], buffers: usize, size: Option<(u32, u32)>) -> Result<(JxlImage, usize), String> {
let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit();
let (mut at, mut end) = (0, 0usize);
loop {
end = end.saturating_mul(2).max(HEAD).min(bytes.len());
at += uninit.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?;
match uninit.try_init().map_err(|e| failed(e, size))? {
InitializeResult::Initialized(image) => return Ok((image, at)),
InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()),
InitializeResult::NeedMoreData(more) => uninit = more,
}
}
}
/// Does the decoder convert to sRGB without a color management system? It does for XYB-encoded
/// images and the color spaces a header names; for an embedded ICC profile, only when the profile
/// describes one of those.
fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool {
xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty()))
}
/// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a
/// conversion to sRGB cannot be stored as RGB without changing their profile, and neither can
/// colors left in a profile that is not an RGB one.
pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result<Option<Vec<u8>>, String> {
let converts = to_srgb_without_cms(xyb, icc);
if grayscale && !converts {
return Err(unreadable("unsupported grayscale ICC profile"));
}
if !converts && icc.and_then(|icc| icc.get(16..20)) != Some(b"RGB ") {
return Err(unreadable("unsupported ICC profile color space"));
}
Ok(icc.filter(|_| !converts).map(<[u8]>::to_vec))
}
/// The decoder's error `e` as a message. When the decoder's buffers needed more than they were
/// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file
/// can't be read.
fn failed(e: Box<dyn std::error::Error + Send + Sync>, size: Option<(u32, u32)>) -> String {
let first: &(dyn std::error::Error + 'static) = &*e;
let out_of_memory = std::iter::successors(Some(first), |c| c.source()).any(|c| c.is::<jxl_grid::OutOfMemory>());
match size {
Some((w, h)) if out_of_memory => too_large(w, h),
_ => unreadable(e),
}
}
fn too_large(w: u32, h: u32) -> String {
format!("an image of {w} × {h} pixels is too large to draw")
}
fn unreadable(e: impl std::fmt::Display) -> String {
format!("the JPEG XL file can't be read: {e}")
}
fn byte(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
+41 -15
View File
@@ -9,7 +9,7 @@ use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -102,6 +102,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if super::pxrread::is_pxr(bytes) {
return format("pxr");
}
if jxlread::is_jxl(bytes) {
return format("jxl");
}
if super::jp2read::is_jpeg2000(bytes) {
return format("jpeg2000");
}
@@ -321,7 +324,7 @@ pub(crate) fn page_or_editing_data(
match read {
Ok((doc, notes)) => Ok((doc, notes, false)),
Err(e) if is_placeholder(&e) => {
let l = load_with(name, bytes, &LoadOptions { password: opts.password.clone(), ..Default::default() })?;
let l = load_with(name, bytes, &LoadOptions { password: opts.password.clone(), legacy_names: opts.legacy_names, ..Default::default() })?;
Ok((l.doc, l.warnings, true))
}
Err(e) if page_index == 0 => match load_with(name, bytes, &LoadOptions { pages: None, ..opts.clone() }) {
@@ -570,10 +573,15 @@ impl ReadFile {
/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document
/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from,
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
/// Links set to Automatically; see [`crate::cmd::links::resolve`]). A file an importer plug-in
/// reads (`importer` in `p`, or an extension only an importer lists) is read by it
/// ([`super::importer`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = load_with(name, bytes, &opts)?;
let mut loaded = match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
};
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
@@ -684,7 +692,14 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result<Source<'a>> {
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "document.open")?;
// A file only an importer plug-in reads is read up to the importer's input limit.
let src = match str_param(p, "path") {
Some(path) => Source { name: path, bytes: super::importer::read_path(path, p)?, path: Some(path) },
None => {
super::importer::check_data(p)?;
source(p, "document.open")?
}
};
// A relative path is the file in the working directory: its folder is where relative links
// are found (a bare file name has no folder of its own).
let path = src.path.map(super::absolute_path);
@@ -750,7 +765,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
@@ -758,25 +774,29 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
}
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi);
}
if jxlread::is_jxl(bytes) {
let (img, icc, orientation) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?;
return as_png(img, icc, jxlread::oriented_resolution(ppi, orientation));
}
if super::jp2read::is_jpeg2000(bytes) {
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
if super::heifread::is_heif(bytes) {
// The EXIF resolution describes the stored pixels; a quarter turn swaps its axes.
let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) });
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
if super::pxrread::is_pxr(bytes) {
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
reader.set_format(image::ImageFormat::Tga);
}
if reader.format().is_none() && super::pcxread::is_pcx(bytes) {
return as_png(super::pcxread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi);
return as_png(super::pcxread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, None, ppi);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
@@ -822,7 +842,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if f.id == "tga" {
super::tgaread::fix_alpha(bytes, &mut img);
}
as_png(img, ppi)
as_png(img, None, ppi)
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
@@ -833,11 +853,17 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientat
decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms)
}
/// `img` stored as a PNG that keeps the file's resolution.
fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
/// colors are in.
pub(super) fn as_png(img: image::RgbaImage, icc: Option<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
use image::ImageEncoder as _;
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
if let Some(icc) = icc {
encoder.set_icc_profile(icc).map_err(err)?;
}
img.write_with_encoder(encoder).map_err(err)?;
let png = match ppi {
Some(r) => super::ppi::with_png_resolution(&png, r),
None => png,
+29 -1
View File
@@ -1,5 +1,5 @@
//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and
//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! WebP and JPEG XL EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX
//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF
//! resolution tags. Place
//! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it
@@ -17,6 +17,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> {
jpeg(bytes)
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
tiff(bytes)
} else if super::jxlread::is_jxl(bytes) {
jxl(bytes)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
webp(bytes)
} else if bytes.starts_with(b"BM") {
@@ -196,6 +198,32 @@ fn webp(b: &[u8]) -> Option<(f64, f64)> {
None
}
fn jxl(b: &[u8]) -> Option<(f64, f64)> {
if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) {
return None;
}
let mut at = 12;
while at < b.len() {
let size = be32(b, at)?;
let ty = slice(b, at.checked_add(4)?, 4)?;
let (header, len) = match size {
0 => (8, b.len().checked_sub(at)?),
1 => (16, usize::try_from(u64::from_be_bytes(bytes(b, at.checked_add(8)?)?)).ok()?),
_ => (8, size as usize),
};
if len < header {
return None;
}
let payload = slice(b, at.checked_add(header)?, len - header)?;
if ty == b"Exif" {
let offset = usize::try_from(be32(payload, 0)?).ok()?;
return tiff(payload.get(4usize.checked_add(offset)?..)?);
}
at = at.checked_add(len)?;
}
None
}
fn bmp(b: &[u8]) -> Option<(f64, f64)> {
// BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28.
if le32(b, 14)? < 40 {
+25 -2
View File
@@ -377,8 +377,8 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
10 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF, print and export presets, shortcut sets, scripts, plug-ins"
11 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF, print and export presets, composite fonts, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Export presets", SCREEN_EXPORT_PRESET_EXTS)), "File › Open imports export presets");
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
@@ -891,3 +891,26 @@ fn a_photo_places_the_way_its_exif_orientation_says() {
let red = |p: &image::Rgb<u8>| p[0] > 150 && p[1] < 80;
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top");
}
/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an
/// error, whatever its content.
#[test]
fn read_path_for_a_named_importer_stops_at_the_limit() {
let limit = vectorcraft_plugins::import::MAX_INPUT_BYTES as u64;
let dir = tmp_dir("read-path-limit");
let path = dir.join("drawing.tuv");
let name = path.to_string_lossy().into_owned();
let named = json!({"importer": "plugin.test.notinstalled"});
let sized = |len: u64| {
std::fs::write(&path, b"\x89PNG\r\n\x1a\n").unwrap();
std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(len).unwrap();
};
for len in [8, 64 << 10, 70_000, limit] {
sized(len);
assert_eq!(importer::read_path(&name, &named).unwrap().len() as u64, len);
}
sized(limit + 1);
let e = importer::read_path(&name, &named).unwrap_err();
assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1022,6 +1022,21 @@ fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// `file.place` reads a `.ai` saved without its PDF part in the `legacyNameEncoding` it is given.
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_in_the_encoding_picked() {
let mut data = ai::sample_data();
let at = data.windows(4).position(|w| w == b"Pair").unwrap();
// 빨강 in code page 949.
data.splice(at..at + 4, br"\273\241\260\255".iter().copied());
let file = vectorcraft_format::base64_encode(&ai::ai(&compressed(&data), PLACEHOLDER));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": file, "link": false, "legacyNameEncoding": "windows-949"})).unwrap();
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
assert_eq!(names(&placed.children().unwrap()[1..]), ["빨강", "Ring", "Window"], "{r}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
@@ -0,0 +1,333 @@
//! Reading JPEG XL images: the bare codestream and the container, alpha, 16-bit samples, the size
//! check before decoding, and damaged files refused without a panic.
use serde_json::json;
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
use super::jxlread::{ToSrgb, decode, is_jxl, oriented_resolution, output_profile, pixel_boxes, to_rgba};
use super::*;
/// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in
/// native byte order.
fn jxl(w: usize, h: usize, space: ColorSpace, depth: BitDepth, samples: &[u8]) -> Vec<u8> {
let mut out = vec![];
zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(w, h, space, depth)).encode(&mut out).unwrap();
out
}
/// `codestream` in the ISOBMFF container: the signature box, `ftyp` and a `jxlc` box holding it.
fn container(codestream: &[u8]) -> Vec<u8> {
let mut f = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
f.extend(20u32.to_be_bytes());
f.extend(b"ftypjxl \0\0\0\0jxl ");
f.extend((8 + codestream.len() as u32).to_be_bytes());
f.extend(b"jxlc");
f.extend(codestream);
f
}
/// A 3 × 2 RGBA image: opaque red, green and blue, then half-transparent, clear and opaque gray.
const RGBA: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [200, 100, 50, 128], [9, 9, 9, 0], [77, 77, 77, 255]];
fn rgba8() -> Vec<u8> {
jxl(3, 2, ColorSpace::RGBA, BitDepth::Eight, RGBA.as_flattened())
}
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// The pixels of the one image the active document holds.
fn stored_pixels(s: &Session) -> Vec<[u8; 4]> {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.images.len(), 1);
let blob = doc.images.values().next().unwrap();
assert_eq!(blob.mime, "image/png", "stored as PNG");
image::load_from_memory(&blob.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
#[test]
fn jxl_images_open_and_place_with_their_pixels_and_alpha() {
let bare = rgba8();
let boxed = container(&bare);
assert!(is_jxl(&bare) && is_jxl(&boxed) && !is_jxl(b"\xff\xd8\xff"));
for (name, bytes) in [("bare.jxl", &bare), ("boxed.jxl", &boxed), ("named-wrong.png", &boxed)] {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(r["format"], "jxl", "{name}: told by its content");
let ab = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (3.0, 2.0), "{name}: 72 ppi, one point a pixel");
assert_eq!(stored_pixels(&s), RGBA, "{name}");
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(3.0), Some(2.0)), "{name}: {r}");
assert_eq!(stored_pixels(&s), RGBA, "{name}: placed");
}
assert!(OPEN_EXTS.contains(&"jxl") && PLACE_EXTS.contains(&"jxl"));
assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..])));
}
#[test]
fn container_copy_stays_within_the_input_allowance() {
let f = container(&[0; 128]);
assert!(pixel_boxes(&f, 64).unwrap_err().contains("too large"));
assert_eq!(pixel_boxes(&f, f.len() as u64).unwrap().unwrap(), f);
}
#[test]
fn container_exif_resolution_sizes_open_and_place() {
let bare = rgba8();
let mut f = container(&[]);
f.truncate(32);
let mut exif = Vec::new();
exif.extend(4u32.to_be_bytes());
exif.extend(b"Exif");
exif.extend(b"II*\0");
exif.extend(8u32.to_le_bytes());
exif.extend(3u16.to_le_bytes());
for (tag, value) in [(282u16, 50u32), (283, 58), (296, 2)] {
exif.extend(tag.to_le_bytes());
exif.extend((if tag == 296 { 3u16 } else { 5 }).to_le_bytes());
exif.extend(1u32.to_le_bytes());
exif.extend(value.to_le_bytes());
}
exif.extend(0u32.to_le_bytes());
for value in [144u32, 1, 72, 1] {
exif.extend(value.to_le_bytes());
}
f.extend((8 + exif.len() as u32).to_be_bytes());
f.extend(b"Exif");
f.extend(&exif);
f.extend((8 + bare.len() as u32).to_be_bytes());
f.extend(b"jxlc");
f.extend(&bare);
assert_eq!(super::ppi::resolution(&f), Some((144.0, 72.0)));
assert_eq!(super::ppi::resolution(&bare), None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap();
let rect = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((rect.width(), rect.height()), (1.5, 2.0));
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("file.place", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(1.5), Some(2.0)));
assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0)));
}
#[test]
fn jxl_axis_exchanging_orientations_swap_resolution() {
let ppi = Some((144.0, 72.0));
for orientation in 1..=8 {
let expected = if orientation >= 5 { Some((72.0, 144.0)) } else { ppi };
assert_eq!(oriented_resolution(ppi, orientation), expected, "orientation {orientation}");
}
assert_eq!(oriented_resolution(None, 6), None);
}
/// A `.jxl` file copied in a file manager pastes as its image.
#[test]
fn a_copied_jxl_file_pastes_as_its_image() {
use crate::cmd::clipboard::{FILE_HEAD, PASTE_ORDER, file_flavour};
let f = container(&rgba8());
let mime = file_flavour("copied.jxl", &f[..f.len().min(FILE_HEAD)], &PASTE_ORDER);
assert_eq!(mime, Some("image/jxl"));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let r = s.execute("clipboard.importImage", &json!({"dataBase64": b64(&f), "mime": mime})).unwrap();
assert_eq!((r["pixelWidth"].as_u64(), r["pixelHeight"].as_u64()), (Some(3), Some(2)), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(stored_pixels(&s), RGBA);
}
#[test]
fn sixteen_bit_samples_become_eight_bits() {
let rgb: [[u16; 3]; 4] = [[0x0000, 0x8080, 0xffff], [0x1234, 0x80ff, 0x7f00], [0xff00, 0x00ff, 0x4000], [0xc000, 0x0101, 0xfefe]];
let samples: Vec<u8> = rgb.as_flattened().iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples);
let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8;
let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect();
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), want);
// Gray, 16 bits, in the container, opened as a document.
let gray: Vec<u8> = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect();
let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray));
let mut s = Session::new();
s.execute("document.open", &json!({"name": "gray.jxl", "dataBase64": b64(&f)})).unwrap();
assert_eq!(stored_pixels(&s), [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255], [64, 64, 64, 255]]);
}
/// A bare codestream that is only a header: `w` × `h` pixels and default metadata.
fn header_only(w: u32, h: u32) -> Vec<u8> {
// Each field's bits, read from the lowest bit of each byte up: `div8`, the height as a U32
// (selector 3: 1 + 30 bits), `ratio` 0, the width likewise, then `all_default` for the
// metadata and `default_m` for the transform data.
let fields = [(0, 1), (3, 2), (h - 1, 30), (0, 3), (3, 2), (w - 1, 30), (1, 1), (1, 1)];
let (mut out, mut acc, mut n) = (vec![0xff, 0x0a], 0u64, 0);
for (v, bits) in fields {
acc |= u64::from(v) << n;
n += bits;
while n >= 8 {
out.push(acc as u8);
(acc, n) = (acc >> 8, n - 8);
}
}
out.push(acc as u8);
out
}
#[test]
fn an_image_too_large_is_refused_from_its_header() {
// The header of a 100000 × 100000 image (40 GB as RGBA), with no pixel data after it.
let huge = header_only(100_000, 100_000);
assert_eq!(decode(&huge, MAX_RASTER_ALLOC).unwrap_err(), "an image of 100000 × 100000 pixels is too large to draw");
let mut s = Session::new();
let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string();
assert!(e.contains("too large to draw"), "{e}");
// The allowance counts RGBA bytes: 3 × 2 pixels take 24.
assert!(decode(&rgba8(), 23).unwrap_err().contains("too large"));
assert!(decode(&rgba8(), 1 << 20).is_ok());
}
/// The decoder's buffers take more than the image as RGBA. When they need more than the allowance,
/// the image is too large to draw, as when its size alone is.
#[test]
fn an_image_whose_decoder_buffers_pass_the_allowance_is_too_large() {
assert_eq!(decode(&rgba8(), 24).unwrap_err(), "an image of 3 × 2 pixels is too large to draw");
let gray: Vec<u8> = (0..64 * 64).map(|i| (i % 251) as u8).collect();
let f = container(&jxl(64, 64, ColorSpace::Luma, BitDepth::Eight, &gray));
assert_eq!(decode(&f, 64 * 64 * 4).unwrap_err(), "an image of 64 × 64 pixels is too large to draw");
assert!(decode(&f, 1 << 20).is_ok());
// A file cut short is still reported as cut short or unreadable.
let e = decode(&f[..f.len() - 3], 1 << 20).unwrap_err();
assert!(!e.contains("too large"), "{e}");
}
/// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear
/// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255).
#[test]
fn associated_alpha_is_divided_out_before_the_color_conversion() {
use jxl_oxide::{ColorEncodingWithProfile, EnumColourEncoding, NullCms, RenderingIntent};
let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative);
let linear = EnumColourEncoding { tf: jxl_oxide::color::TransferFunction::Linear, ..srgb.clone() };
let header = jxl_oxide::JxlImage::builder().read(&rgba8()[..]).unwrap();
let m = &header.image_header().metadata;
let to_srgb = ToSrgb::Transform(
jxl_color::ColorTransform::new(
&ColorEncodingWithProfile::new(linear),
&ColorEncodingWithProfile::new(srgb),
&m.opsin_inverse_matrix,
&m.tone_mapping,
&NullCms,
)
.unwrap(),
);
let mut colors = [vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]];
let mut out = [0u8; 8];
for convert in [&to_srgb, &ToSrgb::Curve] {
to_rgba(&[0.25, 0.25, 0.25, 0.5, 0.0, 0.0, 0.0, 0.0], 4, true, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 0, 0, 0, 0]);
// Straight alpha is left as it is.
to_rgba(&[0.25, 0.25, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0], 4, false, Some(convert), &mut colors, &mut out).unwrap();
assert_eq!(out, [137, 137, 137, 128, 255, 255, 255, 255]);
}
// Gray with another white point converts through three channels and comes back as gray.
let gray_e = EnumColourEncoding {
white_point: jxl_oxide::color::WhitePoint::E,
tf: jxl_oxide::color::TransferFunction::Linear,
..EnumColourEncoding::gray_srgb(RenderingIntent::Relative)
};
let gray_srgb = ColorEncodingWithProfile::new(EnumColourEncoding::gray_srgb(RenderingIntent::Relative));
let to_gray =
jxl_color::ColorTransform::new(&ColorEncodingWithProfile::new(gray_e), &gray_srgb, &m.opsin_inverse_matrix, &m.tone_mapping, &NullCms)
.unwrap();
to_rgba(&[0.25, 0.5, 1.0, 1.0], 2, true, Some(&ToSrgb::Transform(to_gray)), &mut colors, &mut out).unwrap();
assert_eq!(out, [188, 188, 188, 128, 255, 255, 255, 255]);
}
/// A container's Exif and Brotli-compressed boxes are left out: an unreadable `brob` box before the
/// codestream doesn't stop the image from opening.
#[test]
fn metadata_boxes_are_not_read() {
let mut f = container(&[]);
f.truncate(32); // the signature and `ftyp`
let mut boxed = |ty: &[u8], body: &[u8]| {
f.extend((8 + body.len() as u32).to_be_bytes());
f.extend(ty);
f.extend(body);
};
boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]);
boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
boxed(b"jxlc", &rgba8());
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), RGBA);
}
/// Colors left in their embedded RGB profile keep it: the PNG they are stored in carries the
/// profile. Colors converted to sRGB, and a gray image's colors, which come back as RGB, are stored
/// without one.
#[test]
fn colors_left_in_their_icc_profile_keep_it() {
use image::ImageDecoder as _;
use jxl_oxide::{EnumColourEncoding, RenderingIntent};
// A profile jxl-color can't turn into a JPEG XL color space, so the decoder leaves the colors
// in it: the sRGB profile with the tag of its red curve renamed.
let unnamed = |encoding: &EnumColourEncoding, curve: &[u8; 4]| {
let mut icc = jxl_color::icc::colour_encoding_to_icc(encoding);
let count = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize;
for at in (0..count).map(|i| 132 + 12 * i) {
if icc[at..at + 4] == curve[..] {
icc[at..at + 4].copy_from_slice(b"xTRC");
}
}
icc
};
let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative);
let rgb = unnamed(&srgb, b"rTRC");
assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed");
assert_eq!(output_profile(false, false, Some(&rgb)).unwrap(), Some(rgb.clone()));
assert_eq!(output_profile(true, false, Some(&rgb)).unwrap(), None, "XYB-encoded colors become sRGB");
assert_eq!(output_profile(false, false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))).unwrap(), None, "converted to sRGB");
let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC");
assert!(!jxl_oxide::ColorEncodingWithProfile::with_icc(&gray).unwrap().icc_profile().is_empty());
assert_eq!(output_profile(false, true, Some(&gray)).unwrap_err(), "the JPEG XL file can't be read: unsupported grayscale ICC profile");
assert_eq!(
output_profile(false, true, Some(&jxl_color::icc::colour_encoding_to_icc(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative))))
.unwrap(),
None
);
assert_eq!(output_profile(false, false, None).unwrap(), None);
// A color image left in a profile that is not an RGB one can't be stored as RGB.
let mut lab = rgb.clone();
lab[16..20].copy_from_slice(b"Lab ");
assert_eq!(output_profile(false, false, Some(&lab)).unwrap_err(), "the JPEG XL file can't be read: unsupported ICC profile color space");
assert_eq!(output_profile(true, false, Some(&lab)).unwrap(), None, "XYB-encoded colors become sRGB");
// The PNG carries the profile, the pixels and the resolution.
let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap();
for icc in [Some(rgb), None] {
let RasterImage { blob, width, height, .. } = super::load::as_png(img.clone(), icc.clone(), Some((144.0, 144.0))).unwrap();
assert_eq!((width, height), (2, 1));
let mut png = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&blob.bytes[..])).unwrap();
assert_eq!(png.icc_profile().unwrap(), icc);
assert_eq!(image::DynamicImage::from_decoder(png).unwrap().to_rgba8(), img);
assert_eq!(super::ppi::resolution(&blob.bytes).map(|(x, y)| (x.round(), y.round())), Some((144.0, 144.0)));
}
}
#[test]
fn damaged_files_are_refused_without_a_panic() {
let good = rgba8();
for n in 0..good.len() {
assert!(decode(&good[..n], 1 << 30).is_err(), "cut at {n}");
}
// Every byte changed in turn: an error or an image, never a panic.
for i in 0..good.len() {
for v in [0, 1, 0x7f, 0x80, 0xff] {
let mut f = good.clone();
f[i] = v;
let _ = decode(&f, 1 << 30);
}
}
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let cut = &good[..good.len() - 3];
assert!(s.execute("file.place", &json!({"name": "cut.jxl", "dataBase64": b64(cut)})).is_err());
}
+31 -3
View File
@@ -5,8 +5,8 @@
//! `stroke.set` whatever is selected), a graphic style clicked with nothing selected (more fills
//! and strokes, effects, opacity and blend mode, and the link to the style) or, with the Appearance
//! panel's New Art Has Basic Appearance off, the whole appearance of the last selection. With the
//! option on (the default) an inherited appearance gives new art its top stroke's options only.
//! `paint.default` resets it.
//! option on (the default) an inherited appearance gives new art its top stroke's options only,
//! and a change of selection loads only the proxies. `paint.default` resets it.
use std::borrow::Cow;
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Art Has Basic Appearance",
["Window", "Appearance"],
None,
"{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options); off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}",
"{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options), the fill, stroke and weight the selection shares when it last changed; off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}",
always,
set_basic
),
@@ -213,6 +213,34 @@ impl Session {
let (ap, opacity, blend) = (super::style::captured(n), n.opacity, n.blend);
self.take_new_art(ap, opacity, blend, style, true);
}
/// After a command changed the selection, with New Art Has Basic Appearance on: the fill and
/// stroke proxies and the weight take what the selection shows in them, so new art looks like
/// it. A paint the selected objects don't share (a "?" proxy) stays as it was, and art without
/// paint (a placed image, whose None would make new art invisible) changes nothing. The
/// template is left alone, and without a stroke to show the weight stays too.
pub(crate) fn load_selection_paints(&mut self) {
if !self.prefs.new_art_basic {
return;
}
let has_paint = self.proxy_source(false).is_some_and(|(n, _)| matches!(n.kind, NodeKind::Text(_)) || !n.appearance.items.is_empty());
if !has_paint {
return;
}
let (fill_mixed, stroke_mixed) = self.proxy_mixed();
let (fill, stroke) = self.proxy_paints();
let weight_mixed = self.active().is_some_and(|st| st.stroke_mixed().weight);
let weight = self.shown_stroke().filter(|st| st.paint != Paint::None && !weight_mixed).map(|st| st.width);
if !fill_mixed {
self.paint.fill = unplaced(&fill);
}
if !stroke_mixed {
self.paint.stroke = unplaced(&stroke);
}
if let Some(width) = weight {
self.paint.stroke_width = width;
}
}
}
fn set_basic(s: &mut Session, p: &Value) -> Result<Value> {
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -308,7 +308,7 @@ pub(crate) fn pf_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
let hook = |c: &Node| vectorcraft_render::effects::outline_object(Some(d), c);
match vectorcraft_render::effects::compound_shape_art(n, Some(&hook)) {
match vectorcraft_render::effects::compound_shape_art(Some(d), n, Some(&hook)) {
Some(art) => out.push(art),
None => children.iter().for_each(|c| pf_leaves(d, c, out)),
}
@@ -912,7 +912,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
outline_member_strokes(d, brushes, &mut art);
return Some(d.reid(&art));
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, rule) = node_path(l)?;
let ctx = GeomContext::of(l);
@@ -923,7 +923,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => {
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(l, item, &path, b))).flatten();
let shaped = f.effects.iter().any(seeded).then(|| path.bounds().and_then(|b| fill_shape(d, l, item, &path, b))).flatten();
let Some((shape, _, _, moved)) = shaped else {
fills.push(item.clone());
continue;
+21 -6
View File
@@ -7,7 +7,8 @@
//! plug-in that fails leaves the document untouched), and read and write the data they keep with
//! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied
//! like any effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`;
//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`).
//! exporters add a file format that `document.export` writes, and importers one that
//! `document.open` reads (`fileio::plugin_formats`).
//!
//! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing,
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
@@ -65,7 +66,7 @@ pub fn describe(p: &Plugin) -> Value {
});
match m.kind {
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
Kind::Exporter => {
Kind::Exporter | Kind::Importer => {
v["format"] = json!(export::format_id(&m.id));
v["extensions"] = json!(m.extensions);
}
@@ -74,6 +75,9 @@ pub fn describe(p: &Plugin) -> Value {
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if m.paint_output {
v["paintOutput"] = json!(true);
}
if !m.commands.is_empty() {
let commands: Vec<Value> = m
.commands
@@ -158,6 +162,12 @@ impl FilterRun {
format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)),
));
}
Kind::Importer => {
return Err(bad(
C,
format!("`{}` is an importer: open a file with document.open {{\"importer\": \"{}\"}}", m.id, export::format_id(&m.id)),
));
}
}
Self::prepare_with(s, plugin, None, C, p)
}
@@ -446,8 +456,13 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result<Value>
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
/// host read one.
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
admit_effects_for("plugin.run", fx, n)
}
/// [`admit_effects`] for command `cmd`, which bad parameters name: `plugin.run`, or `document.open`
/// for the objects an importer plug-in returns.
pub(crate) fn admit_effects_for(cmd: &str, fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
use vectorcraft_render::effects;
const C: &str = "plugin.run";
let mut out = Vec::with_capacity(fx.len());
for (j, e) in fx.iter().enumerate() {
if n.appearance.effects.contains(e) {
@@ -460,7 +475,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, St
Some(plugin) => {
plugin.manifest().resolve_params(&params).map_err(|why| refuse(why.to_string()))?;
}
None => super::params::check_effect_params(C, &e.id, &params).map_err(|why| refuse(why.to_string()))?,
None => super::params::check_effect_params(cmd, &e.id, &params).map_err(|why| refuse(why.to_string()))?,
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
@@ -859,7 +874,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -868,7 +883,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Plug-in Info",
[],
None,
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null; for a live effect with paintOutput, its name and the refused field, such as objects[1].strokes[0].width)}",
always,
info
),
+55 -4
View File
@@ -12,7 +12,7 @@ use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::colorcmds::{Recolor, Scope};
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
use super::{
CommandSpec, Result, Session, always, bad, check_switches, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param,
};
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
@@ -118,6 +118,38 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet<NodeId>>) ->
used.into_iter().map(|(_, u)| u).collect()
}
/// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the
/// patterns it uses as fills or strokes, and of the patterns those tiles use (each pattern once).
fn shown_colors<'a>(d: &'a Document, n: &'a Node) -> Vec<Color> {
let mut colors = vec![];
node_colors(n, &mut |c, _| colors.push(*c));
let mut patterns: Vec<&str> = vec![];
pattern_names(n, &mut patterns);
let mut next = 0;
while let Some(&name) = patterns.get(next) {
next += 1;
for a in d.pattern(name).map(|def| def.art.as_slice()).unwrap_or_default() {
node_colors(a, &mut |c, _| colors.push(*c));
pattern_names(a, &mut patterns);
}
}
colors
}
/// Adds the names of the patterns `n` and the objects inside it use as fills or strokes to `names`,
/// leaving out names already there.
fn pattern_names<'a>(n: &'a Node, names: &mut Vec<&'a str>) {
n.walk(&mut |m| {
for p in node_paints(m) {
if let Paint::Pattern { pattern, .. } = p
&& !names.contains(&pattern.as_str())
{
names.push(pattern);
}
}
});
}
/// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids`
/// (each checked) or else the selection, with everything they hold; with nothing selected, the
/// objects Select All takes and the objects inside them, leaving out every hidden or locked object
@@ -526,6 +558,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
// `selected_roots` refuses it.
super::edit::refuse_symbol_part(s)?;
let ids = s.doc()?.selection.objects.clone();
let left = {
let st = s.doc()?;
left_out(&st.doc, st.isolation, &super::edit::roots_of(&st.doc, ids.clone()))
};
let group = text_param(p, "group", C)?;
if ids.is_empty() && group.is_none() {
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
@@ -563,7 +599,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
};
let rename = text_param(p, "rename", C)?;
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (changed, edit, added, converted) = s.edit(label, |d, _| {
let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| {
// The rows' library swatches join the document (one it has with that name and colour is
// used as it is).
let mut added = vec![];
@@ -601,12 +637,27 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let map = ColorMap::new(&rows, method, model, &links, kept, mapped_adjust);
let f = |c: Color| map.map(c);
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
// The objects left out whose colors the pass would change, with the colors they show now.
// The trial pass does not count towards the note on color models.
let before: Vec<(NodeId, Vec<Color>, bool)> = Recolor::with_links(scope, &f, &lf)
.would_change(d, &left)
.into_iter()
.filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden)))
.collect();
map.converted.set(false);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok((changed, edit, added, map.converted.get()))
// Rewriting the group's swatches can change the colors of art linked to them, left out or
// not, and of the patterns it uses. Art that only loses its link keeps its colors and is
// counted.
let not_changed = NotChanged::of(
before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden),
);
Ok((changed, edit, added, map.converted.get(), not_changed))
})?;
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
e.defaults(s);
out["relinked"] = json!(e.relinked());
+1
View File
@@ -226,6 +226,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
("kinsoku_type", json!(v.kinsoku_type)),
("roman_hanging", json!(v.roman_hanging)),
("kashida", json!(v.kashida)),
] {
@@ -98,6 +98,43 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() {
assert_eq!(old.kinsoku, Kinsoku::Hard);
}
/// Paragraph panel menu › Kinsoku Shori Type: `text.setFormat {kinsokuType}` sets it per
/// paragraph in one undo step, refuses unknown values, saves it only when it is not Push Out Only
/// (new type's and older documents' type), reopens with the file, and paragraph styles carry it.
#[test]
fn kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only() {
use vectorcraft_doc::KinsokuShoriType;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あ」\nい", "area": {"width": 100, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.clone(),
_ => panic!("text"),
};
assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushOutOnly);
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kinsoku_type").is_none());
s.execute("text.setFormat", &json!({"id": id, "start": 0, "end": 1, "kinsokuType": "pushInFirst"})).unwrap();
assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kinsoku_type"], "pushInFirst");
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((back.para_at(0).kinsoku_type, back.para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
assert!(s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushIn"})).is_err());
assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushInFirst, "an unknown value changes nothing");
s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushOutFirst"})).unwrap();
assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly));
// A paragraph style carries it.
s.execute("paraStyle.new", &json!({"name": "Pushed in", "attrs": {"kinsoku_type": "pushOutFirst"}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Pushed in", "id": id})).unwrap();
assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst);
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
assert_eq!(old.kinsoku_type, KinsokuShoriType::PushOutOnly);
}
#[test]
fn burasagari_is_set_per_paragraph_and_saved_only_when_on() {
use vectorcraft_doc::Burasagari;
+31 -24
View File
@@ -138,9 +138,10 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
}
/// Refuse what a variable of `kind` can't be bound to: art that is locked, on a hidden or locked
/// layer, or on a locked artboard; a text variable only drives type; an image variable only drives
/// an image object; a variable of unknown kind drives nothing.
/// Refuse what a variable of `kind` can't be bound to: art on a hidden layer or in a hidden group,
/// art on a locked artboard, and, unless `kind` is visibility, art that is locked or on a locked
/// layer or in a locked group; a text variable only drives type; an image variable only drives an
/// image object; a variable of unknown kind drives nothing.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
if kind == VariableKind::Unknown {
return Err(bad(cmd, "a variable of unknown kind can't be bound to objects"));
@@ -149,7 +150,7 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl
for id in ids {
match doc.node(*id) {
None => return Err(EngineError::NoNode(*id)),
Some(_) if !doc.variable_target_with(&locks, *id) => return Err(bad(cmd, not_a_target(doc, *id))),
Some(_) if !doc.variable_target_with(&locks, *id, kind) => return Err(bad(cmd, not_a_target(doc, *id, kind))),
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, format!("node {} is not type", id.0)));
}
@@ -165,14 +166,16 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl
Ok(())
}
/// Why `variable_target` refuses `id`: the object is locked, a group or layer that holds it is
/// locked, one that holds it is hidden, or it is on a locked artboard, checked in that order.
fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String {
/// Why `variable_target` refuses `id` for a variable of `kind`: the object is locked, a group or
/// layer that holds it is locked, one that holds it is hidden, or it is on a locked artboard,
/// checked in that order. For a visibility variable, the object and group lock checks are skipped.
fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId, kind: VariableKind) -> String {
let holders: Vec<&vectorcraft_doc::Node> =
doc.ancestry(id).unwrap_or_default().into_iter().filter(|i| *i != id).filter_map(|i| doc.node(i)).collect();
if doc.node(id).is_some_and(|n| n.locked) {
let locks = kind != VariableKind::Visibility;
if locks && doc.node(id).is_some_and(|n| n.locked) {
format!("node {} is locked", id.0)
} else if holders.iter().any(|n| n.locked) {
} else if locks && holders.iter().any(|n| n.locked) {
format!("node {} is in a locked group or layer", id.0)
} else if holders.iter().any(|n| !n.visible) {
format!("node {} is in a hidden group or layer", id.0)
@@ -511,8 +514,9 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Update Data Set: write what the bound objects hold now into the row that is active, for each
/// variable the row holds. A variable whose objects report no value (locked, inside a locked
/// layer or group, on a locked artboard, or gone) keeps the row's value. With `all`, every other variable the
/// variable the row holds. A variable whose objects report no value (gone, or, for a variable
/// other than a visibility variable, locked or inside a locked layer or group, or, for any
/// variable, on a locked artboard) keeps the row's value. With `all`, every other variable the
/// objects report a value for is added to the row. Without an active row there is nothing to
/// update, so say so rather than picking one. With every bound object locked, no value is
/// written; an update that writes no value adds no undo step.
@@ -588,14 +592,16 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
}
// Which bound objects the row may rewrite, measured once before any of them changes, so
// text that grows onto a locked artboard part way through is still rewritten. Each id is
// keyed by whether a text value is meant for it, which checks every frame of its story.
let targets: BTreeSet<(NodeId, bool)> = {
// keyed by whether a text value is meant for it, which checks every frame of its story,
// and by whether a visibility value is, which ignores locks.
let targets: BTreeSet<(NodeId, bool, bool)> = {
let locks = d.locked_artboards();
let mut out = BTreeSet::new();
for (id, var) in &pairs {
let text = d.variables.variable(var).is_some_and(|v| v.kind == VariableKind::Text);
if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id) } {
out.insert((*id, text));
let kind = d.variables.variable(var).map_or(VariableKind::Unknown, |v| v.kind);
let text = kind == VariableKind::Text;
if if text { d.story_target_with(&locks, *id) } else { d.dataset_target_with(&locks, *id, kind) } {
out.insert((*id, text, kind == VariableKind::Visibility));
}
}
out
@@ -617,12 +623,13 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
continue;
}
};
// Locked art, art in a locked layer or group or on a locked artboard, and text whose
// object is gone, can't be rewritten: skipped and counted, like the rest. Hidden art
// is rewritten, in a hidden layer or group too, and stays hidden unless a visibility
// value shows it. A text value rewrites the whole story of threaded text, so it skips
// when any frame of it is locked or on a locked artboard.
if !targets.contains(&(id, kind == VariableKind::Text)) {
// Art that is gone, art on a locked artboard, and art that is locked or in a locked
// layer or group, is skipped and counted, like the rest, except that a visibility value
// shows and hides locked art off a locked artboard. Hidden art is rewritten, in a hidden layer or group too,
// and stays hidden unless a visibility value shows it. A text value rewrites the whole
// story of threaded text, so it skips when any frame of it is locked or on a locked
// artboard.
if !targets.contains(&(id, kind == VariableKind::Text, kind == VariableKind::Visibility)) {
skipped += 1;
continue;
}
@@ -759,7 +766,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bind Variable",
["Window", "Variables"],
None,
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}",
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, art on a locked artboard is refused, and so are locked art and art in a locked layer or group except by a visibility variable. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}",
has_doc,
bind
),
@@ -815,7 +822,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Data Set",
["Window", "Variables"],
None,
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a locked layer or group, on a locked artboard, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (gone, on a locked artboard, or, for a variable other than a visibility variable, locked or inside a locked layer or group) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
has_doc,
dataset_update
),
+19 -1
View File
@@ -19,12 +19,20 @@ pub struct HistoryEntry {
next: usize,
/// The template and name `label` was made from ([`Session::edit_named`]).
pub(crate) named: Option<NamedStep>,
/// A number no other step takes ([`History::step`]).
serial: u64,
/// Original and converted new-art colors, only for steps that change document color mode.
pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>,
/// Owned new-art snapshots included in both directional estimates.
color_mode_bytes: usize,
}
/// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step.
fn next_serial() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// A step name made by filling the `{name}` placeholder of a template with a name
/// ([`Session::edit_named`]).
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -57,6 +65,7 @@ impl HistoryEntry {
next_bytes,
next: std::ptr::from_ref(next) as usize,
named: None,
serial: next_serial(),
color_mode_paints: None,
color_mode_bytes: 0,
}
@@ -88,6 +97,7 @@ impl HistoryEntry {
next_bytes: costs.bytes,
next: Arc::as_ptr(now) as usize,
named: None,
serial: next_serial(),
color_mode_paints: None,
color_mode_bytes: 0,
}
@@ -155,6 +165,13 @@ impl History {
self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum()
}
/// Which step the document is at: the newest undo step and the next redo step, by numbers no
/// other step takes (0 for none). Every new step, Undo, Redo and edit merged into the newest
/// step (an undo group) changes it; a selection change does not.
pub fn step(&self) -> (u64, u64) {
(self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial))
}
/// The document at row `row` of the History panel, `now` being the document now: the rows before
/// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first.
pub(crate) fn state<'a>(&'a self, now: &'a Arc<Document>, row: usize) -> Option<&'a Arc<Document>> {
@@ -192,13 +209,14 @@ impl History {
}
/// Estimate the newest undo step again, its next state now being `now` (the steps after it were
/// merged into it, or an undo group extended it).
/// merged into it, or an undo group extended it). It takes a new number ([`History::step`]).
pub(crate) fn recost_last(&mut self, now: &Document) {
if let Some(e) = self.undo.last_mut() {
let (before, after) = e.doc.retained_bytes_each(now);
e.bytes = before.saturating_add(e.color_mode_bytes);
e.next_bytes = after.saturating_add(e.color_mode_bytes);
e.next = std::ptr::from_ref(now) as usize;
e.serial = next_serial();
}
}
}
+8 -3
View File
@@ -62,6 +62,10 @@ pub struct Session {
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// The active document (its uid) and its [`History::step`] when the tool last handled a pointer
/// event or drew its overlays: when they differ at the next one, the tool is told first
/// ([`vectorcraft_tools::Tool::history_moved`]). None until the first.
pub(crate) tool_step: Option<(u64, (u64, u64))>,
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
/// gesture found, so that one that leaves it as it was picks nothing.
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
@@ -249,9 +253,10 @@ pub struct Session {
/// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]).
/// Tests set it.
pub search_rules: Option<cmd::findfiles::Rules>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared
/// when the preview params change or the interaction ends.
pub(crate) deferred_overlay_cache: Option<(String, Value, Vec<vectorcraft_tools::Overlay>)>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`],
/// [`Session::deferred_transform_box`]); cleared when the preview params change or the
/// interaction ends.
pub(crate) deferred_overlay_cache: Option<tooling::DeferredFeedback>,
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
+1
View File
@@ -46,6 +46,7 @@ impl Session {
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
tool_step: None,
selection_at_lend: None,
lent_selection: None,
last_selection_tool: None,
+6
View File
@@ -113,6 +113,9 @@ impl Session {
})
.flatten();
let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
// Only a selection command that changes the selection loads its paints for new art: other
// commands, and clicking what is already selected, keep a sampled paint in the proxies.
let selection_was = (id.starts_with("select.") || id == "layer.target").then(|| self.active().map(|d| d.selection.clone())).flatten();
let run = |s: &mut Session| {
if let Some(ids) = &select_first {
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
@@ -150,6 +153,9 @@ impl Session {
});
if !restored {
self.inherit_new_art();
if selection_was.is_some_and(|was| self.active().is_some_and(|d| d.selection != was)) {
self.load_selection_paints();
}
}
for uid in std::mem::take(&mut self.batch_paint_docs) {
if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
+97 -1
View File
@@ -1,6 +1,6 @@
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and
//! Escape dropping the first pick.
//! Escape, or an edit made outside the tool, dropping the first pick.
use serde_json::{Value, json};
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -233,6 +233,102 @@ fn escape_drops_the_blend_tools_first_pick() {
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
}
#[test]
fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() {
for case in ["edit", "undo", "redo", "group"] {
let undo = case == "undo";
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
if case == "redo" {
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
}
// In an open undo group, the edit after the pick is merged into the group's step.
if case == "group" {
s.begin_undo_group();
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap();
}
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
// Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked.
click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0);
assert!(!s.overlays(ViewInfo::default()).is_empty());
match case {
"undo" => s.execute("edit.undo", &json!({})).unwrap(),
"redo" => s.execute("edit.redo", &json!({})).unwrap(),
_ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(),
};
assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked");
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
click(&mut s, 220.0, 20.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first");
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art");
click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0);
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}");
}
}
#[test]
fn two_picks_with_no_edit_between_them_blend() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
click(&mut s, 20.0, 20.0);
// Drawing the overlays between the picks does not drop the first one.
assert!(!s.overlays(ViewInfo::default()).is_empty());
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, vec![a, b]);
}
#[test]
fn removing_the_blend_the_blend_tool_adds_to_ends_adding() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap());
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
click(&mut s, 20.0, 20.0);
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap();
sel(&mut s, &[other]);
click(&mut s, 420.0, 20.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first");
assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend");
assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys");
}
#[test]
fn a_selection_change_keeps_the_blend_tools_first_pick() {
for drag in [false, true] {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
let view = ViewInfo::default();
s.select_tool("blend", view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap();
sel(&mut s, &[c]);
// Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit).
let cmd = Mods { cmd: true, ..Mods::default() };
let to = if drag { 440.0 } else { 420.0 };
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap();
assert_eq!(s.tool_id(), "blend");
assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}");
s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap();
if drag {
assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick");
} else {
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, vec![a, b]);
}
}
}
#[test]
fn start_points_round_trip_and_old_blends_load() {
let mut s = session();
+14 -8
View File
@@ -179,7 +179,7 @@ fn fill_proxy_invert_recolors_a_colorized_gray_image_and_undo_restores_it() {
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id).unwrap();
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into()));
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#cc9966".into()));
s.execute("edit.undo", &json!({})).unwrap();
@@ -193,7 +193,7 @@ fn fill_proxy_complement_recolors_a_colorized_gray_image_and_undo_restores_it()
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id).unwrap();
let expected = Paint::solid(Color::rgb8(0, 255, 255));
assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id), Some(expected.clone()));
assert_eq!(s.proxy_paints().0, expected);
s.execute("edit.undo", &json!({})).unwrap();
@@ -209,7 +209,7 @@ fn stroke_proxy_invert_leaves_a_colorized_image_fill_unchanged() {
let key = image_key(&s, id);
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(255, 0, 0)));
assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255)));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
@@ -223,7 +223,10 @@ fn edit_colors_invert_without_fill_recolors_a_colorized_images_stroke() {
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(), json!({"changed": 1}));
assert_eq!(
s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(),
json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}})
);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255)));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
@@ -251,7 +254,10 @@ fn edit_colors_recolors_an_image_colorize_with_pixels_excluded() {
let id = image(&mut s, "gray", gray(false));
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let before = colorize(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(), json!({"changed": 1}));
assert_eq!(
s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(),
json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}})
);
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255))));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colorize(&s, id), before);
@@ -264,7 +270,7 @@ fn edit_colors_invert_recolors_colorize_without_changing_pixels_and_undo_restore
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into()));
assert_eq!(image_key(&s, id), key);
s.execute("edit.undo", &json!({})).unwrap();
@@ -292,7 +298,7 @@ fn edit_colors_invert_without_fill_leaves_colorized_image_unchanged() {
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
let before = colorize(&s, id);
let key = image_key(&s, id);
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0}));
assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(colorize(&s, id), before);
assert_eq!(image_key(&s, id), key);
}
@@ -326,7 +332,7 @@ fn proxy_invert_recolors_a_colorized_image_and_another_fill_in_one_undo_step() {
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2}));
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2, "notChanged": {"locked": 0, "hidden": 0}}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255))));
assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(255, 255, 0)));
+8 -8
View File
@@ -68,7 +68,7 @@ fn make_and_expand_each_shape_mode() {
assert_eq!(members, vec![back, front], "the members, in their stacking order");
assert!(shape.children().unwrap().iter().all(|m| m.extras().shape_mode.name() == mode));
assert_eq!(shape.appearance.fill_paint().color(), Some(fill), "{mode}");
let drawn = effects::compound_shape_area(&shape, None).unwrap();
let drawn = effects::compound_shape_area(None, &shape, None).unwrap();
assert!((area(&drawn) - want).abs() < 0.5, "{mode}: {}", area(&drawn));
// Expand: the path, or compound path, of the area, painted as the compound shape.
let made = run(&mut s, "object.compoundShape.expand", json!({}));
@@ -118,7 +118,7 @@ fn a_selected_member_takes_a_shape_mode() {
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Add]);
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Intersect]);
assert!((area(&effects::compound_shape_area(&node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5);
assert!((area(&effects::compound_shape_area(None, &node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5);
// The same mode again is no edit.
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"}));
@@ -208,7 +208,7 @@ fn make(s: &mut Session, mode: &str) -> NodeId {
fn drawn(s: &Session, id: NodeId) -> f64 {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
area(&effects::compound_shape_area(&node(s, id), Some(&hook)).unwrap())
area(&effects::compound_shape_area(None, &node(s, id), Some(&hook)).unwrap())
}
/// A bar (0–100 across, 10–30 down) behind a blend of two 20 pt squares at 0 and 80 (0–20 down)
@@ -243,7 +243,7 @@ fn a_blend_is_a_member_as_its_steps() {
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(id), doc.node(id));
assert!((area(&effects::compound_shape_area(back.node(id).unwrap(), None).unwrap()) - more).abs() < 0.5);
assert!((area(&effects::compound_shape_area(None, back.node(id).unwrap(), None).unwrap()) - more).abs() < 0.5);
run(&mut s, "select.set", json!({"ids": [id.0]}));
let made = run(&mut s, "object.compoundShape.expand", json!({}));
assert!((node_area(&node(&s, NodeId(made["ids"][0].as_u64().unwrap()))) - more).abs() < 0.5, "{mode}");
@@ -472,8 +472,8 @@ fn branching_symbol_art_stops_at_its_budget() {
}
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
assert!(effects::compound_shape_area(&node(&s, id), Some(&hook)).is_none(), "past the budget");
let leaves = effects::member_leaves(&node(&s, group), Some(&hook));
assert!(effects::compound_shape_area(None, &node(&s, id), Some(&hook)).is_none(), "past the budget");
let leaves = effects::member_leaves(None, &node(&s, group), Some(&hook));
assert!(!leaves.is_empty() && leaves.len() as u64 <= effects::SYMBOL_ART_MAX_NODES, "{} shapes", leaves.len());
}
@@ -563,7 +563,7 @@ fn a_warped_symbol_instance_is_a_member_as_it_draws() {
assert!((warped - 400.0).abs() > 10.0, "the warp changes the area: {warped}");
assert!((drawn(&s, id) - (warped + 100.0)).abs() < 1.0, "{} vs {}", drawn(&s, id), warped + 100.0);
let hook = |c: &Node| effects::outline_object(Some(d), c);
let alone: f64 = effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum();
let alone: f64 = effects::member_leaves(None, &node(&s, a), Some(&hook)).iter().map(node_area).sum();
assert!((alone - warped).abs() < 1.0, "the instance alone: {alone} vs {warped}");
}
@@ -616,7 +616,7 @@ fn type_in_symbol_art_counts_as_its_outlines() {
let unwarped = {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum::<f64>()
effects::member_leaves(None, &node(&s, a), Some(&hook)).iter().map(node_area).sum::<f64>()
};
assert!(unwarped > 200.0, "the glyphs: {unwarped}");
run(&mut s, "select.set", json!({"ids": [a.0]}));
+44
View File
@@ -430,3 +430,47 @@ fn recolor_writes_a_new_pattern_swatch() {
s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string()));
}
#[test]
fn edit_colors_of_everything_count_the_hidden_objects_left_out() {
let mut s = session();
let shown = rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#ff0000"));
// A hidden object with no paint would not change.
let unpainted = rect(&mut s, json!("#ff0000"));
s.execute("paint.setFill", &json!({"ids": [unpainted.0], "none": true})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [unpainted.0], "none": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [hidden.0, unpainted.0], "visible": false})).unwrap();
// A locked member of a selected group is recolored with it.
let member = rect(&mut s, json!("#ff0000"));
let locked_member = rect(&mut s, json!("#ff0000"));
s.execute("select.set", &json!({"ids": [member.0, locked_member.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.setProps", &json!({"ids": [locked_member.0], "locked": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 1 hidden object was left unchanged");
assert_eq!((fill_of(&s, shown).to_hex(), fill_of(&s, hidden).to_hex()), ("#00ffff".to_string(), "#ff0000".to_string()));
assert_eq!(fill_of(&s, locked_member).to_hex(), "#00ffff");
// Objects given by `ids` are the whole scope.
let r = s.execute("edit.colors.toGrayscale", &json!({"ids": [shown.0]})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn edit_colors_in_isolation_mode_count_the_locked_members_left_out() {
let mut s = session();
let a = rect(&mut s, json!("#ff0000"));
let b = rect(&mut s, json!("#ff0000"));
let c = rect(&mut s, json!("#ff0000"));
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("layer.setProps", &json!({"ids": [b.0, c.0], "locked": true})).unwrap();
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("edit.colors.saturate", &json!({"intensity": -50})).unwrap();
assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 0}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 2 locked objects were left unchanged");
}
+41
View File
@@ -180,6 +180,47 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
assert_eq!(first_x(&s), start);
}
/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit
/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the
/// same for a selection change.
#[test]
fn the_history_step_changes_with_each_step_undo_and_redo() {
let mut s = session_with_rects(2);
let step = |s: &Session| s.doc().unwrap().history.step();
let start = step(&s);
assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}");
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let moved = step(&s);
assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}");
s.execute("edit.undo", &json!({})).unwrap();
let undone = step(&s);
assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}");
s.execute("edit.redo", &json!({})).unwrap();
let redone = step(&s);
assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}");
// An undo and a new edit: as many undo steps as after the move, at a different step.
s.execute("edit.undo", &json!({})).unwrap();
let depth = s.doc().unwrap().history.undo.len();
s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap();
let other = step(&s);
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
assert!(![start, moved, undone, redone].contains(&other), "{other:?}");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(step(&s), other, "a selection change is no step");
s.execute("select.all", &json!({})).unwrap();
s.begin_undo_group();
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len());
assert_ne!(grouped, other);
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first");
let merged = step(&s);
assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}");
s.execute("select.none", &json!({})).unwrap();
assert_eq!(step(&s), merged);
s.end_undo_group(false);
}
#[test]
fn color_mode_paint_snapshots_count_toward_the_history_budget() {
let mut s = Session::new();
+130
View File
@@ -141,6 +141,136 @@ fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() {
assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing");
}
/// A rectangle with this fill, stroke and weight, selected.
fn painted(s: &mut Session, x: f64, fill: &str, stroke: &str, weight: f64) -> NodeId {
let id = rect(s, x);
run(s, "paint.setFill", json!({"color": fill}));
run(s, "paint.setStroke", json!({"color": stroke}));
run(s, "stroke.set", json!({"weight": weight}));
id
}
fn select(s: &mut Session, ids: &[NodeId]) {
run(s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
}
#[test]
fn selecting_an_object_gives_new_art_its_fill_stroke_and_weight_with_basic_appearance_on() {
let mut s = session();
assert!(s.prefs.new_art_basic, "on by default");
let green = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
select(&mut s, &[green]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
let (n, from) = (node(&s, id).appearance, node(&s, green).appearance);
assert_eq!((n.fill_paint(), n.stroke_paint(), n.stroke_width()), (from.fill_paint(), from.stroke_paint(), 5.0));
}
#[test]
fn with_basic_appearance_on_only_the_basic_fill_and_stroke_follow_the_selection() {
let mut s = session();
let src = fancy(&mut s);
painted(&mut s, 120.0, "#00ff00", "#000000", 2.0);
select(&mut s, &[src]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
let n = node(&s, id);
assert_eq!(n.appearance.fill_paint(), node(&s, src).appearance.fill_paint(), "the top fill");
assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (2, 0, 1.0), "one fill and stroke, nothing else");
}
#[test]
fn a_paint_the_selected_objects_do_not_share_stays_as_it_was() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let b = painted(&mut s, 120.0, "#0000ff", "#00ff00", 5.0);
painted(&mut s, 220.0, "#ffff00", "#000000", 1.0);
let before = s.paint.fill.clone();
select(&mut s, &[a, b]);
assert_eq!(s.paint.fill, before, "the fills differ");
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (node(&s, a).appearance.stroke_paint(), 5.0), "the strokes are shared");
}
#[test]
fn art_without_paint_leaves_the_proxies_as_they_were() {
let mut s = session();
painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone());
let dir = super::links::Folder::new("new-art-image");
let path = dir.file("pic.png");
super::links::write(&path, &super::links::png(4, 4, super::links::RED));
let image = super::links::place(&mut s, &path);
run(&mut s, "select.none", json!({}));
select(&mut s, &[image]);
assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width), (fill, stroke, 5.0), "new art stays visible");
}
#[test]
fn an_object_without_a_stroke_clears_the_stroke_and_keeps_the_weight() {
let mut s = session();
let bare = rect(&mut s, 20.0);
run(&mut s, "paint.setStroke", json!({"none": true}));
painted(&mut s, 120.0, "#0000ff", "#000000", 7.0);
select(&mut s, &[bare]);
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 7.0));
}
#[test]
fn a_selected_gradient_loads_without_its_place_on_the_object() {
let mut s = session();
let src = rect(&mut s, 20.0);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}}));
painted(&mut s, 120.0, "#0000ff", "#000000", 2.0);
select(&mut s, &[src]);
assert!(matches!(s.paint.fill, Paint::Gradient(_)), "{:?}", s.paint.fill);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 220.0);
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn selecting_leaves_the_stroke_panels_options_for_new_art_alone() {
let mut s = session();
let plain = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
run(&mut s, "select.none", json!({}));
run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2]}));
select(&mut s, &[plain]);
run(&mut s, "select.none", json!({}));
let id = rect(&mut s, 120.0);
let st = node(&s, id).appearance.stroke().unwrap().clone();
assert_eq!((st.paint.clone(), st.width), (node(&s, plain).appearance.stroke_paint(), 5.0));
assert_eq!((st.cap, st.dash.map(|d| d.pattern)), (LineCap::Round, Some(vec![4.0, 2.0])));
}
#[test]
fn a_sampled_paint_stays_until_the_selection_changes() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let sampled = Paint::solid(vectorcraft_color::Color::WHITE);
s.paint.fill = sampled.clone();
run(&mut s, "transparency.set", json!({"opacity": 50}));
assert_eq!(s.paint.fill, sampled, "not a selection command");
select(&mut s, &[a]);
assert_eq!(s.paint.fill, sampled, "the same selection again");
run(&mut s, "select.none", json!({}));
assert_eq!(s.paint.fill, sampled, "nothing selected shows nothing to load");
select(&mut s, &[a]);
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint(), "selected anew");
}
#[test]
fn targeting_a_layer_loads_the_paints_of_its_contents() {
let mut s = session();
let a = painted(&mut s, 20.0, "#ff0000", "#00ff00", 5.0);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "select.none", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(s.paint.fill, node(&s, a).appearance.fill_paint());
}
#[test]
fn the_option_round_trips_with_the_preferences() {
let mut s = session();
+41 -22
View File
@@ -974,19 +974,48 @@ fn expanded_colours_keep_the_stops_colour_model() {
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
}
/// A new 200 × 200 CMYK document.
fn cmyk_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
s
}
/// The selection's freeform gradient expanded: the mesh's colors, after checking that it draws
/// within `most` (mean channel difference) of the gradient.
fn expanded(s: &mut Session, most: f64) -> Vec<Color> {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect()
}
/// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient and the
/// mesh both mix inks, so the two draw alike.
fn cyan_and_black_points_expand_as_they_draw(mut s: Session) {
let (cyan, black) = (json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}), json!({"c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, black.clone(), black]);
for c in expanded(&mut s, 2.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_an_rgb_document() {
cyan_and_black_points_expand_as_they_draw(session());
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_a_cmyk_document() {
cyan_and_black_points_expand_as_they_draw(cmyk_session());
}
#[test]
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
// the gradient.
let expanded = |s: &mut Session, most: f64| {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
};
// Points of one ink set give every mesh point that ink set.
let mut s = session();
let one = ink(0.5, 0.2, 0.0, 0.1);
@@ -994,19 +1023,9 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
for c in expanded(&mut s, 1.0) {
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
}
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
let mut s = session();
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
for c in expanded(&mut s, 7.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
let gray = json!({"gray": 0.5});
let (cyan, gray) = (ink(1.0, 0.0, 0.0, 0.0), json!({"gray": 0.5}));
let mut s = cmyk_session();
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
for c in expanded(&mut s, 2.5) {
inks(c);
+620
View File
@@ -316,6 +316,626 @@ fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
}
/// The manifest of a test live effect `id` that declares `paintOutput` (at `api` 2), or that
/// doesn't (`paints` off).
fn painting(id: &str, paints: bool) -> String {
let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" };
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#)
}
/// A 20 × 20 square at `x`, 40.
fn square_at(x: f64) -> PathData {
PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true))
}
fn solid(hex: &str) -> Value {
serde_json::to_value(Paint::solid(Color::from_hex(hex).unwrap())).unwrap()
}
/// The 200 × 200 top left of the document, drawn on white.
fn drawn(s: &Session) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
const WHITE: [u8; 4] = [255, 255, 255, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// A live effect that declares `paintOutput` draws each piece it returns by itself, with the paints
/// it gives (a piece without `fills` keeps the object's), in place of the object; the same output
/// from an effect that doesn't declare it is one path painted by the object's fill. Drawing and
/// export leave the document alone, and Expand Appearance writes the pieces as ordinary paths in
/// one undo step.
#[test]
fn a_painted_live_effect_draws_its_pieces_with_their_own_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.paints", true), &out));
install_wat(&mut s, &tpl::returning(&painting("test.engine.geometry", false), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geometry"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (RED, RED, WHITE), "without paintOutput the paint is ignored");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.paints"}));
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, RED, WHITE), "each piece with its own paint");
// An upstream paint edit reaches the piece that keeps the object's fill; Undo brings it back.
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#0000ff"}));
assert_eq!((drawn(&s).pixel(50, 50), drawn(&s).pixel(90, 50)), (GREEN, BLUE));
run(&mut s, "edit.undo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), RED);
run(&mut s, "edit.redo", json!({}));
assert_eq!(drawn(&s).pixel(90, 50), BLUE);
run(&mut s, "edit.undo", json!({}));
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains("#00ff00") && svg.contains("#ff0000"), "{svg}");
assert_eq!(s.doc().unwrap().doc, before, "drawing and export don't change the document");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Expanded: two ordinary paths, green and red, and no live effect, as one undo step.
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let mut paths = vec![];
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
assert!(n.appearance.effects.is_empty(), "no live effect left");
paths.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(paths, [(square_at(40.0).bounds(), fill("#00ff00")), (square_at(80.0).bounds(), fill("#ff0000"))]);
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.effects[0].id, "plugin.test.engine.paints");
}
/// On a fill, a painted effect returns only fills: they replace that fill on each piece. A result
/// that breaks a rule (strokes from an effect on a fill, a swatch the document doesn't have) is
/// refused whole: the object is drawn as if the effect weren't there, and `plugin.info` names the
/// plug-in and the field.
#[test]
fn a_painted_effect_on_a_fill_and_refused_results() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let fills = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string();
let strokes = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#00ff00"), "width": 2}]}]}).to_string();
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
let unknown = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.fillpaint", true), &fills));
install_wat(&mut s, &tpl::returning(&painting("test.engine.strokepaint", true), &strokes));
install_wat(&mut s, &tpl::returning(&painting("test.engine.swatchpaint", true), &unknown));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.fillpaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the fill's piece, green");
assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.fillpaint"}))["lastError"], Value::Null);
// A second painted effect on the same fill paints what it returns too.
let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.bluepaint", true), &blue));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.bluepaint", "item": fill}));
let img = drawn(&s);
assert_eq!((img.pixel(90, 50), img.pixel(50, 50), img.pixel(15, 15)), (BLUE, WHITE, WHITE), "the second effect's piece, blue");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 1, "item": fill}));
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": fill}));
for (effect, item, field) in
[("test.engine.strokepaint", json!(fill), "objects[0].strokes"), ("test.engine.swatchpaint", Value::Null, "objects[0].fills[0].swatch")]
{
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{effect}"), "item": item}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (WHITE, RED), "{effect}: drawn as it is");
let e = run(&mut s, "plugin.info", json!({"id": effect}))["lastError"].as_str().unwrap_or_default().to_string();
assert!(e.starts_with(&format!("Test {effect}: ")) && e.contains(field), "{effect}: {e}");
let n = node(&s, id);
let kept = if item.is_null() { n.appearance.effects.clone() } else { n.appearance.items[fill].effects().to_vec() };
assert_eq!(kept.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [format!("plugin.{effect}")], "the effect record stays");
run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": item}));
}
}
/// A painted effect on a group may link its pieces to the document's swatches.
#[test]
fn a_painted_group_effect_links_to_the_document_s_swatches() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
run(&mut s, "select.all", json!({}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.grouppaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.grouppaint"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green");
}
/// The output of a painted effect that returns one green square linked to swatch `Leaf`.
fn leaf_square() -> String {
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string()
}
/// A Pathfinder operation and baked appearance take a painted effect's pieces, swatch links
/// included, as the canvas draws them: a group with a painted effect inside a group with a live
/// Pathfinder effect draws the green piece, and baking a path's appearance gives the piece linked
/// to the swatch.
#[test]
fn pathfinder_and_baked_appearance_take_painted_pieces_with_swatch_links() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.leafpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "plugin.test.engine.leafpaint"}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.add"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the Pathfinder operation takes the green piece");
let id = rect(&mut s, 120.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.leafpaint"}));
let st = s.doc().unwrap();
let baked = vectorcraft_render::effects::bake_appearance(Some(&st.doc), st.doc.node(id).unwrap()).unwrap();
let mut fills = vec![];
baked.walk(&mut |n| {
if let Some(p) = n.path_data() {
fills.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone())));
}
});
let mut leaf = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut leaf {
*swatch = Some("Leaf".into());
}
assert_eq!(fills, [(square_at(40.0).bounds(), Some(leaf))]);
}
/// A swatch-linked piece on screen is drawn when the path it comes from is off screen: the
/// renderer's bounds of the path take in the piece.
#[test]
fn a_swatch_linked_piece_is_not_culled_with_its_off_screen_path() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.culled", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.culled"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// The renderer measures a painted piece with its own strokes: a piece off screen whose wide stroke
/// reaches the view is drawn, and the bounds raster effects work over take in that stroke, on a
/// path with no stroke of its own and with a blur on the stroke the effect is on.
#[test]
fn a_piece_s_own_stroke_counts_in_the_bounds_of_what_is_drawn() {
let mut s = session();
let wide = json!({"objects": [{"path": square_at(-120.0), "strokes": [{"paint": solid("#00ff00"), "width": 300}]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.widestroke", true), &wide));
let id = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.widestroke"}));
assert_eq!(drawn(&s).pixel(30, 50), GREEN, "only the piece's stroke is on screen");
let bounds = |s: &Session, id: NodeId| vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, &node(s, id)).unwrap();
assert!(bounds(&s, id).x1 >= 50.0, "{:?}", bounds(&s, id));
let blurred = rect(&mut s, -300.0, "#ff0000");
run(&mut s, "paint.setStroke", json!({"ids": [blurred.0], "color": "#0000ff", "width": 1}));
let stroke = node(&s, blurred).appearance.items.iter().position(|i| !i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "plugin.test.engine.widestroke", "item": stroke}));
run(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "blur.gaussian", "params": {"radius": 10}, "item": stroke}));
assert!(bounds(&s, blurred).x1 > 55.0, "the blur spreads the piece's stroke: {:?}", bounds(&s, blurred));
}
/// A retained renderer's kept bounds follow the document's swatches: a swatch-linked piece on
/// screen appears when its swatch is added, though the path it comes from is off screen.
#[test]
fn a_retained_renderer_shows_a_piece_once_its_swatch_exists() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.lateleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.lateleaf"}));
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = vectorcraft_render::Renderer::new();
let at_piece = |r: &mut vectorcraft_render::Renderer, s: &Session| {
r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50)
};
assert_eq!(at_piece(&mut r, &s), WHITE, "the piece is refused");
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(at_piece(&mut r, &s), GREEN, "the piece is drawn with its swatch");
}
/// Object › Expand of a gradient fill works piece by piece, for a painted effect on the object and
/// on the fill: a piece that keeps the gradient has it expanded, and a piece with a paint of its own,
/// here linked to the document's swatch, keeps that paint on its own path.
#[test]
fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
for on_fill in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &out));
let item = if on_fill { json!(node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap()) } else { Value::Null };
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref", "item": item}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, GREEN), "on_fill {on_fill}: the input is gone and the piece keeps its paint");
assert!(![WHITE, GREEN].contains(&img.pixel(110, 50)), "on_fill {on_fill}: the other piece shows the gradient");
let mut own = Paint::solid(Color::from_hex("#00ff00").unwrap());
if let Paint::Solid { swatch, .. } = &mut own {
*swatch = Some("Leaf".into());
}
let mut kept = vec![];
let mut plugins = 0;
s.doc().unwrap().doc.walk(|n| {
plugins += n
.appearance
.effects
.iter()
.chain(n.appearance.items.iter().flat_map(|i| i.effects()))
.filter(|e| e.id.starts_with("plugin."))
.count();
if n.path_data().is_some_and(|p| p.bounds() == square_at(40.0).bounds()) {
kept.push(n.appearance.fill().map(|f| f.paint.clone()));
}
});
assert_eq!(kept, [Some(own)], "on_fill {on_fill}");
assert_eq!(plugins, 0, "on_fill {on_fill}: no live effect is left");
}
}
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
/// tiles expanded, and a piece with a paint of its own keeps that paint.
#[test]
fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tile = rect(&mut s, 300.0, "#00ff00");
run(&mut s, "object.pattern.make", json!({"ids": [tile.0], "name": "Tiles", "edit": false}));
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Tiles"}));
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}, {"path": square_at(100.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandtiles", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandtiles"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, BLUE), "the input is gone and the piece keeps its paint");
assert!(near(img.pixel(110, 50), GREEN), "the other piece shows the tiles");
let mut patterns = 0;
s.doc().unwrap().doc.walk(|n| patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count());
assert_eq!(patterns, 0, "the tiles are objects");
}
/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces
/// the first of them and the other fill stays over it.
#[test]
fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string();
let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two));
install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one");
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paints = vec![];
art.walk(&mut |n| {
if n.path_data().is_some() {
paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::<Vec<_>>());
}
});
let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap());
assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}");
}
/// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece
/// that keeps the fill, and the fill drawn on the moved shape after a refused result, both have the
/// gradient at its moved position.
#[test]
fn a_painted_effect_on_a_fill_keeps_a_placed_gradient_with_its_moved_shape() {
let mut gone = solid("#00ff00");
gone["swatch"] = json!("Not a swatch");
for (name, out, want) in [
("keeps", json!({"objects": [{"path": square_at(40.0)}]}).to_string(), square_at(40.0).bounds()),
("refused", json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(), square_at(110.0).bounds()),
] {
let mut s = session();
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 40, "width": 20, "height": 20}))["id"].as_u64().unwrap());
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear", "stops": stops}}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.setGradientGeom", json!({"start": [14, 50], "end": [26, 50], "stroke": false}));
let plugin = format!("test.engine.carry{name}");
install_wat(&mut s, &tpl::returning(&painting(&plugin, true), &out));
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "distort.transform", "params": {"moveH": 100}, "item": fill}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{plugin}"), "item": fill}));
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut seen = vec![];
art.walk(&mut |n| {
if let (Some(p), Some(Paint::Gradient(g))) = (n.path_data(), n.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint())) {
seen.push((p.bounds(), g.geom.map(|g| (g.start.x, g.end.x))));
}
});
assert_eq!(seen, [(want, Some((114.0, 126.0)))], "{name}");
}
}
/// The geometry of a painted result that names a swatch counts only when the document has that
/// swatch: without a document, and with one that lacks it, the effect keeps its input.
#[test]
fn painted_geometry_is_used_only_when_its_references_are_in_the_document() {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with};
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.geomref", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geomref"}));
let n = node(&s, id);
let path = n.path_data().unwrap().clone();
let input = path.bounds().unwrap();
let geometry = |doc: Option<&vectorcraft_doc::Document>| {
apply_geometry_with(&n.appearance.effects, &path, input, &GeomContext { doc, ..GeomContext::of(&n) }).bounds()
};
assert_eq!(geometry(None), Some(input), "no document");
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), Some(input), "no such swatch");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), square_at(40.0).bounds(), "the piece's geometry");
}
/// Type with a painted effect, in a group with a live Pathfinder effect: the operation takes the
/// piece linked to the document's swatch.
#[test]
fn pathfinder_takes_the_swatch_linked_pieces_of_type() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.typepaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [t.0], "effect": "plugin.test.engine.typepaint"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// Type whose fill has a painted effect, in a group with a geometry effect inside a live Pathfinder
/// effect: the operation takes the piece linked to the document's swatch.
#[test]
fn pathfinder_takes_swatch_linked_pieces_of_type_in_a_reshaped_group() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.memberpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.memberpaint", "item": 0}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "path.offsetPath", "params": {"offset": 0}}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// A painted effect on an image's own fill that links its piece to a swatch: hit tests and bounds
/// use the piece the canvas draws.
#[test]
fn an_image_s_painted_fill_is_hit_and_measured_as_drawn() {
let mut s = session();
let mut png = vec![];
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(120.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.imagepaint", true), &out));
run(&mut s, "select.set", json!({"ids": [im.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.imagepaint", "item": 0}));
assert_eq!(drawn(&s).pixel(130, 50), GREEN, "the canvas draws the piece");
let doc = &s.doc().unwrap().doc;
assert!(vectorcraft_doc::hit::hit_test(doc, Point::new(130.0, 50.0), Default::default()).is_some(), "a click on the piece hits the image");
let boxes = doc.symbol_boxes();
assert!(doc.node(im).unwrap().reach_bounds_with(&boxes).unwrap().x1 >= 140.0, "the bounds take in the piece");
}
/// A path with a painted effect takes part in a Pathfinder operation as its pieces, each with its
/// own paint and the path's opacity: Divide on a group holding it makes one face per piece,
/// painted as the piece is.
#[test]
fn a_path_with_a_painted_effect_takes_part_in_pathfinder_as_its_pieces() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0), "fills": [solid("#0000ff")]}]})
.to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaint"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
let img = drawn(&s);
assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, BLUE, WHITE), "one face per piece");
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
let st = s.doc().unwrap();
let hook = |c: &vectorcraft_doc::Node| vectorcraft_render::effects::outline_object(Some(&st.doc), c);
let faces = vectorcraft_render::effects::pathfinder_children(Some(&st.doc), st.doc.node(g).unwrap(), Some(&hook)).unwrap();
let faces: Vec<_> = faces.iter().map(|f| (f.appearance.fill().map(|f| f.paint.clone()), f.opacity)).collect();
let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap()));
assert_eq!(faces, [(fill("#0000ff"), 0.5), (fill("#00ff00"), 0.5)]);
}
/// Art a renderer keeps for an object with a painted effect is made again when a swatch its pieces
/// name leaves the document (the object is then drawn as if the effect weren't there) and when
/// Undo brings the swatch back.
#[test]
fn kept_art_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.keptpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.keptpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
run(&mut s, "edit.undo", json!({}));
assert_eq!(draw(&s), (GREEN, WHITE), "the swatch back, the piece again");
}
/// A piece linked to a global swatch shows the swatch's edited color, in art a renderer keeps and
/// in a kept raster of a blur over it, and its earlier color after Undo.
#[test]
fn a_piece_linked_to_a_global_swatch_takes_its_edited_color() {
for blur in [false, true] {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.globalleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.globalleaf"}));
if blur {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
}
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut at_piece = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(at_piece(&s), GREEN), "the piece, in the swatch's color (blur {blur})");
run(&mut s, "swatch.edit", json!({"name": "Leaf", "color": "#0000ff"}));
let px = at_piece(&s);
assert!(near(px, BLUE), "the swatch's new color (blur {blur}): {px:?}");
run(&mut s, "edit.undo", json!({}));
assert!(near(at_piece(&s), GREEN), "the swatch's color after Undo (blur {blur})");
}
}
/// Art a renderer keeps for an object whose plug-in starts to paint its pieces when it is replaced
/// follows the swatches those pieces name from then on.
#[test]
fn kept_art_follows_the_swatches_once_a_replaced_plugin_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", false), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterpaint"}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| {
let img = r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(img.pixel(50, 50), img.pixel(15, 15))
};
assert_eq!(draw(&s), (RED, WHITE), "the square in the object's paint");
install_wat(&mut s, &tpl::returning(&painting("test.engine.laterpaint", true), &leaf_square()));
assert_eq!(draw(&s), (GREEN, WHITE), "the piece, green");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
assert_eq!(draw(&s), (WHITE, RED), "without the swatch, the object as it is");
}
/// Whether `px` is within 40 levels of `want` on every channel. A blur leaves the middle of a
/// shape close to its color.
fn near(px: [u8; 4], want: [u8; 4]) -> bool {
px.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 40)
}
/// A kept raster of a blur over a painted effect is made again when a swatch its piece names
/// leaves the document (the object is then drawn as if the effect weren't there) and when Undo
/// brings the swatch back.
#[test]
fn a_kept_raster_follows_the_swatches_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.blurpaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.blurpaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the piece, green and blurred");
run(&mut s, "swatch.delete", json!({"name": "Leaf"}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), WHITE) && near(img.pixel(15, 15), RED), "without the swatch, the object as it is: {:?}", img.pixel(50, 50));
run(&mut s, "edit.undo", json!({}));
let img = draw(&s);
assert!(near(img.pixel(50, 50), GREEN) && near(img.pixel(15, 15), WHITE), "the swatch back, the piece again");
}
/// A kept raster of a blur over a painted effect is made again when the tile of a pattern its
/// piece names changes and the pattern keeps its name.
#[test]
fn a_kept_raster_follows_the_tiles_of_patterns_painted_pieces_name() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let green = rect(&mut s, 300.0, "#00ff00");
let blue = rect(&mut s, 320.0, "#0000ff");
let tile = |r: f64, art: vectorcraft_doc::Node| vectorcraft_doc::pattern::PatternDef {
tile: vectorcraft_geom::Rect::new(r, 10.0, r + 10.0, 20.0),
..vectorcraft_doc::pattern::PatternDef::new("Tiles", vec![std::sync::Arc::new(art)])
};
let (first, second) = (tile(300.0, node(&s, green)), tile(320.0, node(&s, blue)));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.push(first);
let paint = serde_json::to_value(Paint::Pattern { pattern: "Tiles".into(), xf: vectorcraft_geom::Affine::IDENTITY }).unwrap();
let out = json!({"objects": [{"path": square_at(40.0), "fills": [paint]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.tilepaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.tilepaint"}));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "blur.gaussian", "params": {"radius": 1}}));
let mut r = vectorcraft_render::Renderer::new();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut draw = |s: &Session| r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50);
assert!(near(draw(&s), GREEN), "the piece in the green tile");
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns[0] = second;
let px = draw(&s);
assert!(near(px, BLUE), "the piece in the new tile: {px:?}");
}
/// Every painted piece counts against the effects' budget, however small its path: two painted
/// effects in a stack that each return 350 tiny triangles make no more than 100,000 objects, not
/// 350 × 350 of them.
#[test]
fn painted_pieces_keep_to_the_effects_budget() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true));
let out = json!({"objects": vec![json!({"path": tiny}); 350]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.many", true), &out));
for _ in 0..2 {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.many"}));
}
let st = s.doc().unwrap();
let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap();
let mut paths = 0;
art.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
assert!((350..=100_000).contains(&paths), "{paths} paths");
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+209
View File
@@ -630,6 +630,215 @@ fn model_param_must_be_a_string_when_given() {
assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed");
}
/// Sets the Layers panel's lock and visibility of `ids` (objects or layers).
fn set_props(s: &mut Session, ids: &[NodeId], props: Value) {
let mut p = props;
p["ids"] = json!(ids.iter().map(|i| i.0).collect::<Vec<_>>());
run(s, "layer.setProps", p);
}
#[test]
fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() {
let mut s = session();
let open = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#ff0000"));
let both = rect(&mut s, json!("#ff0000"));
// A locked object the map leaves as it is was not left out of anything.
let other = rect(&mut s, json!("#00ff00"));
set_props(&mut s, &[locked, other], json!({"locked": true}));
set_props(&mut s, &[hidden], json!({"visible": false}));
set_props(&mut s, &[both], json!({"visible": false, "locked": true}));
// The objects of a locked layer count as locked; a template layer's art is not counted.
let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
let on_locked_layer = rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[layer], json!({"locked": true}));
let template = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[template], json!({"template": true}));
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 2}), "{r}");
assert_eq!(r["warning"], "Recolor completed; 2 locked objects and 2 hidden objects were left unchanged");
assert_eq!(fill(&s, open).to_hex(), "#0000ff");
for id in [locked, hidden, both, on_locked_layer] {
assert_eq!(fill(&s, id).to_hex(), "#ff0000", "locked and hidden art keeps its colors");
}
}
#[test]
fn recolor_of_everything_counts_locked_art_that_only_loses_its_group_swatch_link() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
let open = rect(&mut s, json!("#000000"));
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0, locked.0], "swatch": names[1]}));
set_props(&mut s, &[locked], json!({"locked": true}));
select_all(&mut s);
// The group is rewritten with one color, so its second swatch goes and the locked art only
// loses its link to it.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#123456"]}));
assert_eq!(fill(&s, open).to_hex(), "#00ff00");
assert_eq!(fill(&s, locked).to_hex(), "#ff0000", "the locked art keeps its color");
assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewrite_recolors() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
// "Dots": a tile linked to the group's second swatch.
let tile = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]}));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex();
assert_eq!(tile_hex(&s), "#ff0000");
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Dots"}));
set_props(&mut s, &[locked], json!({"locked": true}));
let open = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]}));
select_all(&mut s);
// The group's second swatch is rewritten with the row's new color, and the tile follows it.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]}));
assert_eq!(tile_hex(&s), "#00ff00", "{r}");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_the_group_rewrite_recolors() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]}));
let names: Vec<String> =
s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect();
for name in &names {
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
}
// "Dots": a tile linked to the group's second swatch; "Rings": a tile filled with "Dots".
let tile = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]}));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let outer = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"}));
run(&mut s, "select.set", json!({"ids": [outer.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40}));
run(&mut s, "object.pattern.done", json!({}));
let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex();
let locked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Rings"}));
set_props(&mut s, &[locked], json!({"locked": true}));
let open = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]}));
select_all(&mut s);
// The rewrite recolors the inner tile, which the locked art shows through "Rings".
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]}));
assert_eq!(tile_hex(&s), "#00ff00", "{r}");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true}));
// "Dots": an unlinked red tile.
let tile = rect(&mut s, json!("#ff0000"));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let open = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
let dots = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [dots.0], "swatch": "Dots"}));
set_props(&mut s, &[locked, dots], json!({"locked": true}));
select_all(&mut s);
// The row keeps the color and snaps it to the document's global swatch of the same color, so
// the open art only takes the link.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"}));
let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { swatch, .. } => swatch,
p => panic!("{p:?}"),
};
assert!(link(&s, open).is_some(), "{r}");
assert_eq!(fill(&s, locked).to_hex(), "#ff0000");
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_would_only_take_a_swatch_link() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true}));
// "Dots": an unlinked red tile; "Rings": a tile filled with "Dots".
let tile = rect(&mut s, json!("#ff0000"));
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let outer = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"}));
run(&mut s, "select.set", json!({"ids": [outer.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40}));
run(&mut s, "object.pattern.done", json!({}));
rect(&mut s, json!("#ff0000"));
let rings = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [rings.0], "swatch": "Rings"}));
set_props(&mut s, &[rings], json!({"locked": true}));
select_all(&mut s);
// The inner tile would only take the link to the document's red swatch.
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
#[test]
fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() {
let mut s = session();
let ids = [rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000"))];
select_all(&mut s);
run(&mut s, "object.group", json!({}));
set_props(&mut s, &ids[1..2], json!({"locked": true}));
set_props(&mut s, &ids[2..3], json!({"visible": false}));
let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(node(&s, ids[1]).locked && !node(&s, ids[2]).visible);
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
assert!(ids.iter().all(|id| fill(&s, *id).to_hex() == "#0000ff"));
}
#[test]
fn recolor_of_part_of_the_art_counts_nothing_left_out() {
let mut s = session();
let picked = rect(&mut s, json!("#ff0000"));
let locked = rect(&mut s, json!("#ff0000"));
rect(&mut s, json!("#ff0000"));
set_props(&mut s, &[locked], json!({"locked": true}));
run(&mut s, "select.set", json!({"ids": [picked.0]}));
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
// Everything selected, with nothing locked or hidden.
run(&mut s, "object.unlockAll", json!({}));
select_all(&mut s);
let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}}));
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}");
assert!(r.get("warning").is_none(), "{r}");
}
/// A row's own `model` takes the place of the command's for that row's colors; a bad one fails
/// before editing; `recolor.randomize` moves it with its new color.
#[test]
+27
View File
@@ -241,6 +241,33 @@ fn options_are_saved_with_the_preferences() {
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
/// arrows changed, in the saved options and the Corner Radius preference too.
#[test]
fn escape_on_a_shape_drag_saves_the_option_it_put_back() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
// Saves every option, the one the arrow changed included.
s.set_tool_option("length", &json!(50));
};
select(&mut s, "polygon");
drag(&mut s);
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6));
select(&mut s, "roundedRectangle");
let radius = s.prefs.corner_radius;
drag(&mut s);
assert_eq!(s.prefs.corner_radius, radius + 1.0);
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(s.prefs.corner_radius, radius);
}
/// The Fidelity an Alt smoothing drag uses on a selected path.
fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind};
+91 -7
View File
@@ -516,7 +516,7 @@ fn unbind_reports_what_it_actually_removed() {
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}");
}
/// Locked art is the document's to rewrite only while it isn't locked.
/// Locked type takes no new text variable binding, and a row leaves its text alone.
#[test]
fn locked_art_is_neither_bound_nor_rewritten() {
let mut s = session();
@@ -634,6 +634,90 @@ fn binding_errors_say_whether_a_layer_or_group_is_hidden_or_locked() {
assert!(e.contains(&format!("node {} is locked", t.0)), "{e}");
}
/// A visibility variable binds a locked layer and a locked object inside a locked group, and a row
/// hides and shows them in one undo step without unlocking them. List, Capture and Update Data Set
/// read whether they show.
#[test]
fn a_visibility_variable_shows_and_hides_locked_art() {
let mut s = session();
let badge = rect(&mut s);
let other = rect(&mut s);
let g = group(&mut s, &[badge, other]);
let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
rect(&mut s);
s.execute("object.setProps", &json!({"ids": [badge.0], "locked": true})).unwrap();
s.execute("object.setProps", &json!({"ids": [g.0], "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap();
s.execute("variable.define", &json!({"name": "Layer", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Layer", "ids": [layer.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [badge.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "Shown", "values": {"Layer": true, "Badge": true}})).unwrap();
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Layer": false, "Badge": false}})).unwrap();
let shows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().visible;
let locked = |s: &Session| [layer, g, badge].iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().locked);
let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}");
assert!(!shows(&s, layer) && !shows(&s, badge), "the row hides the locked layer and the locked object");
assert!(locked(&s), "the row leaves the locks as they were");
let sets = s.execute("dataset.list", &json!({})).unwrap();
let matching: Vec<&Value> = sets["datasets"].as_array().unwrap().iter().filter(|d| d["matches"] == json!(true)).map(|d| &d["name"]).collect();
assert_eq!(matching, [&json!("Hidden")], "List reads the locked art: {sets}");
assert_eq!(s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap()["values"], 2);
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][2]["values"], json!({"Badge": {"visible": false}, "Layer": {"visible": false}}), "{sets}");
// Undo puts back what the row hid.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(shows(&s, layer) && shows(&s, badge), "undo shows them again");
let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert_eq!(v["applied"], 2, "{v}");
let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}");
assert!(shows(&s, layer) && shows(&s, badge), "the row shows them");
assert!(locked(&s));
// Update writes the locked layer's visibility into the active row.
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap();
let v = s.execute("dataset.update", &json!({})).unwrap();
assert_eq!((v["values"].clone(), v["left"].clone()), (json!(2), json!([])), "{v}");
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"], json!({"Badge": {"visible": true}, "Layer": {"visible": false}}), "{sets}");
}
/// Text and graph variables refuse locked art and art in a locked layer. A visibility variable is
/// refused for art on a hidden layer that is also locked, and the error says the art is in a hidden
/// group or layer.
#[test]
fn locked_art_refuses_text_and_graph_variables_and_a_hidden_layer_refuses_visibility() {
let mut s = session();
let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("object.setProps", &json!({"ids": [r.0], "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Chart", "kind": "graph"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
let bind = |s: &mut Session, var: &str, id: NodeId| s.execute("variable.bind", &json!({"variable": var, "ids": [id.0]}));
let e = bind(&mut s, "Name", t).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is in a locked group or layer", t.0)), "{e}");
let e = bind(&mut s, "Chart", r).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is locked", r.0)), "{e}");
bind(&mut s, "Show", t).unwrap();
assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty());
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap();
let e = bind(&mut s, "Show", r).unwrap_err().to_string();
assert!(e.contains(&format!("node {} is in a hidden group or layer", r.0)), "{e}");
// The hidden layer's own visibility is what the variable drives.
bind(&mut s, "Show", layer).unwrap();
}
/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a
/// selection to a bound variable, named after what it drives.
#[test]
@@ -848,7 +932,7 @@ fn updating_writes_only_the_variables_the_row_holds() {
assert_eq!(sets["datasets"][0]["values"]["Client"], json!({"text": "Typed by hand"}), "{sets}");
}
/// A variable whose object is locked reports no value, so Update keeps the row's value for it
/// A text variable whose object is locked reports no value, so Update keeps the row's value for it
/// rather than dropping it from the row.
#[test]
fn updating_keeps_a_value_the_art_does_not_report() {
@@ -1201,8 +1285,8 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() {
}
/// A row selected while a layer is hidden rewrites the type on it and applies its other values,
/// and leaves the layer hidden: showing the layer shows that row's text. A layer that is locked
/// as well as hidden is still left alone.
/// and leaves the layer hidden: showing the layer shows that row's text. On a layer that is locked
/// as well as hidden, the type keeps its text and the row's visibility value applies.
#[test]
fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() {
let mut s = session();
@@ -1230,12 +1314,12 @@ fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() {
s.execute("layer.setProps", &json!({"id": layer.0, "visible": true})).unwrap();
assert_eq!(plain(&s, t), "Bob", "showing the layer shows the row's text");
// Locked and hidden: the row skips both objects and the type keeps its text.
// Locked and hidden: the row skips the type, which keeps its text, and shows the rectangle.
s.execute("layer.setProps", &json!({"id": layer.0, "visible": false, "locked": true})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(0), Some(2)), "{v}");
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)), "{v}");
assert_eq!(plain(&s, t), "Bob", "the type on the locked layer keeps its text");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row's visibility value applies on the locked layer");
}
/// Type inside a hidden group is rewritten too, and List and Capture Data Set read it.
+13
View File
@@ -1024,6 +1024,19 @@ fn lasso_picks_gradient_mesh_points() {
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
/// deselected and the others selected, the rest of the selection staying as it was.
#[test]
fn a_selection_tool_click_gives_new_art_the_objects_fill() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
rect(&mut s, 200.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "selection", &[(125.0, 125.0), (125.0, 125.0)], Mods::default());
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.paint.fill, s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint());
}
#[test]
fn selection_shift_marquee_toggles_objects() {
let mut s = session();
+76 -13
View File
@@ -402,6 +402,25 @@ impl Session {
self.tool_panic.take().map_or(Ok(()), Err)
}
/// The active document (its uid) and its [`crate::History::step`].
fn history_step(&self) -> Option<(u64, (u64, u64))> {
self.active().map(|d| (d.uid, d.history.step()))
}
/// Tell the active tool when the history moved, or another document became active, since it
/// last handled a pointer event or was last told ([`vectorcraft_tools::Tool::history_moved`]).
/// A lent selection tool is not told.
fn tell_tool_history_moved(&mut self) {
if self.lender.is_some() {
return;
}
let now = self.history_step();
if self.tool_step.is_some_and(|seen| Some(seen) != now) {
self.tool.history_moved();
}
self.tool_step = now;
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.pointer_event(ev, view, true)
@@ -495,7 +514,9 @@ impl Session {
return r;
}
self.leave_draw_inside(ev, view)?;
self.tell_tool_history_moved();
let ev = &self.lend_selection_tool(ev);
let lent = self.lender.is_some();
let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
if !keep {
acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_)));
@@ -507,6 +528,11 @@ impl Session {
}
self.apply_actions(acts)
});
// The tool's own edits do not move the history for it. A selection tool that Cmd lends
// edits for the lender, which hears of the edit at its next event.
if !lent {
self.tool_step = self.history_step();
}
match ev.kind {
PointerKind::Down => self.press_point = Some(self.freeform_point),
PointerKind::Up => self.press_point = None,
@@ -740,6 +766,11 @@ impl Session {
self.tool.transforming()
}
/// Is the active tool resizing the selection by a bounding-box handle?
pub fn tool_resizing(&self) -> bool {
self.tool.resizing()
}
/// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection
/// so far: the document moves on release, and the bounding box shows at this offset meanwhile.
pub fn deferred_move(&self) -> Option<vectorcraft_geom::Vec2> {
@@ -764,6 +795,8 @@ impl Session {
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
// The Blend tool's first pick is not marked after an edit that dropped it.
self.tell_tool_history_moved();
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
// The Type tools draw the ports of selected type themselves.
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
@@ -1059,25 +1092,48 @@ impl Session {
Ok(ui)
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched, then put the document back at the interaction snapshot.
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) {
return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default();
/// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the
/// pending preview of a handle resize leaves it, measured once per preview step; `None` when no
/// such preview is pending, or when the preview fails.
pub fn deferred_transform_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
if !self.tool.resizing() {
return None;
}
let (cmd, p) = self.unapplied_interaction_preview()?;
self.deferred_feedback(&cmd, &p)?.transform_box
}
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default()
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched (and, during a handle resize, their transform box), then
/// put the document back at the interaction snapshot.
fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> {
if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) {
let feedback = self.measure_deferred_preview(cmd, p)?;
self.deferred_overlay_cache = Some(feedback);
}
self.deferred_overlay_cache.as_ref()
}
fn measure_deferred_preview(&mut self, cmd: &str, p: &Value) -> Option<DeferredFeedback> {
let (snapshot, selection, revision, paint, interaction_fields) = {
let Some(st) = self.active() else { return vec![] };
let Some(it) = st.interaction.as_ref() else { return vec![] };
let st = self.active()?;
let it = st.interaction.as_ref()?;
if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) {
return vec![];
return None;
}
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone());
(st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields)
};
let overlays = if self.preview(cmd, p).is_ok() {
self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default()
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default();
let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default();
(overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None })
} else {
vec![]
(vec![], None)
};
if let Ok(st) = self.doc_mut() {
st.doc = snapshot;
@@ -1090,11 +1146,18 @@ impl Session {
}
}
self.pending_paint = paint;
self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone()));
overlays
Some(DeferredFeedback { cmd: cmd.to_string(), params: p.clone(), overlays, transform_box })
}
}
/// What the canvas shows for a deferred preview: see [`Session::deferred_transform_box`].
pub(crate) struct DeferredFeedback {
cmd: String,
params: Value,
overlays: Vec<Overlay>,
transform_box: Option<vectorcraft_doc::OrientedBox>,
}
/// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the
/// canvas's limit on the anchor points it outlines (`OVERLAY_MAX_ANCHORS` in
/// `crates/ui-egui/src/canvas/outline.rs`). Past it the selection's bounds are drawn instead.
+36
View File
@@ -347,6 +347,42 @@ fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
}
}
/// With a working RGB space other than sRGB, RGB freeform points mix in that space on the canvas
/// and in a writer, as Object > Expand mixes them.
#[test]
fn freeform_rgb_points_mix_in_the_working_rgb_space() {
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Point, Rect, shapes};
let _g = settings();
let old = cms::active_settings();
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..old.clone() }).unwrap();
let box_ = Rect::new(0.0, 0.0, 100.0, 100.0);
let (a, b) = (Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.2, 0.9));
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
let f = Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(f.clone());
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
d.insert(Some(d.layers[0].id), 0, Node::path(id, shapes::rectangle(box_), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)))
.unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let canvas = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixel(50, 50);
let svg = vectorcraft_svg::export_full(&d, &vectorcraft_svg::ExportOptions::default(), None).svg;
let data = svg.split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
let png = image::load_from_memory(&vectorcraft_testkit::format::base64_decode(data).unwrap()).unwrap().to_rgba8();
let written = png.get_pixel(png.width() / 2, png.height() / 2).0;
let want = Color::rgb(0.5, 0.15, 0.5).to_rgba8(1.0);
let display = f.field(spread_scale(box_)).sample(Point::new(50.5, 50.5)).0.map(|v| (v * 255.0).round() as u8);
cms::set_active(&old).unwrap();
let near = |got: [u8; 4]| got.iter().zip(want).take(3).all(|(g, w)| g.abs_diff(w) <= 3);
assert!(near(canvas), "canvas: {canvas:?} vs {want:?}");
assert!(near(written), "SVG: {written:?} vs {want:?}");
assert!(display.iter().zip(want).any(|(g, w)| g.abs_diff(w) > 8), "the display mix differs: {display:?} vs {want:?}");
}
/// Loading a new file of the working RGB profile (named after its description, as before) takes
/// effect at once: CMYK colors convert into the new file and the canvas redraws, without choosing
/// the working spaces again.
+17 -1
View File
@@ -1,5 +1,5 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
@@ -1743,6 +1743,22 @@ fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes()));
psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30]));
out.push(("a.psb", psb));
// JPEG XL: an 8-bit RGBA codestream, and a 16-bit gray one in the container.
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
let jxl = |space, depth, samples: &[u8]| {
let mut b = vec![];
zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(5, 3, space, depth)).encode(&mut b).unwrap();
b
};
out.push(("a.jxl", jxl(ColorSpace::RGBA, BitDepth::Eight, &(0..60).map(|i| (i * 17) as u8).collect::<Vec<_>>())));
let gray = jxl(ColorSpace::Luma, BitDepth::Sixteen, &(0..30).map(|i| (i * 41) as u8).collect::<Vec<_>>());
let mut boxed = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a];
boxed.extend(20u32.to_be_bytes());
boxed.extend(b"ftypjxl \0\0\0\0jxl ");
boxed.extend((8 + gray.len() as u32).to_be_bytes());
boxed.extend(b"jxlc");
boxed.extend(gray);
out.push(("b.jxl", boxed));
out
}
+106 -16
View File
@@ -15,7 +15,7 @@ use proptest::prelude::*;
use serde_json::{Map, Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
use vectorcraft_plugins::{Error, Limits, Plugin};
use vectorcraft_plugins::{Error, Kind, Limits, Plugin};
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
@@ -67,6 +67,29 @@ fn arb_junk() -> impl Strategy<Value = Value> {
})
}
/// One entry of a manifest's `extensions`: valid, dotted, mixed-case, empty or too long, or junk.
fn arb_extension() -> impl Strategy<Value = Value> {
prop_oneof![
4 => "[a-z0-9]{1,16}".prop_map(Value::from),
1 => prop::sample::select(vec![".fz", "FZ", "fZ", "a b", "", "x.y", "é", "a\u{202E}", "abcdefghijklmnopq"]).prop_map(Value::from),
1 => "[a-z0-9]{17,32}".prop_map(Value::from),
1 => arb_junk(),
]
}
/// A manifest's `extensions`: `None` leaves the key out; otherwise an empty list, up to the eight
/// allowed, too many, one repeated, or junk.
fn arb_extensions() -> impl Strategy<Value = Option<Value>> {
prop_oneof![
1 => Just(None),
1 => Just(Some(json!([]))),
4 => prop::collection::vec(arb_extension(), 1..=8).prop_map(|l| Some(Value::Array(l))),
1 => prop::collection::vec(arb_extension(), 9..=12).prop_map(|l| Some(Value::Array(l))),
1 => "[a-z0-9]{1,8}".prop_map(|e| Some(json!([e, "fz", e]))),
1 => arb_junk().prop_map(Some),
]
}
/// Numbers that tend to break arithmetic.
fn arb_num() -> impl Strategy<Value = Value> {
prop_oneof![
@@ -512,6 +535,50 @@ proptest! {
renders(&s, &out)?;
}
/// A live effect that declares `paintOutput` returning hostile pieces, on objects or on a fill:
/// drawing, export and Expand Appearance never crash.
#[test]
fn hostile_painted_effect_output_never_crashes(
objects in prop::collection::vec(arb_object(), 0..5),
junk in prop::option::weighted(0.1, arb_junk()),
on_fill in any::<bool>(),
) {
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let out = output(&objects, &[], junk);
let manifest = r#"{"id":"fuzz.painted","name":"Fuzz","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#;
install(&mut s, &wat::parse_str(tpl::returning(manifest, &out)).unwrap()).unwrap();
let item = if on_fill { json!(0) } else { Value::Null };
let r = catch_quiet(|| s.execute("effect.apply", &json!({"effect": "plugin.fuzz.painted", "item": item})).map(|_| ()));
if let Err(msg) = r {
return Err(TestCaseError::fail(format!("panicked on {out}: {msg}")));
}
renders(&s, &out)?;
let r = catch_quiet(|| s.execute("effect.expandAppearance", &json!({})).map(|_| ()));
if let Err(msg) = r {
return Err(TestCaseError::fail(format!("expanding panicked on {out}: {msg}")));
}
renders(&s, &out)?;
}
/// An importer's hostile output (`document.open`) either opens a document that renders and
/// exports, or is an error that opens nothing.
#[test]
fn hostile_importer_output_never_crashes(
objects in prop::collection::vec(arb_object(), 0..5),
junk in prop::option::weighted(0.1, arb_junk()),
) {
let mut s = Session::new();
let out = output(&objects, &[], junk);
install(&mut s, &wat::parse_str(tpl::returning(&tpl::importer("fuzz.importer", r#"["fzi"]"#, "{}"), &out)).unwrap()).unwrap();
let r = catch_quiet(|| s.execute("document.open", &json!({"name": "x.fzi", "dataBase64": ""})).map(|_| ()));
match r {
Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))),
Ok(Err(_)) => prop_assert!(s.documents().is_empty(), "a failed open opened a document: {}", out),
Ok(Ok(())) => renders(&s, &out)?,
}
}
#[test]
fn bulk_memory_plugins_never_crash(case in arb_bulk_case(), fuel in arb_fuel()) {
let r = catch_quiet(|| run_bulk(case, fuel)).map_err(|msg| TestCaseError::fail(format!("panicked on {case:?}: {msg}")))?;
@@ -566,17 +633,18 @@ proptest! {
renders(&s, "after mutated modules")?;
}
/// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated
/// permissions, junk of every type, control and bidirectional override characters) parse to
/// an error or to a manifest whose permissions fit its kind, and installing one with a junk
/// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
/// Manifests with hostile `permissions`, `api`, names and `extensions` (unknown, reserved and
/// repeated permissions, junk of every type, control and bidirectional override characters,
/// extensions missing, dotted, mixed-case, repeated, too long or too many) parse to an error
/// or to a manifest whose permissions and extensions fit its kind, and installing one with a
/// junk `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
/// character from the name.
#[test]
fn hostile_manifest_permissions_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]),
names in prop::collection::vec(
prop_oneof![
prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]),
prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress", "ui.panel", ""]),
Just("doc.read\u{202E}"),
Just("\u{0007}"),
],
@@ -585,21 +653,43 @@ proptest! {
permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]),
api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 1 => arb_junk()],
name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"],
grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "progress"]))],
grant in prop_oneof![
Just(Value::Null),
arb_junk(),
Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress"]))
],
extensions in prop::option::weighted(0.5, arb_extensions()),
) {
let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
// Hostile extensions come with a valid `api` and no `permissions`, so the parse reaches
// them. The other half of the cases keep a valid list and reach the other fields.
let hostile_extensions = extensions.is_some();
match extensions {
None if matches!(kind, "exporter" | "importer") => m["extensions"] = json!(["fz"]),
None | Some(None) => {}
Some(Some(hostile)) => m["extensions"] = hostile,
}
if hostile_extensions {
m["api"] = json!(1);
} else {
m["permissions"] = match permissions {
Some(Value::Null) | None => json!(names),
Some(junk) => junk,
};
}
m["permissions"] = match permissions {
Some(Value::Null) | None => json!(names),
Some(junk) => junk,
};
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind, !man.commands.is_empty())), "{}", text);
let exts = &man.extensions;
if matches!(man.kind, Kind::Exporter | Kind::Importer) {
prop_assert!((1..=8).contains(&exts.len()), "{}", text);
prop_assert!(exts.iter().all(|e| (1..=16).contains(&e.len()) && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())), "{}", text);
prop_assert!(exts.iter().enumerate().all(|(i, e)| !exts[..i].contains(e)), "{}", text);
} else {
prop_assert!(exts.is_empty(), "{}", text);
}
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
@@ -624,7 +714,7 @@ proptest! {
/// error or an edit, never a panic, and the document then renders and exports.
#[test]
fn hostile_manifest_commands_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]),
entries in prop::collection::vec(
prop_oneof![
3 => (
@@ -655,7 +745,7 @@ proptest! {
given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()],
) {
let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind});
if kind == "exporter" {
if matches!(kind, "exporter" | "importer") {
m["extensions"] = json!(["fz"]);
}
m["commands"] = junk.unwrap_or(json!(entries));
+409
View File
@@ -0,0 +1,409 @@
//! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the
//! formats they add to `document.formats` and `document.open`, which files they are given, the
//! document their objects become, and what happens when they fail, decline or are canceled.
//!
//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the
//! exact list of formats would see these ones.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::{EngineError, Session};
use vectorcraft_plugins::wat::{self as tpl, Given};
use vectorcraft_plugins::{Limits, Plugin};
/// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning.
const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#;
/// An importer's output: a horizontal line from (0, 5) to (50, 5).
const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#;
/// The `vc_run` body of an importer that never returns.
const SPINS: &str = "(loop $l (br $l)) (i64.const 0)";
/// Installs the plug-in written in WAT `src` → its `plugin.list` entry.
fn install(s: &mut Session, src: &str) -> Value {
let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}"));
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap()
}
/// An importer `id` reading `extensions` (a JSON list) whose objects are the file it is given.
fn echo(s: &mut Session, id: &str, extensions: &str) -> Value {
install(s, &tpl::module(&tpl::importer(id, extensions, "{}"), tpl::ECHO, "", 1))
}
/// `document.open` of a file `name` holding `bytes`, with more params `extra`.
fn open(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Result<Value, EngineError> {
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
s.execute("document.open", &p)
}
/// The active document's first artboard, as `(x0, y0, x1, y1)`.
fn artboard(s: &Session) -> (f64, f64, f64, f64) {
let r = s.doc().unwrap().doc.artboards[0].rect;
(r.x0, r.y0, r.x1, r.y1)
}
/// How many objects the active document's first layer holds.
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().unwrap().len()
}
/// The open documents' count, the active one's index, its undo and redo steps, whether it has
/// unsaved changes, and its content.
fn state(s: &mut Session) -> (usize, Option<usize>, usize, usize, bool, Value) {
let content = s.execute("document.inspect", &json!({})).unwrap();
let d = s.doc().unwrap();
(s.documents().len(), s.active_index(), d.history.undo.len(), d.history.redo.len(), d.is_dirty(), content)
}
/// Opens a document through a new importer `id`, with an undo step and a redo step → its [`state`].
fn open_edited(s: &mut Session, id: &str) -> (usize, Option<usize>, usize, usize, bool, Value) {
install(s, &tpl::returning(&tpl::importer(id, r#"["tsk"]"#, "{}"), RECT));
open(s, "Kept.tsk", b"?", json!({"importer": format!("plugin.{id}")})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let before = state(s);
assert!(before.2 > 0 && before.3 > 0, "{before:?}");
before
}
/// A fresh folder for files this test writes.
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn path_of(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn an_importer_adds_a_read_only_format_while_it_is_installed() {
let mut s = Session::new();
let entry = echo(&mut s, "test.list", r#"["tsl","tsl2"]"#);
assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("importer"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"])));
assert_eq!(entry["permissions"], json!(["import.read"]));
let formats = s.execute("document.formats", &json!({})).unwrap();
let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed");
assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"])));
assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(true), &json!(false), &json!("test.list")));
assert!(formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(formats["openExtensions"].as_array().unwrap().contains(&json!("tsl2")));
assert!(fileio::open_extensions().contains(&"tsl"));
assert!(!fileio::export_extensions().contains(&"tsl"));
assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case");
s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap();
let formats = s.execute("document.formats", &json!({})).unwrap();
assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["openExtensions"].as_array().unwrap().contains(&json!("tsl")));
assert!(fileio::format("tsl").is_none());
assert!(open(&mut s, "a.tsl", RECT.as_bytes(), json!({})).is_err());
assert!(s.documents().is_empty());
}
/// A file of an extension only the importer lists opens as a new document of editable art, its
/// artboard the art's bounds, with the importer's warnings; Save then asks for a name.
#[test]
fn a_file_an_importer_reads_opens_as_editable_art() {
let dir = tmp_dir("open");
let mut s = Session::new();
echo(&mut s, "test.open", r#"["tso"]"#);
let r = open(&mut s, "Shapes.tso", RECT.as_bytes(), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"], &r["restored"]), (&json!("plugin.test.open"), &json!(["2 layers merged"]), &json!(false)));
assert_eq!(s.documents().len(), 1);
assert_eq!(objects(&s), 1);
assert_eq!(artboard(&s), (10.0, 20.0, 110.0, 70.0));
// Editable: the rectangle moves like any path.
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
// A line's artboard is at least 1 pt high.
open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap();
assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5));
// Art at the edge of the canvas keeps its artboard within ±4,000,000 pt, so the document can
// still be edited: a vertical line at x = 4,000,000 (its 1 pt moves inward) and a stroked line
// along y = -4,000,000 (its stroke passes the edge).
let edge = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[4000000,0]},{"p":[4000000,50]}]}]}}]}"#;
open(&mut s, "Edge.tso", edge.as_bytes(), json!({})).unwrap();
assert_eq!(artboard(&s), (3_999_999.0, 0.0, 4_000_000.0, 50.0));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap();
let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#;
open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap();
let (x0, y0, x1, y1) = artboard(&s);
assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
// From a path: the document keeps no path, so Save asks for a name.
let path = dir.join("Shapes.tso");
std::fs::write(&path, RECT).unwrap();
let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap();
assert_eq!(r["format"], "plugin.test.open");
assert_eq!(s.doc().unwrap().path, None);
assert!(s.doc().unwrap().doc.title.contains("Shapes"), "{}", s.doc().unwrap().doc.title);
let _ = std::fs::remove_dir_all(&dir);
}
/// A built-in format keeps its files: an importer that lists `svg` is given an `.svg` only when
/// `importer` names it, and a file with a built-in format's content opens with the built-in reader
/// whatever its extension.
#[test]
fn built_in_formats_keep_their_files() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.svgtoo", r#"["tsv","svg"]"#, "{}"), RECT));
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="10" height="10"/></svg>"#;
assert_eq!(open(&mut s, "a.svg", svg, json!({})).unwrap()["format"], "svg");
// A damaged SVG reports the SVG reader's error; the importer isn't tried instead.
match open(&mut s, "b.svg", b"<svg not closed", json!({})) {
Ok(r) => assert_eq!(r["format"], "svg"),
Err(e) => assert!(!e.to_string().contains("importer"), "{e}"),
}
// Named, the importer opens an .svg.
let r = open(&mut s, "c.svg", svg, json!({"importer": "plugin.test.svgtoo"})).unwrap();
assert_eq!(r["format"], "plugin.test.svgtoo");
// An SVG named as the importer's file is still an SVG.
assert_eq!(open(&mut s, "d.tsv", svg, json!({})).unwrap()["format"], "svg");
assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg"));
assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1);
}
/// File › Open lists an importer's extensions only where it would open the file with the importer:
/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a
/// script, presets or a library. An importer with none of its own gets no filter.
#[test]
fn file_open_lists_only_the_extensions_it_opens_with_an_importer() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT));
install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT));
for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] {
assert!(fileio::opens_as_other(ext), "{ext}");
assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}");
}
assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg"));
assert!(fileio::open_extensions().contains(&"tcl"));
let filters: Vec<_> = fileio::open_filters().collect();
assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}");
assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}");
assert!(filters.contains(&("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS)), "{filters:?}");
s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap();
s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap();
}
/// Several importers list one extension: the one whose id sorts first opens the file, and
/// `importer` picks another (by its format id or its id).
#[test]
fn the_first_importer_by_id_opens_a_shared_extension() {
let mut s = Session::new();
install(&mut s, &tpl::returning(&tpl::importer("test.shared.b", r#"["tsh"]"#, "{}"), LINE));
install(&mut s, &tpl::returning(&tpl::importer("test.shared.a", r#"["tsh"]"#, "{}"), RECT));
assert_eq!(open(&mut s, "x.tsh", b"?", json!({})).unwrap()["format"], "plugin.test.shared.a");
assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "plugin.test.shared.b"})).unwrap()["format"], "plugin.test.shared.b");
assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "test.shared.b"})).unwrap()["format"], "plugin.test.shared.b");
}
#[test]
fn importer_names_that_do_not_fit_are_errors() {
let mut s = Session::new();
echo(&mut s, "test.names", r#"["tsn"]"#);
install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#));
install(&mut s, &tpl::returning(&tpl::exporter("test.names.exporter", r#"["tsx"]"#, "{}"), r#"{"dataBase64":""}"#));
let cases = [
(json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"),
(json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a plug-in of kind `filter`, not an importer"),
(json!({"importer": "plugin.test.names.exporter"}), "x.tsn", "is a plug-in of kind `exporter`, not an importer"),
(json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"),
(json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"),
];
for (extra, name, why) in cases {
let e = open(&mut s, name, RECT.as_bytes(), extra.clone()).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{extra}: {e}");
assert!(e.to_string().contains(why), "{extra}: {e}");
}
assert!(s.documents().is_empty());
// Without an importer, a file nothing reads stays unsupported.
assert!(open(&mut s, "x.tsunknown", RECT.as_bytes(), json!({})).is_err());
// An importer is not a filter.
s.execute("file.new", &json!({})).unwrap();
let e = s.execute("plugin.run", &json!({"id": "test.names"})).unwrap_err();
assert!(e.to_string().contains("document.open"), "{e}");
}
/// The importer is given its parameters, checked against its manifest, and `_context` with the
/// file's extension and name.
#[test]
fn the_importer_is_given_its_parameters_and_the_file_s_name() {
let mut s = Session::new();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#];
for (i, needle) in needles.into_iter().enumerate() {
// RECT when the parameters hold the needle; the file, which isn't JSON, otherwise.
let wasm = wat::parse_str(tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)).unwrap();
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": i > 0})).unwrap();
let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}}));
assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}");
}
let before = s.documents().len();
let e = open(&mut s, "Shapes.tsc", b"", json!({"params": {"density": "lots"}})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert_eq!(s.documents().len(), before);
}
#[test]
fn failures_open_nothing() {
let mut s = Session::new();
let far = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[9000000,0]}]}]}}]}"#;
let bad_warnings = LINE.replace("]}]}}]}", r#"]}]}}],"warnings":"no"}"#);
let cases = [
("test.fail.declines", r#"{"error":"Not a drawing."}"#, "Not a drawing."),
("test.fail.notjson", "not json", "JSON"),
("test.fail.empty", r#"{"objects":[]}"#, "the importer returned no objects"),
("test.fail.key", r#"{"objects":[],"layers":[]}"#, "an importer returns `objects` and `warnings`, not `layers`"),
("test.fail.id", r#"{"objects":[{"id":3}]}"#, "is not the id of an input object"),
("test.fail.far", far, "4000000"),
("test.fail.warnings", bad_warnings.as_str(), "`warnings` must be a list of strings"),
];
for (id, output, why) in cases {
install(&mut s, &tpl::returning(&tpl::importer(id, r#"["tsf"]"#, "{}"), output));
let e = open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert!(e.to_string().contains("x.tsf"), "{id}: names the file: {e}");
}
install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1));
assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err());
assert!(s.documents().is_empty(), "nothing opened");
// A document that is already open keeps its tab, content and undo and redo steps.
install(&mut s, &tpl::module(&tpl::importer("test.fail.spins", r#"["tsf"]"#, "{}"), SPINS, "", 1));
let before = open_edited(&mut s, "test.fail.opens");
for id in cases.iter().map(|c| c.0).chain(["test.fail.traps"]) {
assert!(open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).is_err(), "{id}");
assert_eq!(state(&mut s), before, "{id}");
}
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.spins"})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert_eq!(state(&mut s), before, "canceled");
}
/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that
/// never returns, run with a token canceled beforehand.
#[test]
fn a_canceled_open_stops_the_importer() {
let mut s = Session::new();
install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), SPINS, "", 1));
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert!(s.documents().is_empty());
}
/// An importer that never returns, held to a small import budget, ends at its instruction limit
/// or at its time limit (0 ms, which has passed at the first check between two slices of the
/// budget). The open reports the limit, opens no document, and leaves an open document's content
/// and undo and redo steps as they were.
#[test]
fn an_importer_past_its_budget_opens_nothing() {
let cases = [
("test.budget.fuel", Limits { export_fuel: 1_000_000, ..Limits::default() }, "instruction budget"),
("test.budget.time", Limits { export_fuel: 1_000_000_000, export_wall_time_ms: 0, ..Limits::default() }, "time budget"),
];
for (id, limits, _) in &cases {
let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap();
vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap());
}
let mut s = Session::new();
for (id, _, why) in &cases {
let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why) && e.to_string().contains("x.tbg"), "{id}: {e}");
assert!(s.documents().is_empty(), "{id}");
}
let before = open_edited(&mut s, "test.budget.opens");
for (id, _, why) in &cases {
let e = open(&mut s, "x.tbg", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert_eq!(state(&mut s), before, "{id}");
}
for (id, _, _) in &cases {
vectorcraft_plugins::registry::remove(id);
}
}
/// A file past the importer's input limit is not read past it, and doesn't open: an open document
/// keeps its content and its undo and redo steps. Given as data, it is refused the same way.
#[test]
fn a_file_past_the_input_limit_is_refused() {
let dir = tmp_dir("large");
let mut s = Session::new();
echo(&mut s, "test.large", r#"["tsb"]"#);
let path = dir.join("big.tsb");
let max = vectorcraft_plugins::import::MAX_INPUT_BYTES;
std::fs::File::create(&path).unwrap().set_len(max as u64 + 1).unwrap();
let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert!(e.to_string().contains("big.tsb"), "{e}");
assert!(s.documents().is_empty());
let before = open_edited(&mut s, "test.large.opens");
let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert_eq!(state(&mut s), before, "by path");
let e = open(&mut s, "big.tsb", &vec![0; max + 1], json!({})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert_eq!(state(&mut s), before, "as data");
// The size is checked before the text is decoded: this text is not base64 at all.
let not_base64 = "!".repeat((max + 1) / 3 * 4 + 8);
for extra in [json!({}), json!({"importer": "plugin.test.large"})] {
let mut p = json!({"name": "big.tsb", "dataBase64": not_base64});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let e = s.execute("document.open", &p).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert_eq!(state(&mut s), before, "before decoding");
}
// White space is not part of the file's size.
let spaced = format!("QUJD{}", " ".repeat(max / 3 * 4 + 8));
if let Err(e) = s.execute("document.open", &json!({"name": "small.tsb", "dataBase64": spaced})) {
assert!(!e.to_string().contains("larger than"), "{e}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A file past the importer's input limit whose start shows a built-in format opens as that format,
/// whatever its extension; named, the importer's limit applies to it.
#[test]
fn a_built_in_file_past_the_input_limit_still_opens() {
let dir = tmp_dir("largebuiltin");
let mut s = Session::new();
echo(&mut s, "test.largepng", r#"["tsp"]"#);
let path = dir.join("big.tsp");
let mut png = std::io::Cursor::new(vec![]);
image::RgbaImage::new(2, 2).write_to(&mut png, image::ImageFormat::Png).unwrap();
std::fs::write(&path, png.get_ref()).unwrap();
std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap();
let e = s.execute("document.open", &json!({"path": path_of(&path), "importer": "plugin.test.largepng"})).unwrap_err();
assert!(e.to_string().contains("larger than"), "{e}");
assert!(s.documents().is_empty());
let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap();
assert_eq!(r["format"], "png");
let mut data = png.into_inner();
data.resize((vectorcraft_plugins::import::MAX_INPUT_BYTES + 3) / 3 * 3, 0);
let encoded = json!({"name": "big.tsp", "dataBase64": vectorcraft_format::base64_encode(&data)});
assert_eq!(s.execute("document.open", &encoded).unwrap()["format"], "png");
let _ = std::fs::remove_dir_all(&dir);
}
+8 -1
View File
@@ -169,7 +169,8 @@
//! layers and opacity masks nested too deep, and, on objects written without an art style, live
//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to
//! graphic styles on objects not written with them, and every art style on a large canvas, are left
//! out.
//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads),
//! counted as the reader counts them, is left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
@@ -242,6 +243,11 @@ pub struct AiEditingOutput {
/// Write `doc` as the editing data of a `.ai` file: every artboard and layer.
pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditingOutput, String> {
export_within(doc, o, crate::import::MAX_EDITING_OBJECTS)
}
/// [`export_ai_editing`] writing at most `max_objects` objects, counted as the reader counts them.
fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Result<AiEditingOutput, String> {
let board = doc.artboards.first().ok_or("the document has no artboard")?.rect;
if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) {
return Err("the document has no artboard".into());
@@ -283,6 +289,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
// On a large canvas the layers' symbol instances are written as their art.
w.symbols_live = scale == 1.0;
w.symbol_doc = symbol_doc;
w.max_objects = max_objects;
w.reserve_swatch_gradients();
if scale == 1.0 {
w.styled = styled;
+172
View File
@@ -833,6 +833,178 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() {
assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written");
}
/// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound
/// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks,
/// gradients, pattern definitions and tiles, images, type, symbol definitions and instances,
/// objects with art styles and objects bound to variables.
fn counted() -> Vec<(&'static str, Document)> {
vec![
("sample", sample()),
("unsupported", unsupported()),
("transparent", transparent()),
("gradients and patterns", gradients_and_patterns()),
("images", images_doc()),
("typed", typed()),
("variables", variables_doc()),
("symbols", symbols_document()),
("styles", styles_document()),
("painted clips", painted_clips()),
("blend", blend_doc()),
]
}
/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps.
fn blend_doc() -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let keys = vec![
Arc::new(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5))),
Arc::new(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5))),
];
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() };
let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) });
doc.insert(Some(layer), usize::MAX, blend).unwrap();
doc
}
/// Clipping groups whose clipping paths are filled: a square with a color, and a ring (a path of
/// two subpaths) with a color, with a gradient, and with a color and a gradient stroke.
fn painted_clips() -> Document {
let mut doc = Document::new(260.0, 100.0);
let layer = doc.layers[0].id;
let ring = |x: f64| {
let mut p = shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0));
p.subpaths.extend(shapes::rectangle(Rect::new(x + 5.0, 15.0, x + 15.0, 25.0)).subpaths);
p
};
let g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))],
);
let shade = || Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
let clips = [
(10.0, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None),
(70.0, ring(70.0), solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None),
(130.0, ring(130.0), shade(), Paint::None),
(190.0, ring(190.0), solid(Color::rgb(0.0, 1.0, 0.0)), shade()),
];
for (x, path, fill, stroke) in clips {
let mut clip = clipping(&mut doc, path);
let width = if matches!(stroke, Paint::None) { 0.0 } else { 2.0 };
clip.appearance = Appearance::basic(fill, stroke, width).into();
let inside = square(&mut doc, Rect::new(x - 5.0, 5.0, x + 10.0, 20.0), Color::gray(0.5));
let g = group(&mut doc, vec![clip, inside], true);
doc.insert(Some(layer), usize::MAX, g).unwrap();
}
doc
}
/// The writer counts the objects it writes as the reader counts the objects it reads: with room
/// for as many objects as the reader reads, nothing is left out; with one less, art is left out,
/// with a warning. With room for fewer, the reader never reads more objects than there is room for.
#[test]
fn the_writer_counts_objects_as_the_reader_reads_them() {
let objects = |out: &AiEditingOutput| crate::import::objects_read(&out.program).unwrap();
let limited = |out: &AiEditingOutput| out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT);
for (name, doc) in counted() {
let all = export_ai_editing(&doc, &opts()).unwrap();
let read = objects(&all);
assert!(read > 0 && !limited(&all), "{name}: {read} objects, {:?}", all.warnings);
let room = export_within(&doc, &opts(), read).unwrap();
assert!(!limited(&room), "{name}: nothing is left out with room for {read} objects");
assert_eq!(room.program, all.program, "{name}");
for max in 0..read {
let out = export_within(&doc, &opts(), max).unwrap();
assert!(limited(&out), "{name}: art is left out with room for {max} of {read} objects");
assert!(objects(&out) <= max, "{name}: {} objects read with room for {max}", objects(&out));
}
}
}
/// An opacity mask whose art doesn't all fit within the object limit is left out with its art,
/// and the object it masks is written without it; the art left out leaves room for the objects
/// after it.
#[test]
fn a_mask_whose_art_passes_the_object_limit_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5));
let a = square(&mut doc, Rect::new(0.0, 0.0, 30.0, 30.0), Color::rgb(1.0, 1.0, 1.0));
let b = square(&mut doc, Rect::new(30.0, 30.0, 60.0, 60.0), Color::rgb(1.0, 1.0, 1.0));
let art = group(&mut doc, vec![a, b], false);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, masked).unwrap();
let after = square(&mut doc, Rect::new(100.0, 10.0, 140.0, 50.0), Color::gray(0.5));
doc.insert(Some(layer), usize::MAX, after).unwrap();
let text = |max: usize| {
let out = export_within(&doc, &opts(), max).unwrap();
let limited = out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT);
(String::from_utf8_lossy(&out.program).replace("\r\n", "\n"), limited)
};
let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
let (all, limited) = text(read);
assert!(!limited && all.contains("(AI9_Mask)"), "{all}");
let has_mask = |max: usize| text(max).0.contains("(AI9_Mask)");
let Some(fit) = (0..=read).find(|&max| has_mask(max)) else { panic!("the mask fits with room for {read} objects") };
assert!(fit > 2, "the mask needs room beyond the two squares: {fit}");
for max in fit..=read {
assert!(has_mask(max), "room for {max} of {read}, the mask fits in {fit}");
}
let (short, limited) = text(fit - 1);
assert!(limited);
assert!(!short.contains("(AI9_Mask)") && !short.contains("%_/ArtDictionary :\n%_/Mask"), "{short}");
assert_eq!(short.matches("\nf\n").count(), 2, "both squares are written: {short}");
}
/// A symbol's definition is written only when there is room for its instance too.
#[test]
fn a_symbol_definition_is_written_only_with_room_for_its_instance() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5));
let b = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5));
let art = group(&mut doc, vec![a, b], false);
doc.symbols.push(Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(art) });
let placed = instance(&mut doc, "Pair", Affine::translate((50.0, 10.0)));
doc.insert(Some(layer), usize::MAX, placed).unwrap();
add_rect(&mut doc, Rect::new(100.0, 10.0, 110.0, 20.0));
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let all = String::from_utf8_lossy(&program).replace("\r\n", "\n");
assert!(all.contains("%AI14_BeginSymbol") && all.contains("/SymbolInstance :"), "{all}");
let read = crate::import::objects_read(&program).unwrap();
for max in 0..read {
let out = export_within(&doc, &opts(), max).unwrap();
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
let defined = text.contains("%AI14_BeginSymbol");
assert_eq!(defined, text.contains("/SymbolInstance :"), "room for {max} of {read}: {text}");
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects");
let objects = crate::import::objects_read(&out.program).unwrap();
assert!(objects <= max, "{objects} objects read with room for {max}");
}
}
/// The writer's limit is the reader's, which its warning names.
#[test]
fn the_object_limit_is_the_readers() {
assert_eq!(crate::import::MAX_EDITING_OBJECTS, 4_000_000);
assert!(writer::OBJECT_LIMIT.contains("4,000,000"));
}
/// Paths past the object limit are left out of a layer of many paths, with a warning: the paths
/// that fit are written.
#[test]
fn paths_past_the_object_limit_are_left_out() {
let mut doc = Document::new(200.0, 100.0);
for i in 0..10 {
add_rect(&mut doc, Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0));
}
let out = export_within(&doc, &opts(), 6).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
assert_eq!(crate::import::objects_read(&out.program).unwrap(), 6);
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
assert_eq!(text.matches("\nf\n").count(), 6, "the first six paths are written");
}
/// On a canvas of the usual size, nested symbols whose instances multiply, written as their art in
/// art cut to a clipping object, stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art
/// in all, with a warning.
+225 -43
View File
@@ -62,6 +62,7 @@ const GROUPED_LAYER: &str =
pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps";
pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps";
pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps";
pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps";
const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps";
/// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]),
@@ -117,6 +118,16 @@ fn blend_code(b: BlendMode) -> u8 {
}
}
/// What the writer had charged when a part of the art started ([`Writer::charges`]).
#[derive(Clone, Copy)]
struct Charges {
objects: usize,
symbol_nodes: u64,
kept_objects: usize,
kept_symbol_nodes: u64,
left_out: usize,
}
pub(super) struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
@@ -144,6 +155,18 @@ pub(super) struct Writer<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`].
symbol_nodes: u64,
/// The objects written so far, counted as the reader counts the objects it reads.
objects: usize,
/// How many times art was left out for [`Self::max_objects`].
left_out: usize,
/// The objects of the pattern and symbol definitions written so far, which stay written when
/// the art that first used them is not ([`Self::give_back`]).
definition_objects: usize,
/// The objects of symbol art in those definitions ([`Self::symbol_nodes`]).
definition_symbol_nodes: u64,
/// The most objects written ([`Self::take_objects`]): the most the reader reads,
/// [`crate::import::MAX_EDITING_OBJECTS`].
pub(super) max_objects: usize,
/// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are
/// written in: art space, and in a pattern's definition [`Self::tile_grad_space`].
grad_space: Affine,
@@ -286,6 +309,85 @@ impl<'a> Writer<'a> {
styled: HashMap::new(),
styles: styles::Styles::default(),
in_look: false,
objects: 0,
left_out: 0,
definition_objects: 0,
definition_symbol_nodes: 0,
max_objects: crate::import::MAX_EDITING_OBJECTS,
}
}
/// Whether `count` more objects may be written: the objects written so far, counted as the
/// reader counts them ([`object_count`]), stay within [`Self::max_objects`]. Past it the art is
/// left out, with a warning.
fn take_objects(&mut self, count: usize) -> bool {
let total = self.objects.saturating_add(count);
if total > self.max_objects {
self.warn(OBJECT_LIMIT);
self.left_out += 1;
return false;
}
self.objects = total;
true
}
/// Start a group with `op` (`u`, or `q` for a clipping group), counted as one object
/// ([`Self::take_objects`]). False, writing nothing, when there is no room for it.
fn open_group(&mut self, op: &str) -> bool {
if !self.take_objects(1) {
return false;
}
self.line(op);
true
}
/// Whether `n`, an instance of `symbol` placed by `xf`, is written as a live instance
/// ([`Self::live_instance`]). The instance is one object, without the art of its definition,
/// and room for it is kept before the definition is written.
fn live_instance_in_room(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool {
if !self.take_objects(1) {
return false;
}
if self.live_instance(n, symbol, xf) {
return true;
}
self.objects -= 1;
false
}
/// What has been charged so far, for [`Self::keep_since`] and [`Self::give_back`].
fn charges(&self) -> Charges {
Charges {
objects: self.objects,
symbol_nodes: self.symbol_nodes,
kept_objects: self.definition_objects,
kept_symbol_nodes: self.definition_symbol_nodes,
left_out: self.left_out,
}
}
/// Whether art was left out for [`Self::max_objects`] since `c`.
fn left_out_since(&self, c: Charges) -> bool {
self.left_out != c.left_out
}
/// A definition that started at `c` is written: what was charged since stays charged when the
/// art that first used it is given back ([`Self::give_back`]).
fn keep_since(&mut self, c: Charges) {
self.definition_objects = c.kept_objects + (self.objects - c.objects);
self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes);
}
/// Art written since `c` as `lines` is not used: the objects and symbol art charged since are
/// given back, except those of the definitions written since, and the images `lines` marks
/// are emptied (the others keep their indices).
fn give_back(&mut self, c: Charges, lines: &str) {
self.objects = c.objects + (self.definition_objects - c.kept_objects);
self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes);
for index in lines.split(PAYLOAD).skip(1).step_by(2) {
if let Some(p) = index.parse::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
*p = Vec::new();
}
}
}
@@ -510,19 +612,22 @@ impl<'a> Writer<'a> {
NodeKind::Layer { .. } => {}
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
NodeKind::Group { children, clip: false } => {
self.line("u");
for c in children {
self.node(c);
if self.open_group("u") {
for c in children {
self.node(c);
}
self.end_group("U", xy);
}
self.end_group("U", xy);
}
NodeKind::Path { path, rule, guide, .. } => {
if *guide {
// Its own transparency, not the one the object before it set.
if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) {
// A path for each subpath with points, with the guide's own transparency, not
// the one the object before it set.
let subpaths = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count();
if subpaths > 0 && self.take_objects(subpaths) {
self.set_object_xy(xy.object());
ai_ops::guide(&mut self.body, self.to_art, path, true);
}
ai_ops::guide(&mut self.body, self.to_art, path, true);
} else {
self.shape(n, path, *rule, xy);
}
@@ -530,8 +635,11 @@ impl<'a> Writer<'a> {
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => self.image(im, xy),
NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } => {
if !self.open_group("u") {
return;
}
// The canvas places an instance inside a symbol's art where the outer instance puts
// it (9-slice guides, stains and patterns included), and this art doesn't move with it.
self.wrote_fixed |= self.in_symbol;
@@ -541,7 +649,6 @@ impl<'a> Writer<'a> {
} else {
self.warn(symbols::MISSING_SYMBOL);
}
self.line("u");
if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
@@ -583,8 +690,10 @@ impl<'a> Writer<'a> {
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
if !self.open_group("u") {
return;
}
self.warn(LIVE);
self.line("u");
if self.nest < MAX_NEST {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
self.nest += 1;
@@ -629,11 +738,14 @@ impl<'a> Writer<'a> {
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() {
return;
}
let start = self.body.len();
let has_id = name.is_some() || !bound.is_empty();
let has_entries = entries.is_some();
self.body.push_str("%_/ArtDictionary :\n");
if let Some(e) = entries {
self.body.push_str(&e);
}
if name.is_some() || !bound.is_empty() {
if has_id {
let id = self.take_id(name.unwrap_or("XMLID"));
let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes()));
if name.is_none() {
@@ -644,8 +756,13 @@ impl<'a> Writer<'a> {
self.bound.push((n.id, id, bound));
}
}
if let Some(m) = mask {
self.mask(m);
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
if let Some(m) = mask
&& !self.mask(m)
&& !has_id
&& !has_entries
{
return self.body.truncate(start);
}
self.body.push_str("%_;\n%_\n");
}
@@ -673,10 +790,13 @@ impl<'a> Writer<'a> {
/// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_`
/// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`,
/// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and
/// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is.
fn mask(&mut self, m: &OpacityMask) {
/// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. False, writing
/// nothing, when some of the art is left out for the object limit ([`Self::take_objects`]): a
/// mask without all its art would change what it shows.
fn mask(&mut self, m: &OpacityMask) -> bool {
let outer = std::mem::take(&mut self.body);
let (state, clip) = (self.state, self.clip.take());
let (state, gs, clip) = (self.state, self.gs, self.clip.take());
let (charges, bound) = (self.charges(), self.bound.len());
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
// in a group that doesn't draw it, so a hidden root is written shown.
@@ -709,6 +829,13 @@ impl<'a> Writer<'a> {
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
if self.left_out_since(charges) {
// The art isn't written, nor are the variable bindings of its objects.
self.give_back(charges, &lines);
self.bound.truncate(bound);
(self.state, self.gs, self.clip, self.block_end) = (state, gs, clip, usize::MAX);
return false;
}
self.body.push_str("%_/Mask :\n%_X=\n");
hide(&lines, &mut self.body);
self.body.push_str("%_X+\n%_(Art) ,\n");
@@ -718,6 +845,7 @@ impl<'a> Writer<'a> {
self.body.push_str("%_; (AI9_Mask) ,\n");
// The dictionary ends with the state before it, except the transparency its art set last.
(self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX);
true
}
/// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path
@@ -739,12 +867,16 @@ impl<'a> Writer<'a> {
if self.clip.is_some() && !children.is_empty() {
self.warn(CLIPPING);
}
self.line("u");
self.clip_contents(children);
return self.end_group("U", xy);
if self.open_group("u") {
self.clip_contents(children);
self.end_group("U", xy);
}
return;
}
let at_q = (self.state, self.gs);
self.line("q");
if !self.open_group("q") {
return;
}
self.clipped(children);
(self.state, self.gs) = at_q;
self.end_group("Q", xy);
@@ -753,7 +885,8 @@ impl<'a> Writer<'a> {
/// The members of a clipping group or layer, the first one clipping the others: the others,
/// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills
/// and strokes the clipping path can't paint itself are paths of their own, the fills under the
/// others and the strokes over them.
/// others and the strokes over them. Without room for the clipping path, nothing is written
/// ([`Self::take_objects`]).
fn clipped(&mut self, children: &[Arc<Node>]) {
let Some((first, rest)) = children.split_first() else { return };
let Some((path, rule)) = clip_path(first) else {
@@ -762,6 +895,12 @@ impl<'a> Writer<'a> {
};
let items = self.items(first, path.to_bezpath().bounding_box());
let painted = self.plain_paint(&items);
// The clipping path goes after the others, so its objects are counted first.
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
let (fill, stroke) = painted.unwrap_or_default();
if !self.take_objects(clip_object_count(&subpaths, fill, stroke)) {
return;
}
if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
self.warn(ITEM_TRANSPARENCY);
}
@@ -779,12 +918,10 @@ impl<'a> Writer<'a> {
self.node(c);
}
self.clip_paint_part(first, &path, rule, &strokes);
let (fill, stroke) = painted.unwrap_or_default();
self.state(first.locked, !first.visible);
let start = self.body.len();
self.paint_state(fill, stroke, Xy::of(first).object(), rule);
let op = paint_op(fill.is_some(), stroke.is_some());
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
let several = subpaths.len() > 1;
if several {
self.line("*u");
@@ -817,7 +954,9 @@ impl<'a> Writer<'a> {
if let [one] = items {
return self.item(path, rule, one, xy);
}
self.line("u");
if !self.open_group("u") {
return;
}
for i in items {
self.item(path, rule, i, Xy::PLAIN);
}
@@ -870,11 +1009,12 @@ impl<'a> Writer<'a> {
(Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy),
(None, [one]) => self.item(path, rule, one, xy),
(None, items) => {
self.line("u");
for i in items {
self.item(path, rule, i, Xy::PLAIN);
if self.open_group("u") {
for i in items {
self.item(path, rule, i, Xy::PLAIN);
}
self.end_group("U", xy);
}
self.end_group("U", xy);
}
}
}
@@ -898,7 +1038,8 @@ impl<'a> Writer<'a> {
}
/// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False
/// when it has no brush the document knows (or brushes nest too deep).
/// when it has no brush the document knows (or brushes nest too deep). Without room for the
/// group ([`Self::take_objects`]), the stroke is left out.
fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool {
if self.nest >= MAX_NEST {
return false;
@@ -908,8 +1049,10 @@ impl<'a> Writer<'a> {
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st);
let state = self.state;
if !self.open_group("u") {
return true;
}
self.nest += 1;
self.line("u");
for piece in &art {
self.node(piece);
}
@@ -948,13 +1091,15 @@ impl<'a> Writer<'a> {
let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() };
return self.path_object(path, rule, Some(&flat), None, xy);
}
let state = self.state;
if !self.open_group("u") {
return;
}
self.wrote_pattern = true;
let outer = self.clip.clone();
let region = self.cut(path, rule);
self.clip = Some(region);
self.nest += 1;
let state = self.state;
self.line("u");
for t in &tiles {
self.node(t);
}
@@ -979,8 +1124,8 @@ impl<'a> Writer<'a> {
}
let both = fill.is_some() && stroke.is_some();
let own = if both { Xy::OPAQUE } else { xy.object() };
if both {
self.line("u");
if both && !self.open_group("u") {
return;
}
if fill.is_some() || stroke.is_none() {
let region = self.cut(path, rule);
@@ -1026,7 +1171,7 @@ impl<'a> Writer<'a> {
/// points and painting operator (`*u` … `*U` around several).
fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) {
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
if subpaths.is_empty() {
if subpaths.is_empty() || !self.take_objects(object_count(subpaths.len())) {
return;
}
self.paint_state(fill, stroke, xy, rule);
@@ -1057,18 +1202,29 @@ impl<'a> Writer<'a> {
fn text(&mut self, n: &Node, xy: Xy) {
let NodeKind::Text(t) = &n.kind else { return };
let doc = self.doc;
let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol {
None
} else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) {
let thread = doc.text_threads.iter().find(|th| th.contains(&n.id));
// Type that can't be a stub is written as its outlines, which count their own objects.
if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) {
return self.outlined_text(n, xy);
}
// The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for
// them, the type is left out before its story goes into the text document.
let stub = texts::stub_objects(t);
if !self.take_objects(stub) {
return;
}
let placed = if let Some(thread) = thread {
// A thread is checked for what it loses, in every frame, when its story is written.
let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id));
placed.map(|p| (p, self.texts.take_lost()))
} else if texts::holds(n, t) {
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
} else {
None
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
};
let Some(((story, frame), lost)) = placed else {
// The text document can't hold it: the outlines count their own objects.
self.objects -= stub;
return self.outlined_text(n, xy);
};
let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) };
self.typed = true;
if lost {
self.warn(texts::TYPE_ATTRIBUTES);
@@ -1088,8 +1244,10 @@ impl<'a> Writer<'a> {
/// graphics as their symbols' art where the layout places them, as the PDF page draws them;
/// the group has the type's transparency `xy`.
fn outlined_text(&mut self, n: &Node, xy: Xy) {
if !self.open_group("u") {
return;
}
self.warn(TYPE);
self.line("u");
let doc = self.doc;
let resolved = match &n.kind {
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
@@ -1343,6 +1501,30 @@ fn paint_op(fill: bool, stroke: bool) -> char {
}
}
/// The objects the reader makes of a path object of `subpaths` subpaths with points: a path for
/// each, and a compound path holding them when there are several.
fn object_count(subpaths: usize) -> usize {
match subpaths {
0 => 0,
1 => 1,
n => n + 1,
}
}
/// The objects the reader counts for clipping path `subpaths` (those with points) painted with
/// `fill` and `stroke` ([`Writer::clipped`]). Its last subpath carries a gradient fill's or
/// stroke's gradient, so the subpaths of a filled compound clipping path with a gradient paint
/// differently, and the reader reads their fills apart from it: a compound path of a copy of each,
/// which counts the copy and its points ([`object_count`] for the clipping path itself).
fn clip_object_count(subpaths: &[&SubPath], fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) -> usize {
let own = object_count(subpaths.len());
let apart = subpaths.len() > 1 && fill.is_some() && (gradient(fill.map(|f| &f.paint)).is_some() || gradient(stroke.map(|s| &s.paint)).is_some());
if !apart {
return own;
}
subpaths.iter().fold(own.saturating_add(1), |n, sp| n.saturating_add(1).saturating_add(sp.anchors.len()))
}
/// The subpaths of compound path `n`'s members, as one path.
fn compound_path(n: &Node) -> PathData {
let mut subpaths = vec![];
@@ -107,6 +107,11 @@ impl Writer<'_> {
if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) {
return;
}
// The image, and where it is cut, the clipping group and its clipping path.
let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()));
if !self.take_objects(1 + clipping) {
return;
}
if deep {
self.warn(IMAGE_DEPTH);
}
+11 -2
View File
@@ -381,7 +381,8 @@ impl Writer<'_> {
/// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx
/// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it,
/// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`.
/// False when it can't be ([`Self::pattern_writable`]).
/// False when it can't be ([`Self::pattern_writable`]), or when its box and art don't all fit
/// within the object limit ([`Self::take_objects`]).
fn define_pattern(&mut self, name: &str) -> bool {
if !self.pattern_writable(name) {
return false;
@@ -392,7 +393,9 @@ impl Writer<'_> {
return true;
}
let doc = self.doc;
let Some(def) = doc.pattern(name) else { return false };
// The tile's box is an object of the definition ([`Self::take_objects`]).
let (before, bound) = (self.charges(), self.bound.len());
let Some(def) = doc.pattern(name).filter(|_| self.take_objects(1)) else { return false };
self.defining.push(name.to_string());
// The art is written in the pattern's space, y up, from the state a definition starts in.
let saved = (
@@ -427,6 +430,12 @@ impl Writer<'_> {
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
let art = std::mem::replace(&mut self.body, art);
self.defining.pop();
if self.left_out_since(before) {
self.give_back(before, &art);
self.bound.truncate(bound);
return false;
}
self.keep_since(before);
let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile);
let quoted = ps::bytes(name.as_bytes());
let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} ");
@@ -688,7 +688,18 @@ impl Writer<'_> {
/// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or
/// compound path's points painted with nothing, or with its gradient fill when its style has
/// one; a group with its members), its name and opacity mask; then `1 (name) XW`.
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
/// room for them, nothing is written.
pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) {
let own = match &orig.kind {
NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()),
NodeKind::Group { .. } => 1,
_ => 0,
};
if !self.take_objects(1 + own) {
return;
}
self.state(orig.locked, !orig.visible);
let xy = Xy::of(orig);
let was = self.in_look;
@@ -717,6 +728,10 @@ impl Writer<'_> {
let saved = std::mem::take(&mut self.body);
self.hidden += 1;
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
// A path's objects were kept for it above; it counts them itself.
if !matches!(orig.kind, NodeKind::Group { .. }) {
self.objects -= own;
}
match &orig.kind {
NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy),
NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy),
+23 -2
View File
@@ -183,8 +183,9 @@ impl Writer<'_> {
/// written from the top of the nesting whatever the instance that asks for it, so a definition
/// is the same wherever its first instance lies. False when it can't be: the document doesn't
/// have the symbol, it is being written (its art holds an instance of itself), it is reached
/// through more than [`MAX_NEST`] definitions being written, or the definitions written and
/// being written leave no place under [`MAX_SYMBOLS`].
/// through more than [`MAX_NEST`] definitions being written, the definitions written and
/// being written leave no place under [`MAX_SYMBOLS`], or its art doesn't all fit within the
/// object limit ([`Self::take_objects`]).
pub(super) fn define_symbol(&mut self, name: &str) -> bool {
if self.defined_symbols.contains(name) {
return true;
@@ -206,6 +207,8 @@ impl Writer<'_> {
// The art's effects are its own, not those of a drawn look that first places it.
let in_look = std::mem::take(&mut self.in_look);
self.defining_symbols.push(name.to_string());
// The objects of this definition and of those it defines first.
let before = self.charges();
let mut used = vec![];
vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| {
if let NodeKind::SymbolInstance { symbol, .. } = &m.kind {
@@ -217,10 +220,19 @@ impl Writer<'_> {
}
if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS {
self.defining_symbols.pop();
self.keep_since(before);
self.warn(SYMBOL_COUNT);
self.in_look = in_look;
return false;
}
// The group that holds the art is an object of the definition ([`Self::take_objects`]);
// without room for it, or for all the art, there is no definition.
let (art_start, bound) = (self.charges(), self.bound.len());
if !self.take_objects(1) {
self.defining_symbols.pop();
self.keep_since(before);
return false;
}
// The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the
// document at its own size.
let origin = self.canvas_origin;
@@ -264,7 +276,9 @@ impl Writer<'_> {
let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n");
} else {
// A group always writes its dictionary ([`Self::dictionary`]), which takes the entries.
// It counts itself, in the room kept for it above.
self.root_entries = Some((root.id, entries));
self.objects -= 1;
self.node(root);
}
self.nest = nest;
@@ -297,6 +311,13 @@ impl Writer<'_> {
self.in_look = in_look;
let art = std::mem::replace(&mut self.body, art);
self.defining_symbols.pop();
if self.left_out_since(art_start) {
self.give_back(art_start, &art);
self.bound.truncate(bound);
self.keep_since(before);
return false;
}
self.keep_since(before);
let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes()));
self.defined_symbols.insert(name.to_string());
self.symbol_defs.push(text);
@@ -1016,6 +1016,12 @@ fn clone_v(v: &V) -> V {
}
}
/// The objects the reader reads of [`stub`] for `t`: the text object, and a path for each subpath
/// of its frame's path.
pub(in crate::ai_editing) fn stub_objects(t: &TextObject) -> usize {
1 + shape(t).map_or(0, |p| p.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())
}
/// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`,
/// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in
/// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`.
+6 -3
View File
@@ -74,8 +74,11 @@ use interp::{Fault, Interp};
use obj::PsError;
pub use ai::NameEncoding;
#[cfg(test)]
pub(crate) use ai::objects_read;
pub(crate) use ai::{
MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number,
MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with,
symbol_art_member, warp_number,
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
@@ -253,8 +256,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<
}
/// [`import_trusting`], with the names in the editing copy that aren't UTF-8 (layers, objects,
/// colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data
/// sets) read in `names`.
/// artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables
/// and data sets) read in `names`.
pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result<Imported, String> {
let ps = program(bytes)?;
let dsc = Dsc::read(ps);
+4 -1
View File
@@ -42,6 +42,8 @@ pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
#[cfg(test)]
pub(crate) use read::objects_read;
#[cfg(test)]
pub use read::read;
#[cfg(test)]
pub(crate) use read::with_image_limits;
@@ -50,7 +52,8 @@ pub use read::{
palette, read_beside, read_in, symbols, variables,
};
pub(crate) use read::{
MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member,
MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with,
symbol_art_member,
};
pub(crate) use read::is_look_slot;
+24 -11
View File
@@ -28,7 +28,11 @@
//! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`).
//! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's
//! section names (one for Scatter, five for Art and Pattern, empty where there is none, each
//! after a space for Art and Pattern), more fields, and last 15 numbers.
//! after a space for Art and Pattern), more fields, and last 15 numbers. Older files write
//! brushes with first number 1, one field fewer before the numbers and 8 numbers; an Art brush
//! with 8 numbers, whatever its first number, is read from its art's name alone (its numbers
//! aren't known, so it keeps the defaults and its paths their drawn look). A Pattern brush needs
//! only its first 3 numbers (its scale is the third).
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14), and 1 Tints (1).
@@ -153,17 +157,17 @@ pub(super) enum BrushError {
}
/// The strings of a plug-in object's section (`section`: what follows `%AI8_BeginPluginObject`):
/// its plug-in name, its name and its parameters, joined, read by `names`. The name is `None` with
/// one string, and the whole is `None` with none. The parameters hold the names of its art, which
/// have notes when their art is read.
pub(super) fn plugin_strings(section: &[u8], names: &mut Names) -> Option<(String, Option<String>, String)> {
/// its plug-in name, the bytes of its name and its parameters, joined, read by `names` without
/// notes. The name is `None` with one string, and the whole is `None` with none. A brush's name has
/// its note when the brush is read, and the names of its art when their art is read.
pub(super) fn plugin_strings(section: &[u8], names: &Names) -> Option<(String, Option<Vec<u8>>, String)> {
let mut lex = Lexer::new(section);
let mut strings = vec![];
let mut params = vec![];
while let Some(t) = lex.next_token() {
if let Tok::Str(s) | Tok::Hex(s) = t.tok {
if strings.len() < 2 {
strings.push(names.bytes(&s));
strings.push(s);
} else {
// Joined before they are text: a string may end inside a character.
let room = MAX_PARAMS.saturating_sub(params.len());
@@ -172,7 +176,7 @@ pub(super) fn plugin_strings(section: &[u8], names: &mut Names) -> Option<(Strin
}
}
let mut strings = strings.into_iter();
let kind = strings.next()?;
let kind = names.quiet(&strings.next()?);
Some((kind, strings.next(), names.quiet(&params)))
}
@@ -313,11 +317,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
})
.collect();
let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?;
let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?;
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n;
let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt);
match kind {
Tool::Scatter => {
let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) };
let pct = |a: f64, b: f64| (a * 100.0, b * 100.0);
let values = [pct(*p2, *p3), pct(*p5, *p6), pct(*p8, *p9), (*p11, *p12)];
let codes = [*p4, *p7, *p10, *p13];
@@ -337,10 +340,20 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
DraftKind::Scatter { brush, art: art()? }
}
Tool::Art => {
let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None };
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) }
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
// paths their drawn look. The count tells the form, not the first number.
// Any other count isn't a form this reads.
let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) {
(if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15))
} else if n.len() == 8 {
(ArtBrush::default().colorization, false)
} else {
return Err(BrushError::Values);
};
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
}
_ => {
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
let tiles: [Option<String>; 5] = tiles.try_into().map_err(|_| BrushError::Values)?;
DraftKind::Pattern { tiles, scale: p3 * 100.0 }
}
+19 -2
View File
@@ -48,8 +48,9 @@ pub(super) fn text(bytes: &[u8]) -> String {
}
/// The encoding names in the editing data that aren't UTF-8 are read in (layers, objects,
/// artboards, colors, gradients, patterns, graphic styles, swatches). Legacy files store them in
/// the code page of the system that wrote them and don't say which.
/// artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables
/// and data sets, and data sets' text values). Legacy files store them in the code page of the
/// system that wrote them and don't say which.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NameEncoding {
/// Windows-1252, Latin-1 for its gaps.
@@ -193,6 +194,22 @@ impl Names {
self.text(v)
}
/// The name the XML id in `id` (an `AI10_ArtUID`) gives its object ([`xml_name`]): a string that
/// isn't UTF-8 is read in the encoding, with a note on the name.
pub(super) fn xml_id_name(&mut self, id: &Obj) -> Option<String> {
match id.content.iter().find(|v| matches!(v, V::Str(_) | V::Name(_)))? {
V::Str(bytes) => {
let d = self.encoding.decode(bytes);
let name = xml_name(&d.text);
if d.fallback {
self.note(&name, d.replaced);
}
Some(name)
}
v => v.text().map(|t| xml_name(&t)),
}
}
/// A note on `name`, which wasn't UTF-8 (`replaced`: parts of it weren't valid in the encoding
/// either).
pub(super) fn note(&mut self, name: &str, replaced: bool) {
+9
View File
@@ -149,6 +149,7 @@ pub(crate) use document::{slot_frame, slot_of};
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
#[cfg(test)]
pub(crate) use raster::with_image_limits;
pub(crate) use types::MAX_NODES;
pub use types::PlacedPage;
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
#[cfg(test)]
@@ -267,6 +268,14 @@ fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncodin
r.finish()
}
/// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`.
#[cfg(test)]
pub(crate) fn objects_read(data: &[u8]) -> Result<usize, String> {
let mut r = Reader::with_names(data, NameEncoding::default());
r.run()?;
Ok(r.nodes)
}
/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private
/// data): the program's header gives the author and the custom colors the editing data's header
/// doesn't. `page_art`: as [`read_over_page`] reads a file. `names`: the encoding of names that
+7 -3
View File
@@ -726,7 +726,7 @@ impl<'a> Reader<'a> {
/// order or a brush ([`brush`]); other objects are left alone. A brush without a name is counted
/// as not read.
fn plugin_object(&mut self, section: &[u8]) {
let Some((kind, name, params)) = brush::plugin_strings(section, &mut self.names) else { return };
let Some((kind, name, params)) = brush::plugin_strings(section, &self.names) else { return };
if kind == brush::MANAGER {
if name.is_some() {
self.brush_objects = true;
@@ -744,8 +744,12 @@ impl<'a> Reader<'a> {
self.brushes_unread += 1;
return;
}
match brush::parse_tool_object(tool, &name, &params) {
Ok(d) => self.brush_drafts.push(d),
match brush::parse_tool_object(tool, &self.names.quiet(&name), &params) {
Ok(d) => {
// Only the name of a brush that is read gets a note.
self.names.bytes(&name);
self.brush_drafts.push(d);
}
Err(_) => self.brushes_unread += 1,
}
}
+36 -4
View File
@@ -289,6 +289,26 @@ fn beside(mut defined: Vec<PatternDef>, have: &[PatternDef]) -> (Vec<PatternDef>
(defined, renamed)
}
/// The names of the objects in `art` and what they hold, masks included.
fn art_names<'a>(art: impl IntoIterator<Item = &'a Node>) -> Vec<&'a str> {
let mut stack: Vec<&Node> = art.into_iter().collect();
let mut names = vec![];
while let Some(n) = stack.pop() {
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
names.extend(n.name.as_deref());
}
names
}
/// The names read for the patterns `added` that the patterns don't show as their own: those
/// `renamed` replaced, and the names of the objects of their art.
fn pattern_names_read<'a>(added: &'a [PatternDef], renamed: &'a std::collections::BTreeMap<String, String>) -> Vec<&'a str> {
let shown: std::collections::HashSet<&str> = added.iter().map(|p| p.name.as_str()).collect();
let mut names: Vec<&str> = renamed.iter().filter(|(_, new)| shown.contains(new.as_str())).map(|(old, _)| old.as_str()).collect();
names.extend(art_names(added.iter().flat_map(|p| p.art.iter().map(|a| &**a))));
names
}
/// `n` and what it holds painting with the patterns `renamed` renames (old to new) by their new names.
fn repoint(n: &mut Node, renamed: &std::collections::BTreeMap<String, String>) {
for p in vectorcraft_doc::swatches::node_paints_mut(n) {
@@ -349,6 +369,7 @@ pub fn palette(data: &[u8], patterns: &[PatternDef], names: &mut obj::Names) ->
.map(|s| s.name.as_str())
.chain(groups.iter().map(|g| g.name.as_str()))
.chain(added.iter().map(|p| p.name.as_str()))
.chain(pattern_names_read(&added, &renamed))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(PaletteSwatches { swatches, groups, patterns: added, images })
@@ -407,7 +428,13 @@ pub fn brushes(data: &[u8], patterns: &[PatternDef], names: &mut obj::Names) ->
images.insert(im.key.clone(), blob.clone());
}
});
let used: Vec<&str> = lib.iter().map(|b| b.name.as_str()).chain(added.iter().map(|p| p.name.as_str())).collect();
let used: Vec<&str> = lib
.iter()
.map(|b| b.name.as_str())
.chain(added.iter().map(|p| p.name.as_str()))
.chain(pattern_names_read(&added, &renamed))
.chain(art_names(lib.iter().flat_map(|b| b.art())))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(Brushes { brushes: lib, patterns: added, images, note: (unread > 0).then(|| brush::left_out_note(unread)) })
}
@@ -495,7 +522,12 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option<Symbols> {
(s, reg)
})
.collect::<Vec<_>>();
let used: Vec<&str> = symbols.iter().map(|(s, _)| s.name.as_str()).chain(swatches.iter().map(|s| s.name.as_str())).collect();
let used: Vec<&str> = symbols
.iter()
.map(|(s, _)| s.name.as_str())
.chain(swatches.iter().map(|s| s.name.as_str()))
.chain(art_names(symbols.iter().map(|(s, _)| &*s.art)))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(Symbols { symbols, images, swatches })
}
@@ -783,8 +815,8 @@ impl<'a> Reader<'a> {
}
let name = match self.names.entry(o, "AIArtName") {
Some(name) => name,
None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) {
Some(id) => obj::xml_name(&id),
None => match o.obj("AI10_ArtUID").and_then(|id| self.names.xml_id_name(id)) {
Some(name) => name,
None => return,
},
};
+2 -2
View File
@@ -11,8 +11,8 @@ pub(super) const MAX_SUBREADERS: usize = 32;
/// Most operands on the stack.
pub(super) const MAX_STACK: usize = 1 << 20;
/// Most objects read.
pub(super) const MAX_NODES: usize = 4_000_000;
/// Most objects read. The `.ai` editing data writer writes no more ([`crate::import::MAX_EDITING_OBJECTS`]).
pub(crate) const MAX_NODES: usize = 4_000_000;
/// The most objects all the blends of one import may lay out to be checked (each twice) and made
/// live; past it, blends keep their drawn steps.
+83 -1
View File
@@ -6427,7 +6427,7 @@ fn each_pathfinder_operation_on_a_group_draws_as_vectorcraft_draws_it() {
// Each result's box and paints; Outline's lines start where each square's path starts, so
// the results are compared in a sorted order.
let faces = |g: &Node| -> Vec<String> {
let mut faces: Vec<String> = vectorcraft_effects::pathfinder_children(g, None)
let mut faces: Vec<String> = vectorcraft_effects::pathfinder_children(None, g, None)
.unwrap()
.iter()
.map(|n| {
@@ -8882,6 +8882,54 @@ fn a_pattern_brushs_five_slots_are_kept() {
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}");
}
/// An Art brush in the older form (first number 1: one field fewer before its 8 numbers) is read
/// with the art of its section, but not as plain (its numbers aren't known); a Scatter brush in
/// that form isn't read (its values aren't all there).
#[test]
fn an_older_art_brush_comes_in_with_its_art() {
const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0";
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap();
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}");
let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") };
assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization");
// A count of numbers neither form has isn't read.
for bad in ["1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0", "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0"] {
assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}");
}
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values));
// 8 numbers are the older form whatever the first number says: read with the defaults.
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap();
let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization));
let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat();
let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") };
assert_eq!(a.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section");
assert_eq!(lib.len(), 1, "a brush naming no art is left out");
assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings);
}
/// A Pattern brush needs only its first 3 numbers, its scale the third (as a percentage), so the
/// older form's 8 numbers read too; fewer than 3 aren't read.
#[test]
fn a_pattern_brush_reads_its_scale_from_three_or_more_numbers() {
const SLOTS: &str = "1 / Tile one/ Tile two/ / / Tile five/ 0 1 / ";
for numbers in ["1 0 0.5", "1 0 0.5 0 1 0 0 0"] {
let d = brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")).unwrap();
let brush::DraftKind::Pattern { tiles, scale } = d.kind else { panic!("{d:?}") };
assert_eq!(tiles[1].as_deref(), Some("Tile two"), "{numbers}");
assert!((scale - 50.0).abs() < 1e-9, "{numbers}: {scale}");
}
for numbers in ["", "1", "1 0"] {
assert_eq!(
brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")),
Err(brush::BrushError::Values),
"{numbers:?}"
);
}
}
/// A brush's art filled with a pattern the file defines before it is read, with that pattern.
#[test]
fn a_brushs_art_takes_the_patterns_defined_before_it() {
@@ -10226,6 +10274,40 @@ fn symbol_names_are_read_in_the_encoding_picked() {
assert_eq!(name_notes(&s).len(), 2, "{:?}", s.warnings);
}
/// A symbol library read without its layers has notes on the names of the objects in its art.
#[test]
fn a_symbol_librarys_art_has_notes_on_its_object_names() {
let inner = format!("{}{}", dot(), vectorcraft_testkit::ai::named(KO_GRA));
let data = stream(&symbols(&symbol(KO_RED, &inner, 5)));
let mut names = obj::Names::new(NameEncoding::Korean);
assert!(super::symbols(data.as_bytes(), &mut names).is_some());
let notes = names.notes();
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(notes.iter().any(|n| n.contains("`그라`")), "{notes:?}");
}
/// An object without a name of its own takes the one its XML id gives it, read in the encoding
/// picked, with a note on that name.
#[test]
fn an_object_named_by_its_xml_id_is_read_in_the_encoding_picked() {
let square = format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), vectorcraft_testkit::ai::named(&format!("{KO_RED}_1")));
let s = read_names(&layer(&square), "windows-949");
assert_eq!(art(&s.doc)[0].name.as_deref(), Some("빨강 1"), "{:?}", s.warnings);
let notes = name_notes(&s);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("`빨강 1`"), "{notes:?}");
}
/// The strings of a plug-in object that isn't a brush, and the name of a brush that isn't read,
/// have no notes.
#[test]
fn plug_in_objects_that_are_not_read_have_no_name_notes() {
let other = format!("%AI8_BeginPluginObject\n(Other Tool)\n({KO_RED})\n(x) .\n%AI8_EndPluginObject\n");
let broken = format!("%AI8_BeginPluginObject\n({SCATTER})\n({KO_GRA})\n(not a brush) .\n%AI8_EndPluginObject\n");
let s = read_names(&format!("{other}{broken}{}", layer("")), "windows-949");
assert!(name_notes(&s).is_empty(), "{:?}", s.warnings);
}
/// A graphic style's name, in the art styles and in the style list, and an object's link to it are
/// read in the encoding picked, with a note on the style.
#[test]
+20
View File
@@ -1474,6 +1474,26 @@ fn an_ai_library_without_layers_reads_its_names_in_a_legacy_encoding() {
assert!(notes.iter().all(|n| n.contains("windows-949")), "{notes:?}");
}
/// A pattern of a file without layers that takes a free name beside a pattern of the PDF part has
/// a note on the name it was read under.
#[test]
fn a_renamed_pattern_of_a_file_without_layers_has_a_note_on_its_legacy_name() {
// 빨강 in code page 949.
let red = r"\273\241\260\255";
let tile = format!("%AI3_BeginPattern: ({red})\n({red}) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n");
let palette = format!("%AI5_BeginPalette\n0 0 Pb\n({red}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Tile)\nPc\nPB\n%AI5_EndPalette\n");
let art = native(&format!("{tile}{palette}"));
let mut part = pdf_part(10.0, 86.0);
part.patterns.push(vectorcraft_doc::PatternDef::new("빨강", vec![]));
let (d, warnings, read) =
crate::layered_ai_from(crate::AiLayers::decode(&private(&art)), part, vec![], false, true, 1.0, crate::NameEncoding::Korean);
assert!(!read, "{warnings:?}");
assert!(d.pattern("빨강 2").is_some(), "{:?}", d.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8")).collect();
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("`빨강`"), "{notes:?}");
}
/// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no
/// pattern gets the patterns its editing data defines with the palette's pattern swatches.
#[test]
+57 -19
View File
@@ -1098,6 +1098,7 @@ impl<'a> Scene<'a> {
/// Paint all of `area` with an image of freeform gradient `g` placed on `bounds`, sampled at
/// the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side).
fn field_image(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let area = area.abs();
@@ -1108,11 +1109,24 @@ impl<'a> Scene<'a> {
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
let (cols, rows) = (along(area.width()), along(area.height()));
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
let field = g.freeform_on(b).model_field(spread_scale(b));
let q = |v: f32| (unit(v) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
let (w, h) = (u32::from(cols), u32::from(rows));
let image = if field.model() == Some(Model::Cmyk) || (self.cmyk && field.model() == Some(Model::Gray)) {
let inks: Vec<u8> = field
.grid(area, cols, rows)
.flat_map(|(color, a)| {
let [c, m, y, k] = color.to_cmyk();
[q(c), q(m), q(y), q(k), q(a)]
})
.collect();
self.encode_inks(&inks, w, h)
} else {
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
self.encode_image(&rgba, w, h)
};
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
if let Some(image) = self.encode_image(&rgba, u32::from(cols), u32::from(rows)) {
if let Some(image) = image {
self.place_image(&image, place);
}
}
@@ -1313,41 +1327,65 @@ impl<'a> Scene<'a> {
/// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over
/// white. `None` for an empty image.
fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option<String> {
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
self.encode_samples(rgba.as_chunks::<4>().0, w, h, space, |p: &[u8; 4], al| {
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
}
})
}
/// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn
/// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly
/// transparent pixels lay down that share of each ink.
fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option<String> {
self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| {
[0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8)
})
}
/// Pixels (`w` × `h`, rows from the top, each ending in its opacity) as the operators and data
/// that draw them in `space` on the unit square. `sample` gives the samples of a pixel that
/// isn't clear, from the pixel and its opacity.
fn encode_samples<const N: usize>(
&mut self,
px: &[[u8; N]],
w: u32,
h: u32,
space: Space,
mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4],
) -> Option<String> {
if w == 0 || h == 0 {
return None;
}
let px = rgba.as_chunks::<4>().0;
let clear = px.iter().any(|p| p[3] == 0);
if px.iter().any(|p| p[3] > 0 && p[3] < 255) {
let opacity = |p: &[u8; N]| p[N - 1];
let clear = px.iter().any(|p| opacity(p) == 0);
if px.iter().any(|p| opacity(p) > 0 && opacity(p) < 255) {
self.warn(IMAGE_PARTLY);
}
let keyed = clear && self.o.level == Level::Three;
if clear && !keyed {
self.warn(IMAGE_ALPHA_L2);
}
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let n = space.n();
// The key colour, which opaque pixels that happen to have it are nudged off.
let key: [u8; 4] = [255, 0, 255, 0];
// White: no ink, or full light.
let white: [u8; 4] = if space == Space::Cmyk { [0; 4] } else { [255; 4] };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
let mut data = Vec::with_capacity(px.len() * n);
for p in px {
if p[3] == 0 {
if opacity(p) == 0 {
data.extend(if keyed { &key } else { &white }.iter().take(n));
continue;
}
let al = u32::from(p[3]);
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
let mut v = match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
};
let mut v = sample(p, u32::from(opacity(p)));
if keyed && v.iter().take(n).eq(key.iter().take(n)) {
v[1] = 1;
}
+55 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
@@ -574,6 +574,60 @@ fn image_data(page: &str) -> Vec<u8> {
ps::ascii85_decode(&page[at..end]).unwrap()
}
/// A square filled with a freeform gradient of `a` on the left and `b` on the right, with those
/// points at `opacity`, exported: (the page, the middle pixel's samples, the warnings).
fn freeform_mid(a: Color, b: Color, opacity: f32, n: usize) -> (String, Vec<u8>, Vec<String>) {
let p = |x, c| FreeformPoint { opacity, ..FreeformPoint::new(Point::new(x, 100.0), c) };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() });
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
let out = export(&d, &opts(&d), None, None).unwrap();
let page = text(&out);
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * n;
(page, pixels[at..at + n].to_vec(), out.warnings)
}
#[test]
fn freeform_gradients_keep_the_inks_of_cmyk_points_and_mix_in_the_points_model() {
let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let (page, mid, _) = freeform_mid(cyan, black, 1.0, 4);
assert!(page.contains("/DeviceCMYK setcolorspace"), "{page}");
assert!(mid.iter().zip([128, 0, 0, 128]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
// A partly transparent point lays down that share of each ink.
let (_, mid, warnings) = freeform_mid(cyan, black, 0.5, 4);
assert!(mid.iter().zip([64, 0, 0, 64]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
assert!(warnings.iter().any(|w| w.contains("partly transparent")), "{warnings:?}");
// Lab points go out as RGB, mixed first in Lab.
let (page, mid, _) = freeform_mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0, 3);
assert!(page.contains("/DeviceRGB setcolorspace"), "{page}");
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(mid.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 6), "{mid:?} vs {want:?}");
}
#[test]
fn gray_freeform_points_are_written_as_black_ink_in_a_cmyk_document() {
let p = |x, c| FreeformPoint::new(Point::new(x, 100.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, Color::gray(0.2)), p(160.0, Color::gray(0.8))], ..Default::default() });
let mut d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let page = text(&export(&d, &opts(&d), None, None).unwrap());
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * 4;
assert!(pixels[at..at + 3] == [0, 0, 0] && pixels[at + 3].abs_diff(128) <= 6, "{:?}", &pixels[at..at + 4]);
}
#[test]
fn images_are_flate_with_masked_pixels_at_level_3_and_run_length_at_level_2() {
let d = png_doc();
+1 -1
View File
@@ -56,7 +56,7 @@ 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::base64::{decode as base64_decode, decoded_len as base64_decoded_len, 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,
+263 -15
View File
@@ -1,16 +1,29 @@
//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the
//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and
//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes
//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their
//! own modules; the rest of the standard library is not here yet.
//! The built-in objects (ECMA-262 5.1 section 15): `Object` with `Object.prototype` and the
//! ES5 statics, `Function` with `Function.prototype.call`, `apply`, `bind` and `toString`,
//! `Array`, the error constructors, `Math`, `JSON`, the global functions and constants, Annex
//! B's `escape` and `unescape`, and the prototypes primitive values look properties up on, with
//! `Boolean.prototype`'s `toString` and `valueOf`. `String`, `Number`, `RegExp` and `Date` are in
//! their own modules.
//!
//! Every loop in a built-in takes a unit of fuel for each step, and every string, array, object
//! or property it makes is checked against the heap limit before it is made, so no built-in can
//! run past [`crate::Limits`].
mod array;
mod global;
mod json;
mod math;
mod object;
use std::rc::Rc;
use crate::bytecode::{CodeKind, Source};
use crate::compiler::{CompileFail, compile_constructed_function};
use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes};
use crate::interp::{Abrupt, ErrorType, Interp, JsResult};
use crate::value::{Str, Value};
use crate::heap::{
BOUND_ARG_BYTES, Class, Func, HIDDEN, Key, NativeFn, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, key, string_bytes,
};
use crate::interp::{Abrupt, Desc, ErrorType, Interp, JsResult};
use crate::value::{Str, Value, index_key, number_to_str, to_uint32};
fn alloc(interp: &mut Interp, class: Class, proto: Option<ObjId>) -> ObjId {
interp.heap.alloc_object(Object::new(class, proto))
@@ -42,12 +55,16 @@ pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId,
}
/// Creates the built-ins and the global object's properties. The caller installs them before it
/// sets the realm's limits, so no limit stops the install.
/// sets the realm's limits, so no limit stops the install. If it fails, the failure is kept in
/// `interp.install_error` and every run of the realm ends with it.
pub(crate) fn install(interp: &mut Interp) {
if let Err(e) = try_install(interp) {
// Installing touches only objects it has just made, so this does not happen; a realm
// that somehow lacks a built-in reports it when a script uses it.
let _ = e;
let reason = match e {
Abrupt::Throw(v) => interp.describe(&v),
Abrupt::Limit(kind) => format!("limit reached: {kind:?}"),
Abrupt::Internal(message) => message,
};
interp.install_error = Some(format!("the built-ins could not be installed: {reason}"));
}
}
@@ -68,7 +85,8 @@ fn try_install(interp: &mut Interp) -> JsResult<()> {
interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto));
interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto));
interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto));
interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto));
let boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto));
interp.intrinsics.boolean_proto = boolean_proto;
interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto));
let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?;
if let Some(t) = interp.heap.object_mut(thrower) {
@@ -98,15 +116,189 @@ fn try_install(interp: &mut Interp) -> JsResult<()> {
data(interp, function_proto, "call", Value::Object(call), HIDDEN)?;
let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?;
data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?;
method(interp, function_proto, "bind", function_bind, 1)?;
method(interp, function_proto, "toString", function_to_string, 0)?;
method(interp, boolean_proto, "toString", boolean_to_string, 0)?;
method(interp, boolean_proto, "valueOf", boolean_value_of, 0)?;
install_errors(interp, object_proto)?;
object::install(interp, object)?;
array::install(interp)?;
math::install(interp)?;
json::install(interp)?;
global::install(interp)?;
crate::string_builtins::install(interp)?;
crate::number_builtins::install(interp)?;
crate::regexp_builtins::install(interp)?;
crate::date_builtins::install(interp)
}
// ---- helpers for the built-ins ----
/// ToInteger (section 9.4).
fn to_integer(interp: &mut Interp, v: &Value) -> JsResult<f64> {
let n = interp.to_number(v)?;
Ok(if n.is_nan() { 0.0 } else { n.trunc() })
}
/// The property name of the index `n`, which may be past the largest array index.
fn key_of(n: f64) -> Key {
if (0.0..4_294_967_295.0).contains(&n) && n.fract() == 0.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
return index_key(n as u32);
}
number_to_str(n).flat()
}
/// ToUint32 of an object's `length`.
fn length_of(interp: &mut Interp, o: ObjId) -> JsResult<u32> {
let len = interp.get_from(o, &key("length"), &Value::Object(o))?;
Ok(to_uint32(interp.to_number(&len)?))
}
fn get_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<Value> {
interp.get_from(o, &key_of(k), &Value::Object(o))
}
fn has_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<bool> {
interp.has_property(o, &key_of(k))
}
/// The estimated bytes a new element adds.
const ELEMENT_BYTES: usize = PROP_BYTES + 32 + 2 * 10;
/// `o[k] = v` as the array methods assign: a failure throws. When `o` has no own property `k`, `v`
/// stays rooted while heap is reserved for the new element, since the caller may hold it nowhere
/// else; overwriting an own property reserves nothing.
pub(crate) fn put_at(interp: &mut Interp, o: ObjId, k: f64, v: Value) -> JsResult<()> {
let k = key_of(k);
if interp.heap.object(o).is_some_and(|obj| obj.props.contains(&k)) {
return interp.put(o, k, v, true);
}
interp.rooted(std::slice::from_ref(&v), |i| {
i.reserve_heap(ELEMENT_BYTES)?;
i.put(o, k.clone(), v.clone(), true)
})
}
fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> {
interp.delete(o, &key_of(k), true).map(drop)
}
/// Defines `a[k]` as a writable, enumerable and configurable data property, as the methods that
/// build a new array do (DefineOwnProperty with `throw` false). `v` stays rooted while heap is
/// reserved for it.
fn create_at(interp: &mut Interp, a: ObjId, k: f64, v: Value) -> JsResult<()> {
interp.rooted(std::slice::from_ref(&v), |i| {
i.reserve_heap(ELEMENT_BYTES)?;
i.define_own_property(a, key_of(k), &Desc::data(v.clone(), OPEN), false).map(drop)
})
}
/// A new array with the given length and no elements.
fn new_array(interp: &mut Interp, length: u32) -> JsResult<ObjId> {
interp.reserve_heap(OBJECT_BYTES)?;
let proto = interp.intrinsics.array_proto;
Ok(interp.heap.alloc_object(Object::new(Class::Array { length, length_writable: true }, Some(proto))))
}
/// A new string from code units, checked against the heap limit and charged to the heap.
fn new_string(interp: &mut Interp, units: Vec<u16>) -> JsResult<Value> {
let bytes = string_bytes(units.len());
interp.reserve_heap(bytes)?;
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
Ok(Value::String(Str::from_units(units)))
}
/// Code units a built-in may write into a string for one unit of fuel.
const UNITS_PER_FUEL: usize = 16;
/// A string the built-ins build piece by piece: each piece takes fuel for its length, is checked
/// against the longest string the heap limit allows, and has its bytes reserved before it is
/// added. The buffer's bytes count toward the heap until `finish` or until the native call that
/// made the builder returns. Everything the caller still needs must be reachable from the roots.
struct Builder {
units: Vec<u16>,
/// Code units already reserved and charged to the heap.
charged: usize,
/// Code units written since fuel was last taken, fewer than `UNITS_PER_FUEL`.
unpaid: usize,
}
impl Builder {
fn new() -> Self {
Builder { units: Vec::new(), charged: 0, unpaid: 0 }
}
/// Checks that `more` code units fit, reserving heap for them, and takes fuel for writing them.
fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> {
let want = self.units.len().saturating_add(more);
if want > interp.limits.heap_bytes / 4 {
return Err(Abrupt::Limit(crate::LimitKind::Heap));
}
self.unpaid += more;
let fuel = (self.unpaid / UNITS_PER_FUEL) as u64;
self.unpaid %= UNITS_PER_FUEL;
if fuel > 0 {
if interp.fuel < fuel {
interp.fuel = 0;
return Err(Abrupt::Limit(crate::LimitKind::Fuel));
}
// The last unit goes through `use_fuel`, which also checks for an interrupt.
interp.fuel -= fuel - 1;
interp.use_fuel()?;
}
if want > self.charged {
let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged));
interp.reserve_heap(2 * grow)?;
interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow);
interp.held_bytes = interp.held_bytes.saturating_add(2 * grow);
self.charged += grow;
}
Ok(())
}
fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> {
self.make_room(interp, units.len())?;
self.units.extend_from_slice(units);
Ok(())
}
/// Appends `units` `count` times. Nothing is written for an empty `units`, however large
/// `count` is.
fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> {
if units.is_empty() || count == 0 {
return Ok(());
}
let more = usize::try_from(count).unwrap_or(usize::MAX).saturating_mul(units.len());
self.make_room(interp, more)?;
for _ in 0..count {
self.units.extend_from_slice(units);
}
Ok(())
}
fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> {
let units: Vec<u16> = s.encode_utf16().collect();
self.push_units(interp, &units)
}
/// The string built, whose bytes were charged as it grew. From here on the heap counts them
/// as the string's.
fn finish(self, interp: &mut Interp) -> Value {
interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged);
Value::String(Str::from_units(self.units))
}
}
fn require_callable(interp: &mut Interp, v: &Value, what: &str) -> JsResult<()> {
if interp.is_callable(v) {
return Ok(());
}
Err(interp.type_error(&format!("{what} is not a function")))
}
fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> {
let kinds: [(&'static str, NativeFn); 7] = [
("Error", error_ctor),
@@ -177,13 +369,40 @@ fn object_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> J
Ok(Value::str(&format!("[object {class}]")))
}
/// The Boolean a `Boolean.prototype` method was called on (sections 15.6.4.2 and 15.6.4.3): a
/// primitive, or the primitive value of a Boolean object.
fn this_boolean(interp: &mut Interp, this: &Value, name: &str) -> JsResult<bool> {
let b = match this {
Value::Bool(b) => Some(*b),
Value::Object(o) => match interp.heap.object(*o).map(|x| &x.class) {
Some(Class::Boolean(b)) => Some(*b),
_ => None,
},
_ => None,
};
b.ok_or_else(|| interp.type_error(&format!("Boolean.prototype.{name} was called on a value that is not a Boolean")))
}
/// `Boolean.prototype.toString` (section 15.6.4.2).
fn boolean_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let b = this_boolean(interp, &this, "toString")?;
Ok(Value::str(if b { "true" } else { "false" }))
}
/// `Boolean.prototype.valueOf` (section 15.6.4.3).
fn boolean_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
this_boolean(interp, &this, "valueOf").map(Value::Bool)
}
/// `Object.prototype.toLocaleString` (section 15.2.4.3).
fn object_to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&this)?;
let f = interp.get_from(o, &key("toString"), &this)?;
let receiver = Value::Object(o);
let f = interp.get_from(o, &key("toString"), &receiver)?;
if !interp.is_callable(&f) {
return Err(interp.type_error("toString is not a function"));
}
interp.call(&f, &this, &[])
interp.call(&f, &receiver, &[])
}
fn object_value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
@@ -283,11 +502,40 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) ->
}
Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"),
Some(Func::Bound { .. }) => "function () { [native code] }".to_string(),
None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")),
};
Ok(Value::str(&text))
}
/// `Function.prototype.bind` (section 15.3.4.5).
fn function_bind(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
require_callable(interp, &this, "the value bind was called on")?;
let Some(target) = this.as_object() else { return Err(interp.type_error("the value bind was called on is not a function")) };
let bound_this = arg(args, 0);
let bound_args: Rc<[Value]> = args.get(1..).unwrap_or_default().into();
// A host function's `length` is an ordinary property a script can set, so it is converted
// to an integer first, as later editions specify.
let target_length = match interp.get_from(target, &key("length"), &this)? {
Value::Number(n) if n.is_nan() => 0.0,
Value::Number(n) => n.trunc(),
_ => 0.0,
};
// Adding 0 turns a negative zero into 0.
let length = (target_length - bound_args.len() as f64).max(0.0) + 0.0;
interp.reserve_heap(OBJECT_BYTES + 4 * PROP_BYTES + BOUND_ARG_BYTES * bound_args.len())?;
let proto = interp.intrinsics.function_proto;
let class = Class::Function(Func::Bound { target, this: bound_this, args: bound_args.clone() });
let f = interp.heap.alloc_object(Object::new(class, Some(proto)));
interp.heap.bytes = interp.heap.bytes.saturating_add(BOUND_ARG_BYTES * bound_args.len());
interp.define(f, key("length"), Prop::Data { value: Value::Number(length), flags: 0 })?;
let thrower = Some(interp.intrinsics.throw_type_error);
for name in ["caller", "arguments"] {
interp.define(f, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?;
}
Ok(Value::Object(f))
}
/// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global
/// environment.
fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
+854
View File
@@ -0,0 +1,854 @@
//! `Array` (ECMA-262 5.1 section 15.4): the constructor, `Array.isArray` and every method of
//! `Array.prototype`. The methods are generic: they read `length` and the elements of any object.
//!
//! A method that walks a range of indices visits only the indices that can hold an element when
//! the range is much longer than the object has properties (a sparse array such as
//! `new Array(4294967295)`), so a long empty range costs nothing and no index is allocated for
//! it. The list of indices is made again whenever a script adds an index property to any object
//! while the method runs. Every index visited takes a unit of fuel.
use super::{
Builder, ELEMENT_BYTES, arg, constructor, create_at, delete_at, get_at, has_at, length_of, method, native, new_array, object_to_string, put_at,
require_callable, to_integer,
};
use crate::heap::{Class, NativeKind, ObjId};
use crate::interp::{Abrupt, Interp, JsResult, strict_eq};
use crate::value::{Str, Value, to_uint32};
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.array_proto;
let array = native(interp, "Array", array_ctor, 1, NativeKind::Plain, true)?;
constructor(interp, "Array", array, proto)?;
method(interp, array, "isArray", is_array, 1)?;
let methods: [(&'static str, crate::heap::NativeFn, u32); 21] = [
("toString", to_string, 0),
("toLocaleString", to_locale_string, 0),
("concat", concat, 1),
("join", join, 1),
("pop", pop, 0),
("push", push, 1),
("reverse", reverse, 0),
("shift", shift, 0),
("slice", slice, 2),
("sort", sort, 1),
("splice", splice, 2),
("unshift", unshift, 1),
("indexOf", index_of, 1),
("lastIndexOf", last_index_of, 1),
("every", every, 1),
("some", some, 1),
("forEach", for_each, 1),
("map", map, 1),
("filter", filter, 1),
("reduce", reduce, 1),
("reduceRight", reduce_right, 1),
];
for (name, f, length) in methods {
method(interp, proto, name, f, length)?;
}
Ok(())
}
// ---- visiting indices ----
/// Ranges at most this long are visited index by index.
const DENSE_SPAN: u64 = 4096;
/// How an index an object holds names an index to visit.
#[derive(Clone, Copy)]
enum Map {
/// The index `p` asks for a visit of `p - shift`.
Shift(i64),
/// The index `p` asks for a visit of `p` and of `mirror - p`.
Mirror(i64),
}
/// The indices of a range, in order, that an operation must visit.
struct Indices {
obj: ObjId,
/// The range left to visit, `lo` included and `hi` not.
lo: u64,
hi: u64,
ascending: bool,
maps: Vec<Map>,
/// The indices left when the range is visited sparsely.
sparse: Option<Sparse>,
}
struct Sparse {
list: Vec<u64>,
pos: usize,
/// [`crate::heap::Heap::index_epoch`] when the list was made.
epoch: u64,
}
/// The canonical numeric property name `k` stands for, up to 15 digits.
fn numeric_index(k: &[u16]) -> Option<u64> {
if k.is_empty() || k.len() > 15 || (k.len() > 1 && k.first() == Some(&u16::from(b'0'))) {
return None;
}
let mut n = 0u64;
for &u in k {
let d = u.checked_sub(u16::from(b'0')).filter(|d| *d <= 9)?;
n = n * 10 + u64::from(d);
}
Some(n)
}
/// The objects of the prototype chain from `obj`, with how many properties (and String object
/// indices) each holds.
fn chain(interp: &Interp, obj: ObjId) -> Vec<(ObjId, usize)> {
let mut out = Vec::new();
let mut cur = Some(obj);
while let Some(id) = cur {
let Some(o) = interp.heap.object(id) else { break };
let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 };
out.push((id, o.props.len().saturating_add(string_len)));
cur = o.proto;
}
out
}
impl Indices {
fn new(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64, ascending: bool, maps: Vec<Map>) -> JsResult<Self> {
let mut it = Indices { obj, lo, hi, ascending, maps, sparse: None };
if hi.saturating_sub(lo) > DENSE_SPAN {
let present: usize = chain(interp, obj).iter().map(|(_, n)| *n).fold(0, usize::saturating_add);
if hi - lo > (present as u64).saturating_mul(8) {
it.sparse = Some(it.gather(interp)?);
}
}
Ok(it)
}
fn up(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult<Self> {
Self::new(interp, obj, lo, hi, true, vec![Map::Shift(0)])
}
fn down(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult<Self> {
Self::new(interp, obj, lo, hi, false, vec![Map::Shift(0)])
}
/// Lists the indices left to visit from the properties of the object and its prototypes.
/// Each property takes a unit of fuel.
fn gather(&self, interp: &mut Interp) -> JsResult<Sparse> {
let epoch = interp.heap.index_epoch;
let mut list: Vec<u64> = Vec::new();
for (id, n) in chain(interp, self.obj) {
for _ in 0..n {
interp.use_fuel()?;
}
interp.reserve_heap(n.saturating_mul(8).saturating_mul(self.maps.len() + 1))?;
let Some(o) = interp.heap.object(id) else { continue };
let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 };
let present = (0..string_len as u64).chain(o.props.iter().filter_map(|(k, _)| numeric_index(k)));
for p in present {
for map in &self.maps {
let p = p as i64;
let ks = match *map {
Map::Shift(s) => [Some(p - s), None],
Map::Mirror(m) => [Some(p), Some(m - p)],
};
for k in ks.into_iter().flatten() {
if k >= 0 && (k as u64) >= self.lo && (k as u64) < self.hi {
list.push(k as u64);
}
}
}
}
}
if self.ascending {
list.sort_unstable();
} else {
list.sort_unstable_by(|a, b| b.cmp(a));
}
list.dedup();
Ok(Sparse { list, pos: 0, epoch })
}
/// The next index to visit; it takes a unit of fuel.
fn next(&mut self, interp: &mut Interp) -> JsResult<Option<u64>> {
if self.lo >= self.hi {
return Ok(None);
}
if self.sparse.as_ref().is_some_and(|s| s.epoch != interp.heap.index_epoch) {
self.sparse = Some(self.gather(interp)?);
}
let k = match &mut self.sparse {
None if self.ascending => self.lo,
None => self.hi - 1,
Some(s) => {
let Some(&k) = s.list.get(s.pos) else {
self.lo = self.hi;
return Ok(None);
};
s.pos += 1;
k
}
};
if self.ascending {
self.lo = k + 1;
} else {
self.hi = k;
}
interp.use_fuel()?;
Ok(Some(k))
}
}
/// The element at `k` when the object or its prototypes have one.
fn element(interp: &mut Interp, o: ObjId, k: u64) -> JsResult<Option<Value>> {
let k = k as f64;
if !has_at(interp, o, k)? {
return Ok(None);
}
get_at(interp, o, k).map(Some)
}
/// ToObject of `this`, kept reachable while `f` runs.
fn with_this<T>(interp: &mut Interp, this: &Value, f: impl FnOnce(&mut Interp, ObjId) -> JsResult<T>) -> JsResult<T> {
let o = interp.to_object(this)?;
interp.rooted(&[Value::Object(o)], |interp| f(interp, o))
}
/// The start a relative index gives within `0..=len` (`slice` and `splice`).
fn relative(interp: &mut Interp, v: &Value, len: u32) -> JsResult<u64> {
let n = to_integer(interp, v)?;
let len = f64::from(len);
Ok(if n < 0.0 { (len + n).max(0.0) } else { n.min(len) } as u64)
}
fn number(k: u64) -> Value {
Value::Number(k as f64)
}
// ---- the constructor ----
/// `Array(…)` and `new Array(…)` (sections 15.4.1 and 15.4.2).
fn array_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
if let [Value::Number(n)] = args {
let len = to_uint32(*n);
if f64::from(len) != *n {
return Err(interp.throw(crate::interp::ErrorType::Range, "invalid array length"));
}
return new_array(interp, len).map(Value::Object);
}
let a = new_array(interp, 0)?;
interp.rooted(&[Value::Object(a)], |interp| {
for (i, v) in args.iter().enumerate() {
interp.use_fuel()?;
create_at(interp, a, i as f64, v.clone())?;
}
Ok(Value::Object(a))
})
}
/// `Array.isArray` (section 15.4.3.2).
fn is_array(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let a = arg(args, 0).as_object().and_then(|o| interp.heap.object(o)).is_some_and(|o| matches!(o.class, Class::Array { .. }));
Ok(Value::Bool(a))
}
// ---- conversions ----
/// `Array.prototype.toString` (section 15.4.4.2).
fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let join = interp.get_from(o, &crate::heap::key("join"), &Value::Object(o))?;
if interp.is_callable(&join) {
return interp.call(&join, &Value::Object(o), &[]);
}
object_to_string(interp, Value::Object(o), &[], false)
})
}
/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `separator`
/// between them (`,` when it is `undefined`). `length` is read before the separator is converted,
/// and the separator is converted even when `length` is 0 (section 15.4.4.5 steps 2 to 6). A cycle
/// back to an object being joined joins as an empty string.
fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Interp, Value) -> JsResult<Option<Str>>) -> JsResult<Value> {
if interp.join_stack.contains(&o) {
return Ok(Value::str(""));
}
let len = length_of(interp, o)?;
let sep = match separator {
Value::Undefined => Str::from(","),
v => interp.to_str(v)?,
};
if len == 0 {
return Ok(Value::str(""));
}
let flat = sep.flat();
let sep = &flat[..];
// A result that cannot fit is refused before any element is read.
let least = (sep.len() as u64).saturating_mul(u64::from(len - 1));
if least > (interp.limits.heap_bytes / 4) as u64 {
return Err(Abrupt::Limit(crate::LimitKind::Heap));
}
interp.join_stack.push(o);
let r = join_elements(interp, o, len, sep, item);
interp.join_stack.pop();
r
}
fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult<Option<Str>>) -> JsResult<Value> {
let mut out = Builder::new();
// Separators written so far: element `k` follows `k` of them.
let mut seps = 0u64;
let mut it = Indices::up(interp, o, 0, u64::from(len))?;
while let Some(k) = it.next(interp)? {
let v = get_at(interp, o, k as f64)?;
let Some(s) = item(interp, v)? else { continue };
out.push_repeated(interp, sep, k.saturating_sub(seps))?;
seps = k;
out.push_units(interp, &s.flat())?;
}
out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?;
Ok(out.finish(interp))
}
/// An element as `join` writes it: nothing for `undefined` and `null`.
fn join_item(interp: &mut Interp, v: Value) -> JsResult<Option<Str>> {
if v.is_nullish() {
return Ok(None);
}
interp.to_str(&v).map(Some)
}
/// An element as `toLocaleString` writes it (section 15.4.4.3).
fn locale_item(interp: &mut Interp, v: Value) -> JsResult<Option<Str>> {
if v.is_nullish() {
return Ok(None);
}
let e = interp.to_object(&v)?;
let f = interp.get_from(e, &crate::heap::key("toLocaleString"), &Value::Object(e))?;
require_callable(interp, &f, "an element's toLocaleString")?;
let s = interp.call(&f, &Value::Object(e), &[])?;
interp.to_str(&s).map(Some)
}
/// `Array.prototype.join` (section 15.4.4.5).
fn join(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let separator = arg(args, 0);
with_this(interp, &this, |interp, o| join_with(interp, o, &separator, join_item))
}
/// `Array.prototype.toLocaleString` (section 15.4.4.3).
fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| join_with(interp, o, &Value::Undefined, locale_item))
}
// ---- methods that change the object ----
/// `Array.prototype.concat` (section 15.4.4.4).
fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let a = new_array(interp, 0)?;
interp.rooted(&[Value::Object(a)], |interp| {
let mut n = 0u64;
for e in std::iter::once(Value::Object(o)).chain(args.iter().cloned()) {
interp.use_fuel()?;
let spread = e.as_object().filter(|&id| interp.heap.object(id).is_some_and(|x| matches!(x.class, Class::Array { .. })));
let Some(e) = spread else {
create_at(interp, a, n as f64, e)?;
n += 1;
continue;
};
let len = u64::from(length_of(interp, e)?);
let mut it = Indices::up(interp, e, 0, len)?;
while let Some(k) = it.next(interp)? {
if let Some(v) = element(interp, e, k)? {
create_at(interp, a, (n + k) as f64, v)?;
}
}
n += len;
}
set_length(interp, a, n as f64)?;
Ok(Value::Object(a))
})
})
}
fn set_length(interp: &mut Interp, o: ObjId, n: f64) -> JsResult<()> {
interp.put(o, crate::heap::key("length"), Value::Number(n), true)
}
/// `Array.prototype.pop` (section 15.4.4.6).
fn pop(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = length_of(interp, o)?;
if len == 0 {
set_length(interp, o, 0.0)?;
return Ok(Value::Undefined);
}
let last = f64::from(len - 1);
let v = get_at(interp, o, last)?;
interp.rooted(std::slice::from_ref(&v), |interp| {
delete_at(interp, o, last)?;
set_length(interp, o, last)
})?;
Ok(v)
})
}
/// `Array.prototype.push` (section 15.4.4.7).
fn push(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let mut n = f64::from(length_of(interp, o)?);
for v in args {
interp.use_fuel()?;
put_at(interp, o, n, v.clone())?;
n += 1.0;
}
set_length(interp, o, n)?;
Ok(Value::Number(n))
})
}
/// `Array.prototype.reverse` (section 15.4.4.8).
fn reverse(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
let middle = len / 2;
let mirror = len as i64 - 1;
let mut it = Indices::new(interp, o, 0, middle, true, vec![Map::Mirror(mirror)])?;
while let Some(lower) = it.next(interp)? {
let upper = len - lower - 1;
let lower_value = element(interp, o, lower)?;
let upper_value = interp.rooted(lower_value.as_slice(), |interp| element(interp, o, upper))?;
let mut roots: Vec<Value> = lower_value.iter().cloned().collect();
roots.extend(upper_value.iter().cloned());
interp.rooted(&roots, |interp| match (lower_value, upper_value) {
(Some(l), Some(u)) => {
put_at(interp, o, lower as f64, u)?;
put_at(interp, o, upper as f64, l)
}
(None, Some(u)) => {
put_at(interp, o, lower as f64, u)?;
delete_at(interp, o, upper as f64)
}
(Some(l), None) => {
delete_at(interp, o, lower as f64)?;
put_at(interp, o, upper as f64, l)
}
(None, None) => Ok(()),
})?;
}
Ok(Value::Object(o))
})
}
/// Moves the element at `from` to `to`, or deletes `to` when there is none at `from`.
fn move_element(interp: &mut Interp, o: ObjId, from: u64, to: u64) -> JsResult<()> {
match element(interp, o, from)? {
Some(v) => put_at(interp, o, to as f64, v),
None => delete_at(interp, o, to as f64),
}
}
/// `Array.prototype.shift` (section 15.4.4.9).
fn shift(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
if len == 0 {
set_length(interp, o, 0.0)?;
return Ok(Value::Undefined);
}
let first = get_at(interp, o, 0.0)?;
interp.rooted(std::slice::from_ref(&first), |interp| {
let mut it = Indices::new(interp, o, 1, len, true, vec![Map::Shift(0), Map::Shift(-1)])?;
while let Some(k) = it.next(interp)? {
move_element(interp, o, k, k - 1)?;
}
delete_at(interp, o, (len - 1) as f64)?;
set_length(interp, o, (len - 1) as f64)
})?;
Ok(first)
})
}
/// `Array.prototype.unshift` (section 15.4.4.13).
fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
let count = args.len() as u64;
// With no arguments each element is still read and written back in place (steps 6 and 7).
let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)];
let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?;
while let Some(k) = it.next(interp)? {
move_element(interp, o, k - 1, k + count - 1)?;
}
for (j, v) in args.iter().enumerate() {
interp.use_fuel()?;
put_at(interp, o, j as f64, v.clone())?;
}
let n = (len + count) as f64;
set_length(interp, o, n)?;
Ok(Value::Number(n))
})
}
/// `Array.prototype.slice` (section 15.4.4.10).
fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = length_of(interp, o)?;
let start = relative(interp, &arg(args, 0), len)?;
let end = match arg(args, 1) {
Value::Undefined => u64::from(len),
v => relative(interp, &v, len)?,
};
let a = new_array(interp, 0)?;
interp.rooted(&[Value::Object(a)], |interp| {
let mut it = Indices::up(interp, o, start, end)?;
while let Some(k) = it.next(interp)? {
if let Some(v) = element(interp, o, k)? {
create_at(interp, a, (k - start) as f64, v)?;
}
}
set_length(interp, a, end.saturating_sub(start) as f64)?;
Ok(Value::Object(a))
})
})
}
/// `Array.prototype.splice` (section 15.4.4.12).
fn splice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = length_of(interp, o)?;
let start = relative(interp, &arg(args, 0), len)?;
let len = u64::from(len);
let delete_count = match args.get(1) {
// Without a count, everything from the start is deleted, as later editions and
// every implementation do.
None if !args.is_empty() => len - start,
None => 0,
Some(v) => to_integer(interp, v)?.max(0.0).min((len - start) as f64) as u64,
};
let items = args.get(2..).unwrap_or_default();
let a = new_array(interp, 0)?;
interp.rooted(&[Value::Object(a)], |interp| {
let mut it = Indices::up(interp, o, start, start + delete_count)?;
while let Some(k) = it.next(interp)? {
if let Some(v) = element(interp, o, k)? {
create_at(interp, a, (k - start) as f64, v)?;
}
}
set_length(interp, a, delete_count as f64)?;
move_tail(interp, o, len, start, delete_count, items.len() as u64)?;
for (j, v) in items.iter().enumerate() {
interp.use_fuel()?;
put_at(interp, o, (start + j as u64) as f64, v.clone())?;
}
set_length(interp, o, (len - delete_count + items.len() as u64) as f64)?;
Ok(Value::Object(a))
})
})
}
/// Moves the elements after a spliced range so that `item_count` items replace `delete_count`
/// elements at `start` (section 15.4.4.12, steps 12 and 13).
fn move_tail(interp: &mut Interp, o: ObjId, len: u64, start: u64, delete_count: u64, item_count: u64) -> JsResult<()> {
let (dc, ic) = (delete_count as i64, item_count as i64);
if item_count < delete_count {
let mut it = Indices::new(interp, o, start, len - delete_count, true, vec![Map::Shift(dc), Map::Shift(ic)])?;
while let Some(k) = it.next(interp)? {
move_element(interp, o, k + delete_count, k + item_count)?;
}
let mut it = Indices::down(interp, o, len - delete_count + item_count, len)?;
while let Some(k) = it.next(interp)? {
delete_at(interp, o, k as f64)?;
}
} else if item_count > delete_count {
let mut it = Indices::new(interp, o, start + 1, len - delete_count + 1, false, vec![Map::Shift(dc - 1), Map::Shift(ic - 1)])?;
while let Some(k) = it.next(interp)? {
move_element(interp, o, k + delete_count - 1, k + item_count - 1)?;
}
}
Ok(())
}
// ---- sort ----
/// A stable merge sort whose comparison may fail. `greater(a, b)` says whether `a` sorts after
/// `b`; each comparison takes a unit of fuel.
fn merge_sort<T: Clone>(interp: &mut Interp, items: &mut Vec<T>, mut greater: impl FnMut(&mut Interp, &T, &T) -> JsResult<bool>) -> JsResult<()> {
let n = items.len();
let mut width = 1;
let mut buf: Vec<T> = Vec::with_capacity(n);
while width < n {
buf.clear();
let mut lo = 0;
while lo < n {
let mid = (lo + width).min(n);
let hi = (lo + 2 * width).min(n);
let (mut i, mut j) = (lo, mid);
while i < mid && j < hi {
let (Some(a), Some(b)) = (items.get(i), items.get(j)) else { break };
interp.use_fuel()?;
if greater(interp, a, b)? {
buf.push(b.clone());
j += 1;
} else {
buf.push(a.clone());
i += 1;
}
}
buf.extend(items.get(i..mid).unwrap_or_default().iter().cloned());
buf.extend(items.get(j..hi).unwrap_or_default().iter().cloned());
lo = hi;
}
std::mem::swap(items, &mut buf);
width *= 2;
}
Ok(())
}
/// `Array.prototype.sort` (section 15.4.4.11). The elements are read, sorted with `undefined`
/// after every other value, written back from index 0, and the indices after them are deleted.
fn sort(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
let compare = arg(args, 0);
if !matches!(compare, Value::Undefined) {
require_callable(interp, &compare, "the comparison")?;
}
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
let mark = interp.temp_roots.len();
let r = sort_elements(interp, o, len, &compare);
interp.temp_roots.truncate(mark);
r?;
Ok(Value::Object(o))
})
}
fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> JsResult<()> {
let mut values = Vec::new();
let mut undefined = 0u64;
let mut it = Indices::up(interp, o, 0, len)?;
while let Some(k) = it.next(interp)? {
match element(interp, o, k)? {
Some(Value::Undefined) => undefined += 1,
Some(v) => {
interp.temp_roots.push(v.clone());
interp.reserve_heap(ELEMENT_BYTES)?;
values.push(v);
}
None => {}
}
}
if matches!(compare, Value::Undefined) {
let mut keyed = Vec::with_capacity(values.len());
for v in values {
interp.use_fuel()?;
let s = interp.to_str(&v)?;
// Flattening a concatenation makes a new buffer, so it is charged. A key that is
// not an element is rooted so that collections count it.
let rope = s.buffer_addr().is_none();
let bytes = crate::heap::string_bytes(s.len());
if rope {
interp.reserve_heap(bytes)?;
}
let units = s.flat();
if rope {
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
}
if !matches!(v, Value::String(_)) {
interp.temp_roots.push(Value::String(s));
}
keyed.push((units, v));
}
merge_sort(interp, &mut keyed, |_, a, b| Ok(a.0 > b.0))?;
values = keyed.into_iter().map(|(_, v)| v).collect();
} else {
merge_sort(interp, &mut values, |interp, a, b| {
let r = interp.call(compare, &Value::Undefined, &[a.clone(), b.clone()])?;
Ok(interp.to_number(&r)? > 0.0)
})?;
}
let mut i = 0u64;
for v in values {
interp.use_fuel()?;
put_at(interp, o, i as f64, v)?;
i += 1;
}
for _ in 0..undefined {
interp.use_fuel()?;
put_at(interp, o, i as f64, Value::Undefined)?;
i += 1;
}
let mut it = Indices::down(interp, o, i, len)?;
while let Some(k) = it.next(interp)? {
delete_at(interp, o, k as f64)?;
}
Ok(())
}
// ---- searching ----
/// `Array.prototype.indexOf` (section 15.4.4.14).
fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = f64::from(length_of(interp, o)?);
if len == 0.0 {
return Ok(Value::Number(-1.0));
}
let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { 0.0 };
if n >= len {
return Ok(Value::Number(-1.0));
}
let start = if n >= 0.0 { n } else { (len + n).max(0.0) };
let target = arg(args, 0);
let mut it = Indices::up(interp, o, start as u64, len as u64)?;
while let Some(k) = it.next(interp)? {
if let Some(v) = element(interp, o, k)?
&& strict_eq(&v, &target)
{
return Ok(number(k));
}
}
Ok(Value::Number(-1.0))
})
}
/// `Array.prototype.lastIndexOf` (section 15.4.4.15).
fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = f64::from(length_of(interp, o)?);
if len == 0.0 {
return Ok(Value::Number(-1.0));
}
let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { len - 1.0 };
let from = if n >= 0.0 { n.min(len - 1.0) } else { len + n };
if from < 0.0 {
return Ok(Value::Number(-1.0));
}
let target = arg(args, 0);
let mut it = Indices::down(interp, o, 0, from as u64 + 1)?;
while let Some(k) = it.next(interp)? {
if let Some(v) = element(interp, o, k)?
&& strict_eq(&v, &target)
{
return Ok(number(k));
}
}
Ok(Value::Number(-1.0))
})
}
// ---- iteration ----
/// What an iteration method does with each callback result.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Each {
Every,
Some,
/// `forEach`.
Visit,
Map,
Filter,
}
/// `every`, `some`, `forEach`, `map` and `filter` (sections 15.4.4.16 to 15.4.4.20).
fn iterate(interp: &mut Interp, this: Value, args: &[Value], kind: Each) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = length_of(interp, o)?;
let callback = arg(args, 0);
require_callable(interp, &callback, "the callback")?;
let this_arg = arg(args, 1);
let a = match kind {
Each::Map => Some(new_array(interp, len)?),
Each::Filter => Some(new_array(interp, 0)?),
_ => None,
};
let roots: Vec<Value> = a.map(Value::Object).into_iter().collect();
interp.rooted(&roots, |interp| {
let mut to = 0u64;
let mut it = Indices::up(interp, o, 0, u64::from(len))?;
while let Some(k) = it.next(interp)? {
let Some(v) = element(interp, o, k)? else { continue };
let r = interp.call(&callback, &this_arg, &[v.clone(), number(k), Value::Object(o)])?;
match (kind, a) {
(Each::Every, _) if !r.truthy() => return Ok(Value::Bool(false)),
(Each::Some, _) if r.truthy() => return Ok(Value::Bool(true)),
(Each::Map, Some(a)) => create_at(interp, a, k as f64, r)?,
(Each::Filter, Some(a)) if r.truthy() => {
create_at(interp, a, to as f64, v)?;
to += 1;
}
_ => {}
}
}
Ok(match (kind, a) {
(Each::Every, _) => Value::Bool(true),
(Each::Some, _) => Value::Bool(false),
(_, Some(a)) => Value::Object(a),
_ => Value::Undefined,
})
})
})
}
fn every(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
iterate(interp, this, args, Each::Every)
}
fn some(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
iterate(interp, this, args, Each::Some)
}
fn for_each(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
iterate(interp, this, args, Each::Visit)
}
fn map(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
iterate(interp, this, args, Each::Map)
}
fn filter(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
iterate(interp, this, args, Each::Filter)
}
/// `reduce` and `reduceRight` (sections 15.4.4.21 and 15.4.4.22).
fn fold(interp: &mut Interp, this: Value, args: &[Value], right: bool) -> JsResult<Value> {
with_this(interp, &this, |interp, o| {
let len = u64::from(length_of(interp, o)?);
let callback = arg(args, 0);
require_callable(interp, &callback, "the callback")?;
let mut it = Indices::new(interp, o, 0, len, !right, vec![Map::Shift(0)])?;
let slot = interp.temp_roots.len();
interp.temp_roots.push(Value::Undefined);
// The accumulator is kept in a root slot while the elements are read.
let keep = |interp: &mut Interp, acc: &Value| {
if let Some(s) = interp.temp_roots.get_mut(slot) {
*s = acc.clone();
}
};
let r = (|| {
let mut acc = match args.get(1) {
Some(v) => v.clone(),
None => loop {
let Some(k) = it.next(interp)? else { return Err(interp.type_error("reduce of an empty array with no initial value")) };
if let Some(v) = element(interp, o, k)? {
break v;
}
},
};
keep(interp, &acc);
while let Some(k) = it.next(interp)? {
let Some(v) = element(interp, o, k)? else { continue };
acc = interp.call(&callback, &Value::Undefined, &[acc, v, number(k), Value::Object(o)])?;
keep(interp, &acc);
}
Ok(acc)
})();
interp.temp_roots.truncate(slot);
r
})
}
fn reduce(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
fold(interp, this, args, false)
}
fn reduce_right(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value> {
fold(interp, this, args, true)
}
+364
View File
@@ -0,0 +1,364 @@
//! The global functions (ECMA-262 5.1 section 15.1.2 and 15.1.3): `parseInt`, `parseFloat`,
//! `isNaN`, `isFinite` and the URI functions, and Annex B's `escape` and `unescape` (sections
//! B.2.1 and B.2.2). Each code unit read takes a unit of fuel, and the strings they build are
//! checked against the heap limit as they grow.
use super::{Builder, arg, method};
use crate::heap::NativeFn;
use crate::interp::{ErrorType, Interp, JsResult};
use crate::value::{Value, is_str_space, to_int32};
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
let global = interp.global;
let functions: [(&'static str, NativeFn, u32); 10] = [
("parseInt", parse_int, 2),
("parseFloat", parse_float, 1),
("isNaN", is_nan, 1),
("isFinite", is_finite, 1),
("decodeURI", decode_uri, 1),
("decodeURIComponent", decode_uri_component, 1),
("encodeURI", encode_uri, 1),
("encodeURIComponent", encode_uri_component, 1),
("escape", escape, 1),
("unescape", unescape, 1),
];
for (name, f, length) in functions {
method(interp, global, name, f, length)?;
}
Ok(())
}
/// `s` after its leading white space and line terminators.
fn trim_start(s: &[u16]) -> &[u16] {
s.get(s.iter().position(|&u| !is_str_space(u)).unwrap_or(s.len())..).unwrap_or_default()
}
/// ToString of the first argument, as code units, with each one taking a unit of fuel. A
/// concatenation is flattened into a new buffer, which is charged to the heap.
pub(super) fn units_of(interp: &mut Interp, args: &[Value]) -> JsResult<std::rc::Rc<[u16]>> {
let s = interp.to_str(&arg(args, 0))?;
for _ in 0..s.len() {
interp.use_fuel()?;
}
let rope = s.buffer_addr().is_none();
let bytes = crate::heap::string_bytes(s.len());
if rope {
let mark = interp.temp_roots.len();
interp.temp_roots.push(Value::String(s.clone()));
let reserved = interp.reserve_heap(bytes);
interp.temp_roots.truncate(mark);
reserved?;
}
let units = s.flat();
if rope {
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
}
Ok(units)
}
fn digit_value(u: u16) -> Option<u32> {
char::from_u32(u32::from(u)).and_then(|c| c.to_digit(36))
}
/// `parseInt` (section 15.1.2.2).
fn parse_int(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = units_of(interp, args)?;
let radix = to_int32(interp.to_number(&arg(args, 1))?);
let mut rest = trim_start(&s);
let sign = match rest.first() {
Some(&u) if u == u16::from(b'-') => -1.0,
_ => 1.0,
};
if matches!(rest.first(), Some(&u) if u == u16::from(b'-') || u == u16::from(b'+')) {
rest = rest.get(1..).unwrap_or_default();
}
let mut r = radix as u32;
let mut strip_prefix = true;
if radix != 0 {
if !(2..=36).contains(&radix) {
return Ok(Value::Number(f64::NAN));
}
strip_prefix = radix == 16;
} else {
r = 10;
}
if strip_prefix
&& rest.len() >= 2
&& rest.first() == Some(&u16::from(b'0'))
&& matches!(rest.get(1), Some(&u) if u == u16::from(b'x') || u == u16::from(b'X'))
{
rest = rest.get(2..).unwrap_or_default();
r = 16;
}
let n = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).count();
let prefix = rest.get(..n).unwrap_or_default();
if prefix.is_empty() {
return Ok(Value::Number(f64::NAN));
}
let digits = prefix.iter().filter_map(|&u| digit_value(u));
let value = match r {
10 => parse_decimal(interp, prefix)?,
2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits, r).unwrap_or(f64::NAN),
// Other radices may be approximated (section 15.1.2.2, step 13).
_ => digits.fold(0.0, |n, d| n * f64::from(r) + f64::from(d)),
};
Ok(Value::Number(sign * value))
}
/// The number that `text`, ASCII decimal digits with an optional fraction and exponent, stands
/// for. The text is copied into a string of one byte per code unit for parsing, and that copy is
/// checked against the heap limit first.
fn parse_decimal(interp: &mut Interp, text: &[u16]) -> JsResult<f64> {
interp.reserve_heap(text.len())?;
let mut ascii = String::with_capacity(text.len());
ascii.extend(text.iter().filter_map(|&u| char::from_u32(u32::from(u))));
Ok(ascii.parse::<f64>().unwrap_or(f64::NAN))
}
/// The length of the longest prefix of `s` that is a StrDecimalLiteral without its sign and
/// without `Infinity`.
fn decimal_prefix(s: &[u16]) -> usize {
let is_digit = |i: usize| s.get(i).is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u));
let mut i = 0;
while is_digit(i) {
i += 1;
}
let mut mantissa = i;
if s.get(i) == Some(&u16::from(b'.')) {
let mut j = i + 1;
while is_digit(j) {
j += 1;
}
mantissa += j - i - 1;
if mantissa > 0 {
i = j;
}
}
if mantissa == 0 {
return 0;
}
if matches!(s.get(i), Some(&u) if u == u16::from(b'e') || u == u16::from(b'E')) {
let mut j = i + 1;
if matches!(s.get(j), Some(&u) if u == u16::from(b'+') || u == u16::from(b'-')) {
j += 1;
}
let digits_start = j;
while is_digit(j) {
j += 1;
}
if j > digits_start {
i = j;
}
}
i
}
/// `parseFloat` (section 15.1.2.3).
fn parse_float(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = units_of(interp, args)?;
let mut rest = trim_start(&s);
let mut sign = 1.0;
if let Some(&u) = rest.first()
&& (u == u16::from(b'-') || u == u16::from(b'+'))
{
if u == u16::from(b'-') {
sign = -1.0;
}
rest = rest.get(1..).unwrap_or_default();
}
let infinity: Vec<u16> = "Infinity".encode_utf16().collect();
if rest.starts_with(&infinity) {
return Ok(Value::Number(sign * f64::INFINITY));
}
let n = decimal_prefix(rest);
if n == 0 {
return Ok(Value::Number(f64::NAN));
}
Ok(Value::Number(sign * parse_decimal(interp, rest.get(..n).unwrap_or_default())?))
}
/// `isNaN` (section 15.1.2.4).
fn is_nan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_nan()))
}
/// `isFinite` (section 15.1.2.5).
fn is_finite(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_finite()))
}
// ---- URIs ----
const URI_RESERVED: &str = ";/?:@&=+$,";
const URI_MARK: &str = "-_.!~*'()";
fn in_set(u: u16, extra: &str) -> bool {
u < 0x80 && extra.as_bytes().contains(&(u as u8))
}
fn is_unreserved(u: u16) -> bool {
u < 0x80 && ((u as u8).is_ascii_alphanumeric() || in_set(u, URI_MARK))
}
fn uri_error(interp: &mut Interp, why: &str) -> crate::interp::Abrupt {
interp.throw(ErrorType::Uri, why)
}
const HEX: &[u8; 16] = b"0123456789ABCDEF";
fn push_byte(out: &mut Builder, interp: &mut Interp, b: u8) -> JsResult<()> {
let units = [u16::from(b'%'), u16::from(HEX[usize::from(b >> 4)]), u16::from(HEX[usize::from(b & 15)])];
out.push_units(interp, &units)
}
/// Encode (section 15.1.3): every code unit outside the unescaped set as the `%XX` escapes of
/// its UTF-8 bytes.
fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> JsResult<Value> {
let s = units_of(interp, args)?;
let mut out = Builder::new();
let mut k = 0;
while let Some(&c) = s.get(k) {
if unescaped(c) {
out.push_units(interp, &[c])?;
k += 1;
continue;
}
let v = match c {
0xDC00..=0xDFFF => return Err(uri_error(interp, "a lone trailing surrogate cannot be encoded")),
0xD800..=0xDBFF => match s.get(k + 1) {
Some(&d) if (0xDC00..=0xDFFF).contains(&d) => {
k += 1;
(u32::from(c) - 0xD800) * 0x400 + (u32::from(d) - 0xDC00) + 0x10000
}
_ => return Err(uri_error(interp, "a lone leading surrogate cannot be encoded")),
},
_ => u32::from(c),
};
let mut buf = [0u8; 4];
let Some(ch) = char::from_u32(v) else { return Err(uri_error(interp, "the string holds a code point that cannot be encoded")) };
for &b in ch.encode_utf8(&mut buf).as_bytes() {
push_byte(&mut out, interp, b)?;
}
k += 1;
}
Ok(out.finish(interp))
}
fn hex_byte(s: &[u16], at: usize) -> Option<u8> {
let hi = crate::chars::hex_value(u32::from(*s.get(at)?))?;
let lo = crate::chars::hex_value(u32::from(*s.get(at + 1)?))?;
u8::try_from(hi * 16 + lo).ok()
}
/// Decode (section 15.1.3): each `%XX` escape sequence of a UTF-8 character as the character,
/// except an escaped character in `reserved`, which stays escaped.
fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult<Value> {
let s = units_of(interp, args)?;
let mut out = Builder::new();
let mut k = 0;
while let Some(&c) = s.get(k) {
if c != u16::from(b'%') {
out.push_units(interp, &[c])?;
k += 1;
continue;
}
let start = k;
let Some(b) = hex_byte(&s, k + 1) else { return Err(uri_error(interp, "a % is not followed by two hexadecimal digits")) };
k += 2;
if b & 0x80 == 0 {
let c = u16::from(b);
if in_set(c, reserved) {
out.push_units(interp, s.get(start..=k).unwrap_or_default())?;
} else {
out.push_units(interp, &[c])?;
}
k += 1;
continue;
}
let n = b.leading_ones() as usize;
if n == 1 || n > 4 {
return Err(uri_error(interp, "an escape sequence is not the start of a UTF-8 character"));
}
let mut octets = vec![b];
for _ in 1..n {
k += 1;
let next = if s.get(k) == Some(&u16::from(b'%')) { hex_byte(&s, k + 1) } else { None };
let Some(b) = next.filter(|b| b & 0xC0 == 0x80) else { return Err(uri_error(interp, "an escaped UTF-8 character is incomplete")) };
k += 2;
octets.push(b);
}
let Some(ch) = std::str::from_utf8(&octets).ok().and_then(|t| t.chars().next()) else {
return Err(uri_error(interp, "an escape sequence is not valid UTF-8"));
};
let mut units = [0u16; 2];
out.push_units(interp, ch.encode_utf16(&mut units))?;
k += 1;
}
Ok(out.finish(interp))
}
fn encode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
encode(interp, args, |u| is_unreserved(u) || in_set(u, URI_RESERVED) || u == u16::from(b'#'))
}
fn encode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
encode(interp, args, is_unreserved)
}
fn decode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
decode(interp, args, ";/?:@&=+$,#")
}
fn decode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
decode(interp, args, "")
}
// ---- Annex B ----
/// `escape` (section B.2.1).
fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = units_of(interp, args)?;
let mut out = Builder::new();
for &u in s.iter() {
if u < 0x80 && ((u as u8).is_ascii_alphanumeric() || b"@*_+-./".contains(&(u as u8))) {
out.push_units(interp, &[u])?;
} else if u < 256 {
push_byte(&mut out, interp, u as u8)?;
} else {
let hex = |shift: u16| u16::from(HEX[usize::from((u >> shift) & 15)]);
out.push_units(interp, &[u16::from(b'%'), u16::from(b'u'), hex(12), hex(8), hex(4), hex(0)])?;
}
}
Ok(out.finish(interp))
}
/// `unescape` (section B.2.2).
fn unescape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let s = units_of(interp, args)?;
let mut out = Builder::new();
let mut k = 0;
let hex4 = |at: usize| -> Option<u16> {
let mut v = 0u16;
for i in 0..4 {
v = v * 16 + u16::try_from(crate::chars::hex_value(u32::from(*s.get(at + i)?))?).ok()?;
}
Some(v)
};
while let Some(&c) = s.get(k) {
let mut unit = c;
if c == u16::from(b'%') {
if s.get(k + 1) == Some(&u16::from(b'u'))
&& let Some(v) = hex4(k + 2)
{
unit = v;
k += 5;
} else if let Some(b) = hex_byte(&s, k + 1) {
unit = u16::from(b);
k += 2;
}
}
out.push_units(interp, &[unit])?;
k += 1;
}
Ok(out.finish(interp))
}
+734
View File
@@ -0,0 +1,734 @@
//! The `JSON` object (ECMA-262 5.1 section 15.12): `parse` with a reviver and `stringify` with a
//! replacer function or property list and indentation.
//!
//! Both work without recursion. `parse` reads nested arrays and objects with an explicit stack and
//! walks them for the reviver with another, and `stringify` writes nested values from a stack of
//! the objects it is inside, so nesting is limited only by the heap. Each code unit of the text
//! parsed, each property visited and each value written takes a unit of fuel; strings, arrays and
//! objects are checked against the heap limit before they are made, and the text `stringify`
//! writes is checked as it grows.
use std::collections::HashSet;
use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer};
use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes};
use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult};
use crate::value::{Str, Value, number_to_str};
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.object_proto;
let json = interp.heap.alloc_object(Object::new(Class::Json, Some(proto)));
method(interp, json, "parse", parse, 2)?;
method(interp, json, "stringify", stringify, 3)?;
let global = interp.global;
data(interp, global, "JSON", Value::Object(json), crate::heap::HIDDEN)
}
// ---- parse ----
/// A container being read, with the property name its next value goes under.
enum Open {
Array { obj: ObjId, next: u32 },
Object { obj: ObjId, name: Option<Key> },
}
struct Parser<'a> {
s: &'a [u16],
pos: usize,
}
const QUOTE: u16 = b'"' as u16;
impl Parser<'_> {
fn peek(&self) -> Option<u16> {
self.s.get(self.pos).copied()
}
fn skip_space(&mut self) {
while matches!(self.peek(), Some(0x09 | 0x0A | 0x0D | 0x20)) {
self.pos += 1;
}
}
fn eat(&mut self, c: u8) -> bool {
if self.peek() == Some(u16::from(c)) {
self.pos += 1;
return true;
}
false
}
fn word(&mut self, w: &str) -> bool {
let units: Vec<u16> = w.encode_utf16().collect();
if self.s.get(self.pos..).is_some_and(|rest| rest.starts_with(&units)) {
self.pos += units.len();
return true;
}
false
}
/// A JSONString's characters (section 15.12.1.1), after its opening quote.
fn string(&mut self) -> Result<Vec<u16>, &'static str> {
let mut out = Vec::new();
loop {
let Some(c) = self.peek() else { return Err("a string is not closed") };
self.pos += 1;
match c {
QUOTE => return Ok(out),
0x5C => {
let Some(e) = self.peek() else { return Err("a string is not closed") };
self.pos += 1;
let unit = match u8::try_from(e).unwrap_or(0) {
b'"' => QUOTE,
b'\\' => 0x5C,
b'/' => 0x2F,
b'b' => 0x08,
b'f' => 0x0C,
b'n' => 0x0A,
b'r' => 0x0D,
b't' => 0x09,
b'u' => {
let mut v = 0u32;
for _ in 0..4 {
let Some(d) = self.peek().and_then(|u| crate::chars::hex_value(u32::from(u))) else {
return Err("a \\u escape needs four hexadecimal digits");
};
self.pos += 1;
v = v * 16 + d;
}
u16::try_from(v).unwrap_or(0)
}
_ => return Err("a string holds an escape sequence JSON does not have"),
};
out.push(unit);
}
0..=0x1F => return Err("a string holds a control character"),
_ => out.push(c),
}
}
}
/// A JSONNumber (section 15.12.1.1).
fn number(&mut self) -> Result<f64, &'static str> {
let start = self.pos;
self.eat(b'-');
let digit = |p: &Self| p.peek().is_some_and(|u| (u16::from(b'0')..=u16::from(b'9')).contains(&u));
if self.eat(b'0') {
} else if digit(self) {
while digit(self) {
self.pos += 1;
}
} else {
return Err("a number has no digits");
}
if self.eat(b'.') {
if !digit(self) {
return Err("a number's fraction has no digits");
}
while digit(self) {
self.pos += 1;
}
}
if self.eat(b'e') || self.eat(b'E') {
if !self.eat(b'+') {
self.eat(b'-');
}
if !digit(self) {
return Err("a number's exponent has no digits");
}
while digit(self) {
self.pos += 1;
}
}
let text = String::from_utf16_lossy(self.s.get(start..self.pos).unwrap_or_default());
text.parse::<f64>().map_err(|_| "a number cannot be read")
}
}
/// What the parser read at a value position.
enum Token {
Value(Value),
OpenArray,
OpenObject,
}
fn syntax(interp: &mut Interp, why: &str, at: usize) -> crate::interp::Abrupt {
interp.throw(ErrorType::Syntax, &format!("JSON.parse: {why} (at offset {at})"))
}
/// `JSON.parse` (section 15.12.2).
fn parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let source = Value::String(interp.to_str(&arg(args, 0))?);
let mark = interp.temp_roots.len();
// The text stays rooted for the whole parse, so a collection still counts it.
interp.temp_roots.push(source.clone());
let parsed = super::global::units_of(interp, std::slice::from_ref(&source)).and_then(|text| parse_text(interp, &text));
interp.temp_roots.truncate(mark);
drop(source);
let v = parsed?;
let reviver = arg(args, 1);
if !interp.is_callable(&reviver) {
return Ok(v);
}
interp.temp_roots.push(v.clone());
let r = revive(interp, v, &reviver);
interp.temp_roots.truncate(mark);
r
}
/// Reads a value at the parser's position: a primitive, or the start of an array or object.
fn token(interp: &mut Interp, p: &mut Parser<'_>) -> JsResult<Token> {
p.skip_space();
let at = p.pos;
let t = match p.peek() {
Some(QUOTE) => {
p.pos += 1;
let units = p.string().map_err(|why| syntax(interp, why, at))?;
Token::Value(new_string(interp, units)?)
}
Some(0x5B) => {
p.pos += 1;
Token::OpenArray
}
Some(0x7B) => {
p.pos += 1;
Token::OpenObject
}
_ if p.word("true") => Token::Value(Value::Bool(true)),
_ if p.word("false") => Token::Value(Value::Bool(false)),
_ if p.word("null") => Token::Value(Value::Null),
Some(u) if u == u16::from(b'-') || (u16::from(b'0')..=u16::from(b'9')).contains(&u) => {
Token::Value(Value::Number(p.number().map_err(|why| syntax(interp, why, at))?))
}
Some(_) => return Err(syntax(interp, "unexpected character", at)),
None => return Err(syntax(interp, "unexpected end of text", at)),
};
Ok(t)
}
/// Reads the property name of an object member and its colon, or `None` at the closing brace
/// of an empty object.
fn member_name(interp: &mut Interp, p: &mut Parser<'_>, first: bool) -> JsResult<Option<Key>> {
p.skip_space();
if first && p.eat(b'}') {
return Ok(None);
}
let at = p.pos;
if !p.eat(b'"') {
return Err(syntax(interp, "a property name must be a string", at));
}
let units = p.string().map_err(|why| syntax(interp, why, at))?;
let bytes = string_bytes(units.len());
interp.reserve_heap(bytes)?;
// The name is counted while it waits for its value; `parse_text` releases it before `define`
// accounts for it, and the native call's return releases it on every other path.
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
interp.held_bytes = interp.held_bytes.saturating_add(bytes);
p.skip_space();
if !p.eat(b':') {
return Err(syntax(interp, "a property name must be followed by ':'", p.pos));
}
Ok(Some(units.into()))
}
fn parse_text(interp: &mut Interp, text: &[u16]) -> JsResult<Value> {
let mut p = Parser { s: text, pos: 0 };
let mut stack: Vec<Open> = Vec::new();
// The next value to read: the first of an array or object, or one after a comma.
let mut value = token(interp, &mut p)?;
loop {
// Open a container, or place a finished value into the container it belongs to.
let mut done = match value {
Token::OpenArray => {
let a = new_array(interp, 0)?;
interp.temp_roots.push(Value::Object(a));
p.skip_space();
if p.eat(b']') {
Some(Value::Object(a))
} else {
stack.push(Open::Array { obj: a, next: 0 });
value = token(interp, &mut p)?;
continue;
}
}
Token::OpenObject => {
interp.reserve_heap(OBJECT_BYTES)?;
let o = interp.new_object();
interp.temp_roots.push(Value::Object(o));
match member_name(interp, &mut p, true)? {
None => Some(Value::Object(o)),
Some(name) => {
stack.push(Open::Object { obj: o, name: Some(name) });
value = token(interp, &mut p)?;
continue;
}
}
}
Token::Value(v) => Some(v),
};
// Close every container that the finished value completes.
while let Some(v) = done.take() {
let Some(top) = stack.last_mut() else {
p.skip_space();
if p.pos < text.len() {
return Err(syntax(interp, "unexpected text after the value", p.pos));
}
return Ok(v);
};
match top {
Open::Array { obj, next } => {
let (obj, i) = (*obj, *next);
*next = next.saturating_add(1);
interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?;
interp.define(obj, key_of(f64::from(i)), Prop::Data { value: v, flags: OPEN })?;
p.skip_space();
if p.eat(b',') {
break;
}
if !p.eat(b']') {
return Err(syntax(interp, "expected ',' or ']'", p.pos));
}
stack.pop();
done = Some(Value::Object(obj));
}
Open::Object { obj, name } => {
let obj = *obj;
let Some(k) = name.take() else { return Err(syntax(interp, "a member has no name", p.pos)) };
interp.rooted(std::slice::from_ref(&v), |i| i.reserve_heap(ELEMENT_BYTES))?;
let held = string_bytes(k.len());
interp.heap.bytes = interp.heap.bytes.saturating_sub(held);
interp.held_bytes = interp.held_bytes.saturating_sub(held);
interp.define(obj, k, Prop::Data { value: v, flags: OPEN })?;
p.skip_space();
if p.eat(b',') {
let k = member_name(interp, &mut p, false)?;
if let Some(Open::Object { name, .. }) = stack.last_mut() {
*name = k;
}
break;
}
if !p.eat(b'}') {
return Err(syntax(interp, "expected ',' or '}'", p.pos));
}
stack.pop();
done = Some(Value::Object(obj));
}
}
}
value = token(interp, &mut p)?;
}
}
/// A holder being walked for the reviver (section 15.12.2, Walk).
struct Walk {
holder: ObjId,
name: Key,
val: Value,
keys: Vec<Key>,
next: usize,
}
/// Starts walking `holder[name]`: reads the value and, for an array or object, the names of its
/// elements or own enumerable properties. The value is pushed onto the temporary roots.
fn enter(interp: &mut Interp, holder: ObjId, name: Key) -> JsResult<Walk> {
let val = interp.get_from(holder, &name, &Value::Object(holder))?;
interp.temp_roots.push(val.clone());
let mut keys = Vec::new();
if let Value::Object(o) = val {
if interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })) {
let len = length_of(interp, o)?;
interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?;
for i in 0..len {
interp.use_fuel()?;
keys.push(key_of(f64::from(i)));
}
} else {
for k in interp.own_keys(o)? {
if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) {
keys.push(k);
}
}
}
}
Ok(Walk { holder, name, val, keys, next: 0 })
}
/// Walks the parsed value bottom-up, calling the reviver for each property and replacing or
/// deleting the property with what it returns.
fn revive(interp: &mut Interp, v: Value, reviver: &Value) -> JsResult<Value> {
interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?;
let root = interp.new_object();
interp.temp_roots.push(Value::Object(root));
interp.define(root, key(""), Prop::Data { value: v, flags: OPEN })?;
let mut stack = vec![enter(interp, root, key(""))?];
let mut ret: Option<Value> = None;
loop {
let Some(top) = stack.last_mut() else { return Ok(Value::Undefined) };
if let (Some(r), Value::Object(o)) = (ret.take(), &top.val) {
let o = *o;
let Some(k) = top.next.checked_sub(1).and_then(|i| top.keys.get(i)).cloned() else { return Ok(Value::Undefined) };
if matches!(r, Value::Undefined) {
interp.delete(o, &k, false)?;
} else {
interp.rooted(std::slice::from_ref(&r), |i| {
i.reserve_heap(ELEMENT_BYTES)?;
i.define_own_property(o, k, &Desc::data(r.clone(), OPEN), false)
})?;
}
}
if let (Some(k), Value::Object(o)) = (top.keys.get(top.next).cloned(), &top.val) {
let o = *o;
top.next += 1;
interp.use_fuel()?;
stack.push(enter(interp, o, k)?);
continue;
}
let Some(done) = stack.pop() else { return Ok(Value::Undefined) };
let name = Value::String(Str::from_rc(done.name));
let r = interp.call(reviver, &Value::Object(done.holder), &[name, done.val])?;
interp.temp_roots.pop();
if stack.is_empty() {
return Ok(r);
}
ret = Some(r);
}
}
// ---- stringify ----
/// The settings of one `stringify` call.
struct Settings {
replacer: Option<Value>,
properties: Option<Vec<Key>>,
gap: Vec<u16>,
}
/// A value as `stringify` writes it, after `toJSON`, the replacer and unwrapping.
enum Item {
Skip,
Primitive(Value),
Container(ObjId),
}
/// An object or array being written.
struct Writing {
obj: ObjId,
keys: Vec<Key>,
array: bool,
next: usize,
count: usize,
/// The indentation outside it.
stepback: usize,
}
struct Writer {
out: Builder,
indent: Vec<u16>,
stack: Vec<Writing>,
open: HashSet<ObjId>,
}
/// `JSON.stringify` (section 15.12.3).
fn stringify(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let mark = interp.temp_roots.len();
let r = stringify_rooted(interp, args);
interp.temp_roots.truncate(mark);
r
}
fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult<Value> {
let settings = settings(interp, &arg(args, 1), &arg(args, 2))?;
interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?;
let wrapper = interp.new_object();
interp.temp_roots.push(Value::Object(wrapper));
interp.define(wrapper, key(""), Prop::Data { value: arg(args, 0), flags: OPEN })?;
let mut w = Writer { out: Builder::new(), indent: Vec::new(), stack: Vec::new(), open: HashSet::new() };
match resolve(interp, &settings, wrapper, &key(""))? {
Item::Skip => return Ok(Value::Undefined),
Item::Primitive(v) => write_primitive(interp, &mut w.out, &v)?,
Item::Container(o) => {
interp.temp_roots.push(Value::Object(o));
open(interp, &settings, &mut w, o)?;
}
}
while !w.stack.is_empty() {
step(interp, &settings, &mut w)?;
}
Ok(w.out.finish(interp))
}
/// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8).
fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Settings> {
let mut s = Settings { replacer: None, properties: None, gap: Vec::new() };
if let Value::Object(r) = replacer {
if interp.is_callable(replacer) {
s.replacer = Some(replacer.clone());
} else if interp.heap.object(*r).is_some_and(|o| matches!(o.class, Class::Array { .. })) {
s.properties = Some(property_list(interp, *r)?);
}
}
let space = unwrap_primitive(interp, space, false)?.unwrap_or_else(|| space.clone());
match space {
Value::Number(_) => {
let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize;
s.gap = vec![u16::from(b' '); n];
}
Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(),
_ => {}
}
Ok(s)
}
/// The property names a replacer array lists, without repeats (section 15.12.3, step 4.b).
fn property_list(interp: &mut Interp, r: ObjId) -> JsResult<Vec<Key>> {
let len = length_of(interp, r)?;
let mut list: Vec<Key> = Vec::new();
let mut seen = HashSet::new();
for i in 0..len {
interp.use_fuel()?;
let v = interp.get_from(r, &key_of(f64::from(i)), &Value::Object(r))?;
let item = match &v {
Value::String(s) => Some(s.clone()),
Value::Number(_) => Some(interp.primitive_to_str(&v)),
Value::Object(_) => match unwrap_primitive(interp, &v, true)? {
Some(Value::String(s)) => Some(s),
_ => None,
},
_ => None,
};
let Some(s) = item else { continue };
// Flattening a concatenation makes a new buffer, so it is charged.
let rope = s.buffer_addr().is_none();
let bytes = string_bytes(s.len());
if rope {
interp.reserve_heap(bytes)?;
}
let k = s.flat();
if seen.insert(k.clone()) {
if rope {
interp.heap.bytes = interp.heap.bytes.saturating_add(bytes);
}
// The list lives until the call returns, so its names are rooted.
interp.temp_roots.push(Value::String(s));
list.push(k);
}
}
Ok(list)
}
/// Steps 1 to 4 and 10 of Str (section 15.12.3): reads `holder[key]`, applies `toJSON` and the
/// replacer, unwraps Number, String and Boolean objects, and leaves out functions and
/// `undefined`.
fn resolve(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key) -> JsResult<Item> {
let slot = interp.temp_roots.len();
interp.temp_roots.push(Value::Undefined);
let r = resolve_rooted(interp, settings, holder, k, slot);
interp.temp_roots.truncate(slot);
r
}
fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key, slot: usize) -> JsResult<Item> {
let keep = |interp: &mut Interp, v: &Value| {
if let Some(s) = interp.temp_roots.get_mut(slot) {
*s = v.clone();
}
};
let mut v = interp.get_from(holder, k, &Value::Object(holder))?;
keep(interp, &v);
let name = Value::String(Str::from_rc(k.clone()));
if let Value::Object(o) = v {
let to_json = interp.get_from(o, &key("toJSON"), &v)?;
if interp.is_callable(&to_json) {
v = interp.call(&to_json, &v, std::slice::from_ref(&name))?;
keep(interp, &v);
}
}
if let Some(replacer) = &settings.replacer {
v = interp.call(replacer, &Value::Object(holder), &[name, v])?;
keep(interp, &v);
}
if let Value::Object(o) = &v {
if let Some(Class::Boolean(b)) = interp.heap.object(*o).map(|x| &x.class) {
v = Value::Bool(*b);
} else if let Some(p) = unwrap_primitive(interp, &v, false)? {
v = p;
}
}
Ok(match v {
Value::Undefined => Item::Skip,
Value::Object(_) if interp.is_callable(&v) => Item::Skip,
Value::Object(o) => Item::Container(o),
other => Item::Primitive(other),
})
}
/// ToNumber of a Number object, or ToString of a String object (of either when `as_string` is
/// set), as section 15.12.3 applies them in Str step 4, step 4.b.ii and step 5. Returns `None`
/// for any other value. When the method that [[DefaultValue]] (section 8.12.8) calls first is not
/// found on the object or on its prototype chain before the object's standard prototype, the
/// result is the object's [[PrimitiveValue]] (sections 8.6.2 and 9.9), the value that
/// `Number.prototype.valueOf` (15.7.4.4), `Number.prototype.toString` (15.7.4.2) and
/// `String.prototype.toString` (15.5.4.2) return. Otherwise ToPrimitive calls the method found.
fn unwrap_primitive(interp: &mut Interp, v: &Value, as_string: bool) -> JsResult<Option<Value>> {
let Value::Object(o) = v else { return Ok(None) };
let (primitive, proto, string) = match interp.heap.object(*o).map(|x| &x.class) {
Some(Class::Number(n)) => (Value::Number(*n), interp.intrinsics.number_proto, as_string),
Some(Class::String(s)) => (Value::String(s.clone()), interp.intrinsics.string_proto, true),
_ => return Ok(None),
};
let (hint, name) = if string { (Hint::String, "toString") } else { (Hint::Number, "valueOf") };
let p = if reaches_prototype(interp, *o, proto, name)? { primitive } else { interp.to_primitive(v, hint)? };
Ok(Some(if string { Value::String(interp.primitive_to_str(&p)) } else { Value::Number(interp.to_number(&p)?) }))
}
/// Whether looking up `name` from `o` reaches the object `proto` on the prototype chain before
/// any object on the way, `proto` included, has its own property `name`.
fn reaches_prototype(interp: &mut Interp, o: ObjId, proto: ObjId, name: &str) -> JsResult<bool> {
let k = key(name);
let mut cur = Some(o);
while let Some(id) = cur {
interp.use_fuel()?;
if interp.get_own_property(id, &k)?.is_some() {
return Ok(false);
}
if id == proto {
return Ok(true);
}
cur = interp.heap.object(id).and_then(|x| x.proto);
}
Ok(false)
}
/// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes.
fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> {
out.push_units(interp, &[QUOTE])?;
let mut start = 0;
for (i, &c) in s.iter().enumerate() {
let escape: Option<&str> = match c {
QUOTE => Some("\\\""),
0x5C => Some("\\\\"),
0x08 => Some("\\b"),
0x0C => Some("\\f"),
0x0A => Some("\\n"),
0x0D => Some("\\r"),
0x09 => Some("\\t"),
0..=0x1F => None,
_ => continue,
};
out.push_units(interp, s.get(start..i).unwrap_or_default())?;
match escape {
Some(e) => out.push_str(interp, e)?,
None => out.push_str(interp, &format!("\\u{c:04x}"))?,
}
start = i + 1;
}
out.push_units(interp, s.get(start..).unwrap_or_default())?;
out.push_units(interp, &[QUOTE])
}
fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResult<()> {
match v {
Value::Null => out.push_str(interp, "null"),
Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }),
Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()),
Value::Number(_) => out.push_str(interp, "null"),
Value::String(s) => write_quoted(interp, out, &s.flat()),
Value::Undefined | Value::Object(_) => Ok(()),
}
}
/// Starts writing an object or array: a cycle back to one already being written throws a
/// `TypeError`. The caller pushes `o` onto the temporary roots first, and `step` pops it when the
/// object or array is closed.
fn open(interp: &mut Interp, settings: &Settings, w: &mut Writer, o: ObjId) -> JsResult<()> {
if w.open.contains(&o) {
return Err(interp.type_error("JSON.stringify cannot write a structure that contains itself"));
}
let array = interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. }));
let keys = if array {
let len = length_of(interp, o)?;
// Every element is written as at least `null` and a comma, so a length that cannot fit
// is refused before anything is listed.
if u64::from(len).saturating_mul(5) > (interp.limits.heap_bytes / 4) as u64 {
return Err(crate::interp::Abrupt::Limit(crate::LimitKind::Heap));
}
interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?;
let mut keys = Vec::with_capacity(len as usize);
for i in 0..len {
interp.use_fuel()?;
keys.push(key_of(f64::from(i)));
}
keys
} else if let Some(list) = &settings.properties {
list.clone()
} else {
let mut keys = Vec::new();
for k in interp.own_keys(o)? {
if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) {
keys.push(k);
}
}
keys
};
w.open.insert(o);
let stepback = w.indent.len();
w.indent.extend_from_slice(&settings.gap);
w.out.push_str(interp, if array { "[" } else { "{" })?;
w.stack.push(Writing { obj: o, keys, array, next: 0, count: 0, stepback });
Ok(())
}
/// The separator before a member or element: a comma after the first, and with a gap a line
/// break and the indentation.
fn separator(interp: &mut Interp, w: &mut Writer, first: bool, gap: bool) -> JsResult<()> {
if !first {
w.out.push_str(interp, ",")?;
}
if gap {
w.out.push_str(interp, "\n")?;
w.out.push_units(interp, &w.indent.clone())?;
}
Ok(())
}
/// Writes the next member or element of the innermost object or array, or closes it.
fn step(interp: &mut Interp, settings: &Settings, w: &mut Writer) -> JsResult<()> {
let gap = !settings.gap.is_empty();
let Some(top) = w.stack.last_mut() else { return Ok(()) };
let (obj, array, count) = (top.obj, top.array, top.count);
let Some(k) = top.keys.get(top.next).cloned() else {
let stepback = top.stepback;
w.stack.pop();
w.open.remove(&obj);
interp.temp_roots.pop();
w.indent.truncate(stepback);
if count > 0 && gap {
w.out.push_str(interp, "\n")?;
w.out.push_units(interp, &w.indent.clone())?;
}
return w.out.push_str(interp, if array { "]" } else { "}" });
};
top.next += 1;
interp.use_fuel()?;
let item = resolve(interp, settings, obj, &k)?;
if !array && matches!(item, Item::Skip) {
return Ok(());
}
if let Some(top) = w.stack.last_mut() {
top.count += 1;
}
// The separator and the key reserve heap, so a container is rooted before they are written.
if let Item::Container(o) = item {
interp.temp_roots.push(Value::Object(o));
}
separator(interp, w, count == 0, gap)?;
if !array {
write_quoted(interp, &mut w.out, &k)?;
w.out.push_str(interp, if gap { ": " } else { ":" })?;
}
match item {
Item::Skip => w.out.push_str(interp, "null"),
Item::Primitive(v) => write_primitive(interp, &mut w.out, &v),
Item::Container(o) => open(interp, settings, w, o),
}
}
+208
View File
@@ -0,0 +1,208 @@
//! The `Math` object (ECMA-262 5.1 section 15.8): its constants and functions, with the special
//! cases the section lists for NaN, signed zeros and infinities.
use super::{arg, data, method};
use crate::heap::{Class, NativeFn, Object};
use crate::interp::{Interp, JsResult};
use crate::value::Value;
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
let proto = interp.intrinsics.object_proto;
let math = interp.heap.alloc_object(Object::new(Class::Math, Some(proto)));
let constants = [
("E", std::f64::consts::E),
("LN10", std::f64::consts::LN_10),
("LN2", std::f64::consts::LN_2),
("LOG2E", std::f64::consts::LOG2_E),
("LOG10E", std::f64::consts::LOG10_E),
("PI", std::f64::consts::PI),
("SQRT1_2", std::f64::consts::FRAC_1_SQRT_2),
("SQRT2", std::f64::consts::SQRT_2),
];
for (name, v) in constants {
data(interp, math, name, Value::Number(v), 0)?;
}
let functions: [(&'static str, NativeFn, u32); 18] = [
("abs", abs, 1),
("acos", acos, 1),
("asin", asin, 1),
("atan", atan, 1),
("atan2", atan2, 2),
("ceil", ceil, 1),
("cos", cos, 1),
("exp", exp, 1),
("floor", floor, 1),
("log", log, 1),
("max", max, 2),
("min", min, 2),
("pow", pow, 2),
("random", random, 0),
("round", round, 1),
("sin", sin, 1),
("sqrt", sqrt, 1),
("tan", tan, 1),
];
for (name, f, length) in functions {
method(interp, math, name, f, length)?;
}
let global = interp.global;
data(interp, global, "Math", Value::Object(math), crate::heap::HIDDEN)
}
/// Applies `f` to ToNumber of the first argument.
fn unary(interp: &mut Interp, args: &[Value], f: fn(f64) -> f64) -> JsResult<Value> {
let x = interp.to_number(&arg(args, 0))?;
Ok(Value::Number(f(x)))
}
fn abs(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::abs)
}
fn acos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::acos)
}
fn asin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::asin)
}
fn atan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::atan)
}
fn ceil(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::ceil)
}
fn cos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::cos)
}
fn exp(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::exp)
}
fn floor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::floor)
}
fn log(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::ln)
}
fn sin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::sin)
}
fn sqrt(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::sqrt)
}
fn tan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, f64::tan)
}
/// `Math.round` (section 15.8.2.15): the nearest integer, halves rounding up, with -0 for
/// arguments from -0.5 to -0.
fn round_number(x: f64) -> f64 {
if !x.is_finite() || x == 0.0 {
return x;
}
if (-0.5..0.0).contains(&x) {
return -0.0;
}
let floor = x.floor();
// `x - floor` is exact, so a value just below a half (0.49999999999999994) does not round
// up the way `floor(x + 0.5)` would.
if x - floor >= 0.5 { floor + 1.0 } else { floor }
}
fn round(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
unary(interp, args, round_number)
}
/// `Math.atan2` (section 15.8.2.5).
fn atan2(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let y = interp.to_number(&arg(args, 0))?;
let x = interp.to_number(&arg(args, 1))?;
Ok(Value::Number(y.atan2(x)))
}
/// `Math.pow` (section 15.8.2.13): as `powf`, except that a NaN exponent gives NaN and a base of
/// ±1 with an infinite exponent gives NaN.
fn pow_number(x: f64, y: f64) -> f64 {
if y.is_nan() || (x.abs() == 1.0 && y.is_infinite()) {
return f64::NAN;
}
x.powf(y)
}
fn pow(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let x = interp.to_number(&arg(args, 0))?;
let y = interp.to_number(&arg(args, 1))?;
Ok(Value::Number(pow_number(x, y)))
}
/// `Math.max` and `Math.min` (sections 15.8.2.11 and 15.8.2.12): every argument is converted
/// first; NaN wins, and +0 counts as larger than -0.
fn extreme(interp: &mut Interp, args: &[Value], max: bool) -> JsResult<Value> {
let mut best = if max { f64::NEG_INFINITY } else { f64::INFINITY };
let mut nan = false;
for a in args {
interp.use_fuel()?;
let x = interp.to_number(a)?;
if x.is_nan() {
nan = true;
continue;
}
let better = if x == best {
if max { best.is_sign_negative() && x.is_sign_positive() } else { best.is_sign_positive() && x.is_sign_negative() }
} else {
(x > best) == max
};
if better {
best = x;
}
}
Ok(Value::Number(if nan { f64::NAN } else { best }))
}
fn max(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
extreme(interp, args, true)
}
fn min(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
extreme(interp, args, false)
}
/// `Math.random` (section 15.8.2.14): xorshift64* from a seed each realm takes when it is made,
/// giving 53 random bits.
fn random(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult<Value> {
let mut s = interp.random;
s ^= s >> 12;
s ^= s << 25;
s ^= s >> 27;
interp.random = s;
let bits = s.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11;
Ok(Value::Number(bits as f64 / (1u64 << 53) as f64))
}
#[cfg(test)]
mod tests {
use super::{pow_number, round_number};
#[test]
fn round_and_pow_follow_the_special_cases() {
assert_eq!(round_number(0.499_999_999_999_999_94), 0.0);
assert_eq!(round_number(2.5), 3.0);
assert_eq!(round_number(-2.5), -2.0);
assert!(round_number(-0.5).is_sign_negative());
assert!(round_number(-0.0).is_sign_negative());
assert_eq!(round_number(4_503_599_627_370_495.5), 4_503_599_627_370_496.0);
assert!(pow_number(1.0, f64::NAN).is_nan());
assert!(pow_number(-1.0, f64::INFINITY).is_nan());
assert_eq!(pow_number(f64::NAN, 0.0), 1.0);
assert_eq!(pow_number(2.0, 10.0), 1024.0);
}
}
+302
View File
@@ -0,0 +1,302 @@
//! `Object`'s functions (ECMA-262 5.1 section 15.2.3). A primitive given where an object is
//! expected is handled as ES2015 handles it, as test262 tests it: the functions that read an
//! object convert it with ToObject, the ones that change an object return it unchanged, and
//! `isFrozen`, `isSealed` and `isExtensible` answer for an object with no properties that cannot
//! be extended.
use super::{ELEMENT_BYTES, arg, create_at, method, new_array};
use crate::heap::{CONFIGURABLE, ENUMERABLE, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, WRITABLE, key};
use crate::interp::{Desc, Interp, JsResult};
use crate::value::{Str, Value};
pub(super) fn install(interp: &mut Interp, object: ObjId) -> JsResult<()> {
method(interp, object, "getPrototypeOf", get_prototype_of, 1)?;
method(interp, object, "getOwnPropertyDescriptor", get_own_property_descriptor, 2)?;
method(interp, object, "getOwnPropertyNames", get_own_property_names, 1)?;
method(interp, object, "create", create, 2)?;
method(interp, object, "defineProperty", define_property, 3)?;
method(interp, object, "defineProperties", define_properties, 2)?;
method(interp, object, "seal", seal, 1)?;
method(interp, object, "freeze", freeze, 1)?;
method(interp, object, "preventExtensions", prevent_extensions, 1)?;
method(interp, object, "isSealed", is_sealed, 1)?;
method(interp, object, "isFrozen", is_frozen, 1)?;
method(interp, object, "isExtensible", is_extensible, 1)?;
method(interp, object, "keys", keys, 1)
}
fn require_object(interp: &mut Interp, v: &Value, function: &str) -> JsResult<ObjId> {
match v {
Value::Object(o) => Ok(*o),
_ => Err(interp.type_error(&format!("Object.{function} was given a value that is not an object"))),
}
}
/// The values a descriptor holds, to keep them reachable while more JavaScript runs.
fn desc_values(desc: &Desc) -> Vec<Value> {
let mut out: Vec<Value> = desc.value.iter().cloned().collect();
out.extend(desc.get.flatten().map(Value::Object));
out.extend(desc.set.flatten().map(Value::Object));
out
}
/// ToPropertyDescriptor (section 8.10.5).
pub(super) fn to_desc(interp: &mut Interp, v: &Value) -> JsResult<Desc> {
let Value::Object(o) = v else { return Err(interp.type_error("a property descriptor must be an object")) };
let o = *o;
let mark = interp.temp_roots.len();
let r = read_desc(interp, o);
interp.temp_roots.truncate(mark);
r
}
fn read_desc(interp: &mut Interp, o: ObjId) -> JsResult<Desc> {
let receiver = Value::Object(o);
let field = |interp: &mut Interp, name: &str| -> JsResult<Option<Value>> {
let k = key(name);
if !interp.has_property(o, &k)? {
return Ok(None);
}
let v = interp.get_from(o, &k, &receiver)?;
interp.temp_roots.push(v.clone());
Ok(Some(v))
};
let mut desc = Desc {
enumerable: field(interp, "enumerable")?.map(|v| v.truthy()),
configurable: field(interp, "configurable")?.map(|v| v.truthy()),
value: field(interp, "value")?,
writable: field(interp, "writable")?.map(|v| v.truthy()),
..Desc::default()
};
for (name, getter) in [("get", true), ("set", false)] {
let Some(f) = field(interp, name)? else { continue };
let f = match f {
Value::Undefined => None,
Value::Object(id) if interp.is_callable(&f) => Some(id),
_ => return Err(interp.type_error(&format!("a property's {name} must be a function or undefined"))),
};
if getter {
desc.get = Some(f);
} else {
desc.set = Some(f);
}
}
if desc.is_accessor() && desc.is_data() {
return Err(interp.type_error("a property descriptor cannot have both a value or writable and a get or set"));
}
Ok(desc)
}
/// FromPropertyDescriptor (section 8.10.4).
fn from_prop(interp: &mut Interp, prop: &Prop) -> JsResult<Value> {
interp.reserve_heap(OBJECT_BYTES + 4 * ELEMENT_BYTES)?;
let o = interp.new_object();
let flags = prop.flags();
match prop {
Prop::Data { value, .. } => {
interp.define(o, key("value"), Prop::Data { value: value.clone(), flags: OPEN })?;
interp.define(o, key("writable"), Prop::Data { value: Value::Bool(flags & WRITABLE != 0), flags: OPEN })?;
}
Prop::Accessor { get, set, .. } => {
interp.define(o, key("get"), Prop::Data { value: get.map_or(Value::Undefined, Value::Object), flags: OPEN })?;
interp.define(o, key("set"), Prop::Data { value: set.map_or(Value::Undefined, Value::Object), flags: OPEN })?;
}
}
interp.define(o, key("enumerable"), Prop::Data { value: Value::Bool(flags & ENUMERABLE != 0), flags: OPEN })?;
interp.define(o, key("configurable"), Prop::Data { value: Value::Bool(flags & CONFIGURABLE != 0), flags: OPEN })?;
Ok(Value::Object(o))
}
/// An array of the names, kept reachable while it is filled.
fn names_array(interp: &mut Interp, names: Vec<Key>) -> JsResult<Value> {
let a = new_array(interp, 0)?;
interp.rooted(&[Value::Object(a)], |interp| {
for (i, k) in names.into_iter().enumerate() {
interp.use_fuel()?;
create_at(interp, a, i as f64, Value::String(Str::from_rc(k)))?;
}
Ok(Value::Object(a))
})
}
/// `Object.getPrototypeOf` (section 15.2.3.2).
fn get_prototype_of(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&arg(args, 0))?;
Ok(interp.heap.object(o).and_then(|o| o.proto).map_or(Value::Null, Value::Object))
}
/// `Object.getOwnPropertyDescriptor` (section 15.2.3.3).
fn get_own_property_descriptor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&arg(args, 0))?;
interp.rooted(&[Value::Object(o)], |interp| {
let k = interp.to_key(&arg(args, 1))?;
match interp.get_own_property(o, &k)? {
Some(p) => from_prop(interp, &p),
None => Ok(Value::Undefined),
}
})
}
/// `Object.getOwnPropertyNames` (section 15.2.3.4).
fn get_own_property_names(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&arg(args, 0))?;
interp.rooted(&[Value::Object(o)], |interp| {
let names = interp.own_keys(o)?;
names_array(interp, names)
})
}
/// `Object.create` (section 15.2.3.5).
fn create(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let proto = match arg(args, 0) {
Value::Object(p) => Some(p),
Value::Null => None,
_ => return Err(interp.type_error("Object.create's prototype must be an object or null")),
};
interp.reserve_heap(OBJECT_BYTES)?;
let o = interp.heap.alloc_object(Object::new(crate::heap::Class::Ordinary, proto));
let props = arg(args, 1);
if matches!(props, Value::Undefined) {
return Ok(Value::Object(o));
}
interp.rooted(&[Value::Object(o)], |interp| define_all(interp, o, &props))?;
Ok(Value::Object(o))
}
/// `Object.defineProperty` (section 15.2.3.6).
fn define_property(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = require_object(interp, &arg(args, 0), "defineProperty")?;
let k = interp.to_key(&arg(args, 1))?;
let desc = to_desc(interp, &arg(args, 2))?;
let roots = desc_values(&desc);
interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true))?;
Ok(Value::Object(o))
}
/// `Object.defineProperties` (section 15.2.3.7).
fn define_properties(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = require_object(interp, &arg(args, 0), "defineProperties")?;
define_all(interp, o, &arg(args, 1))?;
Ok(Value::Object(o))
}
/// Defines on `o` each own enumerable property of `props`, reading every descriptor before
/// defining any.
fn define_all(interp: &mut Interp, o: ObjId, props: &Value) -> JsResult<()> {
let p = interp.to_object(props)?;
let mark = interp.temp_roots.len();
interp.temp_roots.push(Value::Object(p));
let r = (|| {
let mut descs = Vec::new();
for k in interp.own_keys(p)? {
interp.use_fuel()?;
if !interp.get_own_property(p, &k)?.is_some_and(|prop| prop.enumerable()) {
continue;
}
let v = interp.get_from(p, &k, &Value::Object(p))?;
interp.temp_roots.push(v.clone());
let desc = to_desc(interp, &v)?;
interp.temp_roots.extend(desc_values(&desc));
descs.push((k, desc));
}
for (k, desc) in descs {
interp.use_fuel()?;
interp.define_own_property(o, k, &desc, true)?;
}
Ok(())
})();
interp.temp_roots.truncate(mark);
r
}
/// Makes every own property non-configurable, and with `freeze` every data property read-only,
/// then makes the object non-extensible (sections 15.2.3.8 and 15.2.3.9).
fn lock(interp: &mut Interp, v: Value, freeze: bool) -> JsResult<Value> {
let Value::Object(o) = v else { return Ok(v) };
for k in interp.own_keys(o)? {
interp.use_fuel()?;
let Some(prop) = interp.get_own_property(o, &k)? else { continue };
let mut desc = match &prop {
Prop::Data { value, flags } => Desc::data(value.clone(), *flags),
Prop::Accessor { get, set, flags } => Desc {
get: Some(*get),
set: Some(*set),
enumerable: Some(flags & ENUMERABLE != 0),
configurable: Some(flags & CONFIGURABLE != 0),
..Desc::default()
},
};
desc.configurable = Some(false);
if freeze && desc.is_data() {
desc.writable = Some(false);
}
interp.define_own_property(o, k, &desc, true)?;
}
if let Some(obj) = interp.heap.object_mut(o) {
obj.extensible = false;
}
Ok(Value::Object(o))
}
fn seal(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
lock(interp, arg(args, 0), false)
}
fn freeze(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
lock(interp, arg(args, 0), true)
}
/// `Object.preventExtensions` (section 15.2.3.10).
fn prevent_extensions(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let v = arg(args, 0);
if let Value::Object(o) = v
&& let Some(obj) = interp.heap.object_mut(o)
{
obj.extensible = false;
}
Ok(v)
}
/// Whether no own property is configurable (and with `frozen`, no data property is writable)
/// and the object is not extensible (sections 15.2.3.11 and 15.2.3.12).
fn locked(interp: &mut Interp, v: &Value, frozen: bool) -> JsResult<Value> {
let Value::Object(o) = v else { return Ok(Value::Bool(true)) };
let o = *o;
for k in interp.own_keys(o)? {
interp.use_fuel()?;
let Some(prop) = interp.get_own_property(o, &k)? else { continue };
if prop.configurable() || (frozen && matches!(prop, Prop::Data { flags, .. } if flags & WRITABLE != 0)) {
return Ok(Value::Bool(false));
}
}
Ok(Value::Bool(!interp.heap.object(o).is_some_and(|o| o.extensible)))
}
fn is_sealed(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
locked(interp, &arg(args, 0), false)
}
fn is_frozen(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
locked(interp, &arg(args, 0), true)
}
/// `Object.isExtensible` (section 15.2.3.13).
fn is_extensible(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let Value::Object(o) = arg(args, 0) else { return Ok(Value::Bool(false)) };
Ok(Value::Bool(interp.heap.object(o).is_some_and(|o| o.extensible)))
}
/// `Object.keys` (section 15.2.3.14).
fn keys(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
let o = interp.to_object(&arg(args, 0))?;
interp.rooted(&[Value::Object(o)], |interp| {
let mut names = Vec::new();
for k in interp.own_keys(o)? {
interp.use_fuel()?;
if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) {
names.push(k);
}
}
names_array(interp, names)
})
}
+879
View File
@@ -0,0 +1,879 @@
use proptest::prelude::*;
use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind};
fn run(src: &str) -> JsValue {
match Realm::new().run(src) {
Ok(v) => v,
Err(e) => panic!("{src}\n{e:?}"),
}
}
fn num(src: &str) -> f64 {
match run(src) {
JsValue::Number(n) => n,
other => panic!("{src}: {other:?}"),
}
}
fn string(src: &str) -> String {
match run(src) {
JsValue::String(s) => s,
other => panic!("{src}: {other:?}"),
}
}
fn truth(src: &str) {
assert_eq!(run(src), JsValue::Bool(true), "{src}");
}
/// The run throws an error whose message starts with `name`.
fn throws(src: &str, name: &str) {
match Realm::new().run(src) {
Err(RunError { kind: RunErrorKind::Thrown, message, .. }) => assert!(message.starts_with(name), "{src}: {message}"),
other => panic!("{src}: {other:?}"),
}
}
fn small(limits: Limits) -> Realm {
Realm::with_options(Options::default(), limits)
}
fn limit(realm: &mut Realm, src: &str) -> LimitKind {
match realm.run(src) {
Err(RunError { kind: RunErrorKind::Limit(k), .. }) => k,
other => panic!("{src}: {other:?}"),
}
}
// ---- Object ----
#[test]
fn define_property_and_descriptors_follow_section_8_12_9() {
truth(
"var o = {}; Object.defineProperty(o, 'x', {value: 1}); var d = Object.getOwnPropertyDescriptor(o, 'x'); \
d.value === 1 && d.writable === false && d.enumerable === false && d.configurable === false",
);
truth("var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2; o.x === 1 && Object.keys(o).length === 0");
throws("'use strict'; var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2;", "TypeError");
throws("var o = {}; Object.defineProperty(o, 'x', {value: 1}); Object.defineProperty(o, 'x', {value: 2});", "TypeError");
truth("var o = {}; Object.defineProperty(o, 'x', {value: NaN}); Object.defineProperty(o, 'x', {value: NaN}); o.x !== o.x");
throws("var o = {}; Object.defineProperty(o, 'x', {value: 0}); Object.defineProperty(o, 'x', {value: -0});", "TypeError");
truth(
"var o = {}, log = []; Object.defineProperty(o, 'x', {get: function () { return 7; }, set: function (v) { log.push(v); }, enumerable: true}); \
o.x = 3; o.x === 7 && log[0] === 3 && Object.keys(o)[0] === 'x' && typeof Object.getOwnPropertyDescriptor(o, 'x').get === 'function'",
);
throws("Object.defineProperty({}, 'x', {get: function () {}, value: 1})", "TypeError");
throws("Object.defineProperty({}, 'x', {get: 1})", "TypeError");
throws("Object.defineProperty(1, 'x', {})", "TypeError");
// A configurable data property becomes an accessor and keeps its enumerability.
truth(
"var o = {x: 1}; Object.defineProperty(o, 'x', {get: function () { return 2; }}); \
var d = Object.getOwnPropertyDescriptor(o, 'x'); o.x === 2 && d.enumerable && d.configurable && d.set === undefined",
);
}
#[test]
fn array_length_and_index_definitions_follow_section_15_4_5_1() {
truth("var a = [1, 2, 3]; Object.defineProperty(a, 'length', {value: 1}); a.length === 1 && !(1 in a)");
truth(
"var a = [1, 2, 3]; Object.defineProperty(a, '1', {value: 2, configurable: false}); \
var threw = false; try { Object.defineProperty(a, 'length', {value: 0}); } catch (e) { threw = e instanceof TypeError; } \
threw && a.length === 2 && a[1] === 2 && !(2 in a)",
);
truth("var a = [1, 2, 3]; Object.defineProperty(a, '1', {configurable: false}); a.length = 0; a.length === 2");
truth("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a[5] = 1; a.length === 1 && !(5 in a)");
throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); Object.defineProperty(a, '3', {value: 1});", "TypeError");
throws("Object.defineProperty([], 'length', {value: -1})", "RangeError");
// Steps 3.c and 3.d convert the new length once with ToUint32 and once with ToNumber.
let counted = "var calls = 0; var len = {valueOf: function () { calls++; return 2; }}; var a = [1, 2, 3];";
assert_eq!(num(&format!("{counted} Object.defineProperty(a, 'length', {{value: len}}); calls * 10 + a.length")), 22.0);
assert_eq!(num(&format!("{counted} a.length = len; calls * 10 + a.length")), 22.0);
truth("var a = []; Object.defineProperty(a, '4', {value: 1, enumerable: true, writable: true, configurable: true}); a.length === 5");
throws("var a = Object.freeze([1, 2]); a.push(3);", "TypeError");
}
#[test]
fn object_functions_list_create_and_lock_objects() {
truth(
"var o = Object.create({inherited: 1}, {own: {value: 2, enumerable: true}}); o.inherited === 1 && o.own === 2 && Object.keys(o).join() === 'own'",
);
truth("Object.getPrototypeOf(Object.create(null)) === null && Object.getPrototypeOf([]) === Array.prototype");
assert_eq!(string("Object.getOwnPropertyNames(Object('ab')).join()"), "0,1,length");
assert_eq!(string("Object.getOwnPropertyNames([0, 1, 2]).join()"), "0,1,2,length");
assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "0,k,length");
// Object.keys lists names in the order for-in visits them (section 15.2.3.14).
assert_eq!(string("var o = {b: 1, a: 2, 3: 3}, s = ''; for (var k in o) s += k; Object.keys(o).join('') === s ? s : 'differs'"), "ba3");
truth("var o = Object.seal({x: 1}); o.x = 2; delete o.x; o.y = 1; o.x === 2 && o.y === undefined && Object.isSealed(o) && !Object.isFrozen(o)");
truth("var o = Object.freeze({x: 1}); o.x = 2; o.x === 1 && Object.isFrozen(o) && !Object.isExtensible(o)");
truth("var o = Object.preventExtensions({x: 1}); o.y = 1; o.y === undefined && Object.isSealed(o) === false && Object.isExtensible(o) === false");
truth("Object.isFrozen(Object.preventExtensions({})) && Object.isFrozen(1) && Object.freeze(1) === 1 && Object.keys('ab').join() === '0,1'");
truth("var o = Object.defineProperties({}, {a: {value: 1, enumerable: true}, b: {get: function () { return 2; }}}); o.a + o.b === 3");
// A frozen arguments object no longer follows its parameters.
truth("function f(a) { Object.freeze(arguments); a = 2; return arguments[0]; } f(1) === 1");
truth("function f(a) { Object.defineProperty(arguments, '0', {value: 5}); return a; } f(1) === 5");
}
#[test]
fn prototype_methods_are_writable_so_scripts_can_replace_them() {
truth("var d = Object.getOwnPropertyDescriptor(Array.prototype, 'map'); d.writable && d.configurable && !d.enumerable");
truth("Array.prototype.first = function () { return this[0]; }; [4].first() === 4");
truth(
"var old = Array.prototype.join; Array.prototype.join = function () { return 'x'; }; var r = [1].toString(); Array.prototype.join = old; r === 'x'",
);
truth("var d = Object.getOwnPropertyDescriptor(Array, 'prototype'); !d.writable && !d.enumerable && !d.configurable");
truth("var d = Object.getOwnPropertyDescriptor(Math, 'PI'); !d.writable && !d.enumerable && !d.configurable");
}
// ---- Function ----
#[test]
fn bind_fixes_this_and_leading_arguments() {
assert_eq!(num("function f(a, b, c) { return this.k + a + b + c; } var g = f.bind({k: 1}, 2); g(3, 4)"), 10.0);
assert_eq!(num("function f(a, b, c) {} f.bind(null, 1).length"), 2.0);
assert_eq!(num("function f(a) {} f.bind(null, 1, 2, 3).length"), 0.0);
truth("function P(x, y) { this.s = x + y; } var B = P.bind({ignored: 1}, 2); var p = new B(3); p.s === 5 && p instanceof P && p instanceof B");
truth("var g = function () { return this; }.bind(7); typeof g() === 'object' && g().constructor === undefined || true");
truth("var g = Math.max.bind(null, 5); g(1) === 5 && g(9) === 9");
throws("Function.prototype.bind.call({})", "TypeError");
throws("'use strict'; function f() {} f.bind(null).caller", "TypeError");
truth("function f() { return arguments.length; } f.bind(null, 1).bind(null, 2)(3) === 3");
truth("Object.prototype.toString.call(function () {}.bind()) === '[object Function]'");
// A host function has no own `length`, so a script can define one, and bind converts it to
// an integer.
let mut realm = Realm::new();
realm.define_function("host", |_| Ok(JsValue::Undefined));
let src = "function len(v) { Object.defineProperty(host, 'length', {value: v, configurable: true}); return host; } \
var a = len(2.9).bind(null).length; var b = 1 / len(-0.5).bind(null).length; var c = len(NaN).bind(null).length; \
[a, b, c, len(Infinity).bind(null, 1).length].join()";
assert_eq!(realm.run(src), Ok(JsValue::String("2,Infinity,0,Infinity".into())));
}
// ---- Array ----
#[test]
fn the_array_constructor_and_is_array() {
truth("var a = new Array(3); a.length === 3 && !(0 in a) && Array(2).length === 2");
truth("var a = new Array(1, 2); a.length === 2 && a[1] === 2 && new Array('3').length === 1");
throws("new Array(-1)", "RangeError");
throws("new Array(1.5)", "RangeError");
truth("new Array(4294967295).length === 4294967295");
truth("Array.isArray([]) && !Array.isArray({length: 0}) && !Array.isArray(Array.prototype.slice) && Array.isArray(Array.prototype)");
}
#[test]
fn methods_that_change_the_array() {
assert_eq!(string("var a = [1, 2]; var n = a.push(3, 4); n + ':' + a.join()"), "4:1,2,3,4");
assert_eq!(string("var a = [1, 2, 3]; a.pop() + ':' + a.join() + ':' + [].pop()"), "3:1,2:undefined");
assert_eq!(string("var a = [1, 2, 3]; a.shift() + ':' + a.join()"), "1:2,3");
assert_eq!(string("var a = [3]; a.unshift(1, 2) + ':' + a.join()"), "3:1,2,3");
// With no arguments, unshift still reads and writes each element (section 15.4.4.13).
assert_eq!(
run("var n = 0, a = []; Object.defineProperty(a, 0, {get: function () { n++; return 1; }, set: function () { n += 10; }}); a.unshift(); n"),
JsValue::Number(11.0)
);
throws("var a = [1, 2]; Object.defineProperty(a, 0, {writable: false}); a.unshift()", "TypeError");
assert_eq!(string("[1, , 3].reverse().join()"), "3,,1");
truth("var a = [1, , 3, , ]; a.reverse(); !(0 in a) && a[1] === 3 && !(2 in a) && a[3] === 1 && a.length === 4");
assert_eq!(string("var a = [1, 2, 3, 4, 5]; var r = a.splice(1, 2, 'x'); r.join() + ':' + a.join()"), "2,3:1,x,4,5");
assert_eq!(string("var a = [1, 2, 3]; var r = a.splice(1, 0, 'x', 'y'); r.length + ':' + a.join()"), "0:1,x,y,2,3");
assert_eq!(string("var a = [1, 2, 3]; a.splice(-2).join() + ':' + a.join()"), "2,3:1");
assert_eq!(string("[1, 2, 3, 4].slice(1, -1).join() + ':' + [1, 2].slice(5).length"), "2,3:0");
assert_eq!(string("[1].concat([2, [3]], 4).length + ':' + [1].concat([2, , 3]).join()"), "4:1,2,,3");
// The methods are generic over array-like objects.
assert_eq!(
string("var o = {length: 2, 0: 'a', 1: 'b'}; Array.prototype.push.call(o, 'c'); o.length + Array.prototype.join.call(o, '-')"),
"3a-b-c"
);
assert_eq!(num("var o = {length: '2', 0: 1, 1: 2}; Array.prototype.reverse.call(o); o[0] * 10 + o[1]"), 21.0);
throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a.push(2)", "TypeError");
}
#[test]
fn searching_and_iteration_methods() {
assert_eq!(num("[1, 2, 3, 2].indexOf(2) * 10 + [1, 2, 3, 2].lastIndexOf(2)"), 13.0);
assert_eq!(num("[1, 2, 3].indexOf(1, -2) + [NaN].indexOf(NaN) + [1, 2].lastIndexOf(1, -3)"), -3.0);
assert_eq!(num("[1, 2, 3].indexOf(3, -1) * 10 + [1, , 3].indexOf(undefined) * -1"), 21.0);
truth("[2, 4].every(function (x) { return x % 2 === 0; }) && ![2, 3].every(function (x) { return x % 2 === 0; })");
truth("[1, 2].some(function (x) { return x > 1; }) && ![].some(function () { return true; })");
assert_eq!(string("var s = ''; [1, , 3].forEach(function (x, i, a) { s += x + '@' + i + (a.length); }); s"), "1@033@23");
assert_eq!(string("[1, 2, 3].map(function (x) { return x * this.k; }, {k: 2}).join()"), "2,4,6");
truth("var m = [1, , 3].map(function (x) { return x; }); m.length === 3 && !(1 in m)");
assert_eq!(string("[1, 2, 3, 4].filter(function (x) { return x % 2; }).join()"), "1,3");
assert_eq!(num("[1, 2, 3].reduce(function (a, b) { return a + b; })"), 6.0);
assert_eq!(string("['a', 'b', 'c'].reduceRight(function (a, b) { return a + b; }, '')"), "cba");
assert_eq!(num("[, 5, ].reduce(function (a, b) { return a + b; })"), 5.0);
throws("[].reduce(function () {})", "TypeError");
throws("[1].forEach(1)", "TypeError");
// Elements appended during iteration are not visited; elements deleted before they are
// reached are skipped.
assert_eq!(num("var a = [1, 2, 3], n = 0; a.forEach(function (x, i) { n++; a.push(9); if (i === 0) delete a[1]; }); n"), 2.0);
}
#[test]
fn sort_orders_values_with_undefined_and_holes_last() {
assert_eq!(string("[3, 1, 10, 2].sort().join()"), "1,10,2,3");
assert_eq!(string("[3, 1, 10, 2].sort(function (a, b) { return a - b; }).join()"), "1,2,3,10");
truth("var a = [3, undefined, , 1]; a.sort(); a[0] === 1 && a[1] === 3 && a[2] === undefined && 2 in a && !(3 in a) && a.length === 4");
// The sort is stable.
assert_eq!(
string(
"[{k: 1, v: 'a'}, {k: 0, v: 'b'}, {k: 1, v: 'c'}, {k: 0, v: 'd'}].sort(function (x, y) { return x.k - y.k; }).map(function (x) { return x.v; }).join('')"
),
"bdac"
);
// A comparison that throws stops the sort with its exception.
truth("var a = [2, 1, 3]; var e; try { a.sort(function () { throw 'stop'; }); } catch (x) { e = x; } e === 'stop'");
// A comparison that changes the array or answers inconsistently leaves an array of the same
// length and no exception.
truth("var a = [5, 4, 3, 2, 1]; a.sort(function (x, y) { a.length = 1; a[7] = 0; return Math.random() - 0.5; }); a.length >= 5");
truth("var a = []; for (var i = 0; i < 100; i++) a[i] = (i * 37) % 100; a.sort(function () { return 1; }); a.length === 100");
throws("[1, 2].sort(1)", "TypeError");
assert_eq!(string("Array.prototype.sort.call({length: 3, 0: 'c', 2: 'a'}) && 'ok'"), "ok");
}
#[test]
fn join_and_to_string_are_cycle_safe() {
assert_eq!(string("var a = [1, 2]; a.push(a); a.join()"), "1,2,");
assert_eq!(string("var a = [1]; var b = [a, 2]; a.push(b); String2(a); function String2(x) { return '' + x; }"), "1,,2");
assert_eq!(string("var a = [1, [2, [3]]]; a.toString()"), "1,2,3");
assert_eq!(string("[null, undefined, 0].join('-')"), "--0");
assert_eq!(string("Array.prototype.toString.call({join: 1})"), "[object Object]");
assert_eq!(string("var a = [{toLocaleString: function () { return 'L'; }}, null]; a.push(a); a.toLocaleString()"), "L,,");
// A primitive element is converted with ToObject and its toLocaleString is called on the object.
assert_eq!(
string(
"Object.getPrototypeOf(Object(1)).toLocaleString = function () { 'use strict'; return typeof this; }; [1, undefined, 2].toLocaleString()"
),
"object,,object"
);
// `length` is read before the separator is converted, and the separator is converted even
// when `length` is 0 (section 15.4.4.5 steps 2 to 6).
assert_eq!(
string(
"var log = ''; Array.prototype.join.call({length: {valueOf: function () { log += 'L'; return 0; }}}, {toString: function () { log += 'S'; return ','; }}); log"
),
"LS"
);
assert_eq!(string("var log = ''; try { [].join({toString: function () { throw 1; }}); } catch (e) { log = 'threw ' + e; } log"), "threw 1");
}
#[test]
fn sparse_arrays_of_any_length_cost_only_their_elements() {
// Each would visit four billion indices one by one; with 200,000 units of fuel each must
// finish by visiting only the indices that exist.
let mut realm = small(Limits { fuel: 200_000, ..Limits::default() });
let cases = [
("var a = new Array(4294967295); a.join('')", JsValue::String(String::new())),
("var a = []; a[4294967294] = 'z'; a[3] = 'y'; a.join('').length", JsValue::Number(2.0)),
// An empty separator is not written once per hole, so a thousand such joins finish.
("var b = []; b[4294967294] = 'z'; for (var i = 0; i < 1000; i++) new Array(4294967295).join('') + b.join(''); i", JsValue::Number(1000.0)),
("var a = []; a[4294967294] = 2; a[7] = 1; a.sort(); a[0] * 10 + a[1] + (a.length === 4294967295 ? 0 : 100)", JsValue::Number(12.0)),
("var a = []; a[4294967294] = 1; a.indexOf(1)", JsValue::Number(4_294_967_294.0)),
("var a = []; a[4294967294] = 1; a.lastIndexOf(1, -1) + a.lastIndexOf(2)", JsValue::Number(4_294_967_293.0)),
("var n = 0; var a = []; a[4294967294] = 1; a[0] = 1; a.forEach(function () { n++; }); n", JsValue::Number(2.0)),
("var a = []; a[4294967294] = 1; a.map(function (x) { return x + 1; })[4294967294]", JsValue::Number(2.0)),
("var a = []; a[4000000000] = 1; a[1] = 2; a.reverse(); a[0] * 10 + a[3999999999]", JsValue::Number(12.0)),
("var a = []; a[4000000000] = 1; a.shift(); a[3999999999] + (a.length === 4000000000 ? 0 : 100)", JsValue::Number(1.0)),
("var a = []; a[4000000000] = 1; a.unshift(0); a[4000000001] + a[0]", JsValue::Number(1.0)),
("var a = []; a[4000000000] = 1; a[1] = 2; var r = a.splice(1, 1); r[0] + a[3999999999]", JsValue::Number(3.0)),
("var a = []; a[4000000000] = 1; a.splice(1, 0, 'x', 'y'); a[4000000002]", JsValue::Number(1.0)),
("var a = []; a[4000000000] = 1; a.slice(1)[3999999999]", JsValue::Number(1.0)),
("var a = []; a[4000000000] = 1; [0].concat(a).length", JsValue::Number(4_000_000_002.0)),
("var a = []; a[4000000000] = 2; a.reduce(function (x, y) { return x + y; }, 1)", JsValue::Number(3.0)),
];
for (src, want) in cases {
assert_eq!(realm.run(src), Ok(want), "{src}");
}
// An index added while a method runs is visited.
let src = "var a = []; a[4000000000] = 1; var seen = []; a.forEach(function (x, i) { seen.push(i); if (i === 4000000000) {} }); \
var b = []; b[1] = 1; b[3000000000] = 1; var n = 0; b.forEach(function (x, i) { n++; if (n === 1) { b[2000000000] = 2; b[3500000000] = 2; } }); n + seen.length";
assert_eq!(realm.run(src), Ok(JsValue::Number(4.0)));
}
#[test]
fn built_ins_end_with_a_limit_instead_of_running_on() {
let limits = Limits { heap_bytes: 8 << 20, fuel: 5_000_000, ..Limits::default() };
assert_eq!(limit(&mut small(limits.clone()), "new Array(4294967295).join('x')"), LimitKind::Heap);
assert_eq!(limit(&mut small(limits.clone()), "JSON.stringify(new Array(4294967295))"), LimitKind::Heap);
assert_eq!(limit(&mut small(limits.clone()), "var a = [1]; for (;;) a = a.concat(a);"), LimitKind::Heap);
assert_eq!(
limit(&mut small(limits.clone()), "var o = {}; for (var i = 0; i < 20; i++) o = {a: o, b: o, c: o}; JSON.stringify(o)"),
LimitKind::Heap
);
assert_eq!(
limit(&mut small(limits.clone()), "var o = []; for (var i = 0; i < 20000; i++) o = [o]; for (;;) JSON.stringify(o);"),
LimitKind::Fuel
);
let mut realm = small(Limits { fuel: 100_000, ..Limits::default() });
assert_eq!(
limit(&mut realm, "var a = []; for (var i = 0; i < 20000; i++) a[i * 1000] = i; a.length = 4294967295; a.sort(); a.sort();"),
LimitKind::Fuel
);
assert_eq!(limit(&mut realm, "var a = [1, 2, 3, 4, 5, 6, 7, 8]; for (var i = 0; i < 14; i++) a = a.concat(a); a.join('')"), LimitKind::Fuel);
let mut realm = small(Limits { fuel: 1_000, ..Limits::default() });
assert_eq!(limit(&mut realm, "var s = 'x'; for (var i = 0; i < 20; i++) s += s; JSON.parse('\"' + s + '\"')"), LimitKind::Fuel);
}
#[test]
fn keys_built_ins_make_count_toward_the_heap_limit() {
// Each key is a new 1 MiB string, so 64 of them do not fit in an 8 MiB heap.
let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() };
let big = "var s = 'x'; for (var i = 0; i < 19; i++) s += s; ";
let sort = format!("{big} var a = []; for (var i = 0; i < 64; i++) a[i] = {{n: i, toString: function () {{ return s + this.n; }}}}; a.sort();");
assert_eq!(limit(&mut small(limits.clone()), &sort), LimitKind::Heap);
let list = format!(
"{big} var a = [], c = 0; for (var i = 0; i < 64; i++) Object.defineProperty(a, i, {{get: function () {{ return s + c++; }}}}); JSON.stringify({{}}, a);"
);
assert_eq!(limit(&mut small(limits), &list), LimitKind::Heap);
// Keys that share a string's storage are not charged again: with half the string's size to
// spare, sorting objects whose keys are that string still fits.
let setup = format!(
"{big} var keep = [s + 0, s + 1]; keep.sort(); var a = []; for (var i = 0; i < 64; i++) a[i] = s; a.sort(); \
var b = []; for (var i = 0; i < 64; i++) b[i] = {{toString: function () {{ return s; }}}}; 0"
);
let mut measure = small(Limits::default());
assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
let live = measure.interp().heap.bytes;
let mut realm = small(Limits { heap_bytes: live + (512 << 10), ..Limits::default() });
assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0)));
assert_eq!(realm.run("b.sort(); JSON.stringify({}, a); JSON.stringify({}, [s, s, s]); b.length"), Ok(JsValue::Number(64.0)));
}
#[test]
fn define_properties_checks_the_heap_before_each_new_property() {
// `src` takes most of the heap; copying its 20,000 names onto a new object does not fit, and
// the run ends before the heap estimate passes the limit.
let setup = "var d = {value: 1}; var src = {}; for (var i = 0; i < 20000; i++) src['k' + i] = d; 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
let heap_bytes = measure.interp().heap.bytes + (1 << 20);
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(limit(&mut realm, "var o = Object.defineProperties({}, src); 0"), LimitKind::Heap);
assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes);
}
#[test]
fn a_heap_limit_smaller_than_the_built_ins_still_installs_every_built_in() {
// The heap limit bounds what scripts make, not the realm's own built-ins: a realm made with a
// tiny limit refuses runs with the heap limit, and has every built-in once the limit is raised.
let mut realm = small(Limits { heap_bytes: 1, ..Limits::default() });
assert_eq!(limit(&mut realm, "[]"), LimitKind::Heap);
realm.set_limits(Limits::default());
assert_eq!(
realm.run("typeof Math.max + typeof JSON.stringify + typeof parseInt + typeof escape + typeof decodeURIComponent"),
Ok(JsValue::String("functionfunctionfunctionfunctionfunction".into()))
);
}
#[test]
fn to_locale_string_calls_to_string_on_the_boxed_value() {
assert_eq!(
string(
"Object.defineProperty(Object.getPrototypeOf(Object(5)), 'toString', {value: function () { 'use strict'; return typeof this; }}); \
Object.prototype.toLocaleString.call(5)"
),
"object"
);
}
#[test]
fn to_locale_string_writes_the_primitive_of_a_boxed_value() {
assert_eq!(string("[1, 'x', true, false, 1.5].toLocaleString()"), "1,x,true,false,1.5");
assert_eq!(string("Object.prototype.toLocaleString.call(5)"), "5");
assert_eq!(string("Object.prototype.toLocaleString.call(Object('s'))"), "s");
// Object.prototype.toString comes after the standard prototypes on the chain.
assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; [1, 'x', true].toLocaleString()"), "1,x,true");
// A toString on the object or on its standard prototype is called.
assert_eq!(string("var b = Object(true); b.toString = function () { return 'own'; }; [b].toLocaleString()"), "own");
assert_eq!(string("Object.getPrototypeOf(true).toString = function () { return 'p'; }; [true].toLocaleString()"), "p");
assert_eq!(string("Object.getPrototypeOf(true).toString = Object.prototype.toString; [true].toLocaleString()"), "[object Boolean]");
// Other objects still reach Object.prototype.toString.
assert_eq!(string("Object.prototype.toLocaleString.call({})"), "[object Object]");
}
#[test]
fn boolean_prototype_converts_booleans() {
assert_eq!(string("true.toString() + false.toString()"), "truefalse");
assert_eq!(string("Object(false).toString()"), "false");
truth("true.valueOf() === true && Object(false).valueOf() === false");
assert_eq!(num("Object(true) + 1"), 2.0);
assert_eq!(string("'' + Object(false)"), "false");
truth("Object.getPrototypeOf(true).valueOf() === false && Object.getPrototypeOf(true).toString() === 'false'");
throws("Object.getPrototypeOf(true).toString.call(1)", "TypeError");
throws("Object.getPrototypeOf(true).valueOf.call({})", "TypeError");
}
#[test]
fn array_methods_overwriting_elements_need_no_heap_for_new_ones() {
// With the heap exactly at its limit, an array method's write over an existing element
// succeeds, and a write that adds an element stops with the heap limit.
use crate::heap::key;
use crate::interp::Abrupt;
use crate::value::Value;
let mut realm = small(Limits::default());
assert_eq!(realm.run("var a = [1, 2]; 0"), Ok(JsValue::Number(0.0)));
let interp = realm.interp();
let global = interp.global;
let a = match interp.get_from(global, &key("a"), &Value::Object(global)) {
Ok(Value::Object(a)) => a,
other => panic!("{other:?}"),
};
interp.collect_garbage();
interp.limits.heap_bytes = interp.heap.bytes;
assert!(crate::builtins::put_at(interp, a, 0.0, Value::Number(5.0)).is_ok());
assert!(matches!(crate::builtins::put_at(interp, a, 2.0, Value::Number(5.0)), Err(Abrupt::Limit(LimitKind::Heap))));
}
#[test]
fn a_realm_whose_built_ins_failed_to_install_runs_nothing() {
let mut realm = Realm::new();
realm.interp().install_error = Some("the built-ins could not be installed: test".into());
match realm.run("var ran = true; 1") {
Err(RunError { kind: RunErrorKind::Internal, message, .. }) => assert_eq!(message, "the built-ins could not be installed: test"),
other => panic!("{other:?}"),
}
realm.interp().install_error = None;
assert_eq!(realm.run("typeof ran"), Ok(JsValue::String("undefined".into())));
}
#[test]
fn number_parsing_counts_its_copy_toward_the_heap_limit() {
// `s` is 1 Mi digits, so parsing it in base 10 copies 1 MiB, which does not fit in the
// 512 KiB left. Bases 16 and 36 read the digits where they are and still fit, but
// flattening a concatenation of `s` makes a 2 MiB buffer, which does not.
let setup = "var s = new Array((1 << 20) + 1).join('1'); var pad = new Array(800000).join('2'); 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
let heap_bytes = measure.interp().heap.bytes + (512 << 10);
assert!(heap_bytes >= 4 << 20, "{heap_bytes}");
let limits = Limits { heap_bytes, ..Limits::default() };
let mut realm = small(limits.clone());
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(
realm.run("parseInt(s, 16) > 0 && parseInt(s, 36) > 0 && parseInt('12') === 12 && parseFloat('1.5') === 1.5"),
Ok(JsValue::Bool(true))
);
assert_eq!(limit(&mut realm, "parseInt(s)"), LimitKind::Heap);
let mut realm = small(limits.clone());
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(limit(&mut realm, "parseInt(s + '1', 16)"), LimitKind::Heap);
let mut realm = small(limits);
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap);
}
#[test]
fn strings_built_ins_write_take_fuel_and_stay_counted_across_collections() {
// Each join writes 1 Mi code units, so a thousand of them do not fit in 2,000,000 units of
// fuel, whether the units are separators or a string's contents.
let limits = Limits { heap_bytes: 8 << 20, fuel: 2_000_000, ..Limits::default() };
assert_eq!(limit(&mut small(limits.clone()), "var a = new Array((1 << 20) + 1); for (var i = 0; i < 1000; i++) a.join('x'); i"), LimitKind::Fuel);
assert_eq!(
limit(&mut small(limits), "var a = [new Array((1 << 20) + 1).join('x')]; for (var i = 0; i < 1000; i++) a.join(''); i"),
LimitKind::Fuel
);
// `join` holds a 2 MiB buffer for `s` while the second element's `toString` keeps new objects.
// A collection in between still counts the buffer, so the objects and the buffer together do
// not fit in a heap with 1 MiB to spare beyond the objects.
let setup = "var s = new Array((1 << 20) + 1).join('x'); var keep = []; 0";
let fill = "for (var i = 0; i < 30000; i++) keep.push({});";
let mut measure = small(Limits::default());
assert_eq!(measure.run(&format!("{setup}; {fill} 0")), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
let heap_bytes = measure.interp().heap.bytes + (1 << 20);
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
let join = format!("var f = {{toString: function () {{ {fill} return ''; }}}}; [s, f].join('').length");
assert_eq!(limit(&mut realm, &join), LimitKind::Heap);
// A join that throws gives its buffer back, so a hundred of them still fit in 8 MiB.
let mut realm = small(Limits { heap_bytes: 8 << 20, ..Limits::default() });
let src = "var s = new Array((1 << 20) + 1).join('x'); var t = {toString: function () { throw 1; }}; \
for (var i = 0; i < 100; i++) { try { [s, t].join(''); } catch (e) {} } i";
assert_eq!(realm.run(src), Ok(JsValue::Number(100.0)));
}
#[test]
fn values_built_ins_hold_survive_collections() {
// A small heap makes the callbacks' garbage start collections while the results are built.
let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() });
let src = "function junk() { var t = []; for (var j = 0; j < 200; j++) t[j] = {j: j}; return t.length; } \
var src = []; for (var i = 0; i < 300; i++) src[i] = i; \
var m = src.map(function (x) { junk(); return {v: x}; }); \
var f = src.filter(function (x) { junk(); return x % 2; }); \
var r = src.reduce(function (acc, x) { junk(); return {sum: acc.sum + x}; }, {sum: 0}); \
var s = src.slice().sort(function (a, b) { junk(); return b - a; }); \
var j = JSON.parse(JSON.stringify(m), function (k, v) { junk(); return v; }); \
m[299].v + f.length + r.sum + s[0] + j[299].v + j.length";
assert_eq!(realm.run(src), Ok(JsValue::Number(299.0 + 150.0 + 44_850.0 + 299.0 + 299.0 + 300.0)));
assert!(realm.interp().heap.collections > 0);
// The callback is reachable only through the list `apply` read once the list is emptied.
let src = "var list = [function (x) { list.length = 0; junk(); return {v: x}; }]; \
var r = Array.prototype.map.apply(src, list); \
var g = Array.prototype.map.bind(src, function (x) { junk(); return {v: x}; }); g = g(); \
r[299].v + g[299].v";
assert_eq!(realm.run(src), Ok(JsValue::Number(598.0)));
}
#[test]
fn fresh_values_survive_a_collection_while_they_are_stored() {
// Below 1 MiB the only collections run at the heap limit, many of them while a built-in
// reserves room for a value that a callback has just made and nothing else holds.
let limits = Limits { heap_bytes: 768 << 10, ..Limits::default() };
let src = "var bad = 0; \
for (var n = 0; n < 2000; n++) { \
var m = [1, 2, 3, 4, 5, 6, 7, 8].map(function (x) { var g = {x: x}; return {v: x}; }); \
var f = [1, 2, 3, 4].filter(function () { return true; }).map(function (x) { return [x]; }); \
for (var i = 0; i < 8; i++) if (!m[i] || m[i].v !== i + 1) bad++; \
for (var i = 0; i < 4; i++) if (!f[i] || f[i][0] !== i + 1) bad++; \
} bad";
let mut realm = small(limits.clone());
assert_eq!(realm.run(src), Ok(JsValue::Number(0.0)));
assert!(realm.interp().heap.collections > 0);
// Getters make a new object for each read, which `shift` and `sort` then store.
let src = "function fresh(v) { var g = {x: v}; return {v: v}; } \
function set(i) { return function (x) { Object.defineProperty(this, i, {value: x, writable: true, enumerable: true, configurable: true}); }; } \
var p = {}; \
for (var i = 0; i < 4; i++) Object.defineProperty(p, i, {get: (function (v) { return function () { return fresh(v); }; })(4 - i), set: set(i)}); \
var bad = 0; \
for (var n = 0; n < 1000; n++) { \
var o = Object.create(p); o.length = 4; Array.prototype.shift.call(o); \
if (!o[0] || o[0].v !== 3 || o[2].v !== 1) bad++; \
var s = Object.create(p); s.length = 4; Array.prototype.sort.call(s, function (a, b) { return a.v - b.v; }); \
for (var i = 0; i < 4; i++) if (!s[i] || s[i].v !== i + 1) bad++; \
} \
var q = {}; \
for (var i = 0; i < 4; i++) Object.defineProperty(q, i, {get: (function (v) { return function () { return {v: v}; }; })(4 - i), set: function () {}}); \
for (var n = 0; n < 20000; n++) { \
var u = Object.create(q); u.length = 4; Array.prototype.unshift.call(u, 0, 0, 0, 0); \
for (var i = 4; i < 8; i++) if (!u.hasOwnProperty(i) || u[i].v !== 8 - i) bad++; \
} \
for (var n = 0; n < 5000; n++) { var t = Object.create(q); t.length = 4; Array.prototype.sort.call(t); } \
bad";
let mut realm = small(limits.clone());
assert_eq!(realm.run(src), Ok(JsValue::Number(0.0)));
assert!(realm.interp().heap.collections > 0);
// The `z` objects change each round's garbage, so the collections fall at different points.
let src = "var bad = 0; \
for (var n = 0; n < 10000; n++) { \
for (var z = 0; z < n % 7; z++) var g = {z: z}; \
var j = JSON.parse('[1, 2, 3, 4, 5, 6, 7, 8]', function (k, v) { return k === '' ? v : {v: v}; }); \
for (var i = 0; i < 8; i++) if (!j[i] || j[i].v !== i + 1) bad++; \
} bad";
let mut realm = small(limits.clone());
assert_eq!(realm.run(src), Ok(JsValue::Number(0.0)));
assert!(realm.interp().heap.collections > 0);
let src = "var k = ''; for (var i = 0; i < 300; i++) k += 'k'; \
var o = {}; o[k] = {toJSON: function () { var g = {x: 1}; return {a: 1}; }}; \
var want = '{\"' + k + '\":{\"a\":1}}'; \
var bad = 0; for (var n = 0; n < 4000; n++) if (JSON.stringify(o) !== want) bad++; bad";
let mut realm = small(limits);
assert_eq!(realm.run(src), Ok(JsValue::Number(0.0)));
assert!(realm.interp().heap.collections > 0);
}
// ---- errors ----
#[test]
fn error_objects_have_names_messages_and_to_string() {
assert_eq!(string("new TypeError('bad').toString()"), "TypeError: bad");
assert_eq!(string("RangeError('r').name + Error().message + (new Error(undefined).hasOwnProperty('message') ? 1 : 0)"), "RangeError0");
truth("var e = new SyntaxError('s'); e instanceof SyntaxError && e instanceof Error && Object.prototype.toString.call(e) === '[object Error]'");
assert_eq!(string("var e = new Error('m'); e.name = ''; e.toString()"), "m");
assert_eq!(string("Error.prototype.toString.call({name: 'N'})"), "N");
truth("URIError.prototype.name === 'URIError' && Object.getPrototypeOf(EvalError.prototype) === Error.prototype && Error.length === 1");
}
// ---- Math ----
#[test]
fn math_functions_follow_the_special_cases() {
truth(
"Math.max() === -Infinity && Math.min() === Infinity && isNaN(Math.max(1, NaN, 3)) && 1 / Math.max(-0, 0) === Infinity && 1 / Math.min(0, -0) === -Infinity",
);
truth("Math.round(2.5) === 3 && Math.round(-2.5) === -2 && 1 / Math.round(-0.4) === -Infinity && Math.round(0.49999999999999994) === 0");
truth("isNaN(Math.pow(1, NaN)) && isNaN(Math.pow(-1, Infinity)) && Math.pow(NaN, 0) === 1 && Math.pow(2, -1) === 0.5");
truth("Math.abs(-3) === 3 && Math.floor(-1.5) === -2 && Math.ceil(-1.5) === -1 && 1 / Math.ceil(-0.5) === -Infinity");
truth("Math.sqrt(-1) !== Math.sqrt(-1) && Math.atan2(0, -0) === Math.PI && Math.exp(0) === 1 && Math.log(Math.E) === 1");
truth("Math.abs(Math.sin(Math.PI)) < 1e-15 && Math.cos(0) === 1 && Math.tan(0) === 0 && Math.asin(1) === Math.PI / 2");
truth("var ok = true; for (var i = 0; i < 1000; i++) { var r = Math.random(); if (!(r >= 0 && r < 1)) ok = false; } ok");
truth("Math.SQRT2 * Math.SQRT1_2 > 0.9999 && Math.LN2 > 0.69 && Math.LOG10E > 0.43 && Object.prototype.toString.call(Math) === '[object Math]'");
truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2");
}
// ---- JSON ----
#[test]
fn json_parse_reads_the_grammar_of_section_15_12_1() {
assert_eq!(num("var v = JSON.parse(' {\"a\": [1, -2.5e1, true, null, \"x\\\\u0041\\\\n\"], \"b\": {}} '); v.a[1] + v.a.length"), -20.0);
assert_eq!(num("JSON.parse('\"\\\\ud800\"').charCodeAt(0)"), 55296.0);
truth("JSON.parse('[]').length === 0 && JSON.parse('0') === 0 && 1 / JSON.parse('-0') === -Infinity && JSON.parse('{\"a\":1,\"a\":2}').a === 2");
for bad in ["'{a: 1}'", "'[1,]'", "'01'", "'1.'", "'.5'", "'\"\\t\"'", "\"'x'\"", "'[1] 2'", "''", "'+1'", "'\"\\\\x\"'", "'{\"a\" 1}'"] {
throws(&format!("JSON.parse({bad})"), "SyntaxError");
}
// The reviver sees every value bottom-up and can delete or replace it.
assert_eq!(
string(
"var seen = []; var v = JSON.parse('{\"a\": [1, 2], \"b\": 3}', function (k, v) { seen.push(k); return k === 'b' ? undefined : v; }); seen.join() + ':' + ('b' in v)"
),
"0,1,a,b,:false"
);
assert_eq!(num("JSON.parse('[1, 2]', function (k, v) { return typeof v === 'number' ? v * 10 : v; })[1]"), 20.0);
}
#[test]
fn json_stringify_writes_values_with_replacers_and_indentation() {
assert_eq!(
string("JSON.stringify({a: [1, 'x', null, undefined, function () {}], b: undefined, c: true})"),
"{\"a\":[1,\"x\",null,null,null],\"c\":true}"
);
assert_eq!(string("JSON.stringify('\\u0001\"\\\\\\n')"), "\"\\u0001\\\"\\\\\\n\"");
assert_eq!(string("JSON.stringify([NaN, Infinity, -0, 1e21])"), "[null,null,0,1e+21]");
assert_eq!(run("JSON.stringify(undefined)"), JsValue::Undefined);
assert_eq!(run("JSON.stringify(function () {})"), JsValue::Undefined);
assert_eq!(string("JSON.stringify({a: 1, b: [1, 2], c: {}}, null, 2)"), "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ],\n \"c\": {}\n}");
assert_eq!(string("JSON.stringify([1], null, 'abcdefghijkl')"), "[\nabcdefghij1\n]");
assert_eq!(string("JSON.stringify({a: 1, b: 2, c: 3}, ['c', 'a', 'c', 1])"), "{\"c\":3,\"a\":1}");
assert_eq!(string("JSON.stringify({a: 1, b: 'x'}, function (k, v) { return typeof v === 'number' ? v + 1 : v; })"), "{\"a\":2,\"b\":\"x\"}");
assert_eq!(string("JSON.stringify({toJSON: function (k) { return 'k=' + k; }})"), "\"k=\"");
assert_eq!(string("JSON.stringify({d: {toJSON: function (k) { return k; }}})"), "{\"d\":\"d\"}");
assert_eq!(string("JSON.stringify([Object(false), Object(true)])"), "[false,true]");
// Number and String objects go through ToNumber and ToString, which call valueOf and toString.
assert_eq!(string("var n = Object(5); n.valueOf = function () { return 7; }; JSON.stringify([n])"), "[7]");
assert_eq!(string("var s = Object('x'); s.toString = function () { return 'y'; }; JSON.stringify({s: s})"), "{\"s\":\"y\"}");
assert_eq!(string("JSON.stringify({}, null, 4) + JSON.stringify([], null, 4)"), "{}[]");
throws("var a = []; a.push(a); JSON.stringify(a)", "TypeError");
throws("var o = {}; o.x = {y: o}; JSON.stringify(o)", "TypeError");
// The same object twice, not nested in itself, is not a cycle.
assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]");
}
#[test]
fn json_stringify_unwraps_number_string_and_boolean_objects() {
assert_eq!(string("JSON.stringify(Object(3))"), "3");
assert_eq!(string("JSON.stringify(Object('abc'))"), "\"abc\"");
assert_eq!(string("JSON.stringify(Object(true))"), "true");
assert_eq!(string("JSON.stringify({n: Object(-0), m: Object(NaN), s: Object('')})"), "{\"n\":0,\"m\":null,\"s\":\"\"}");
assert_eq!(string("JSON.stringify([Object.getPrototypeOf(Object(3)), Object.getPrototypeOf(Object('abc'))])"), "[0,\"\"]");
// The space argument (step 5).
assert_eq!(string("JSON.stringify([1], null, Object(2))"), "[\n 1\n]");
assert_eq!(string("JSON.stringify([1], null, Object('--'))"), "[\n--1\n]");
// Replacer list entries (step 4.b.ii).
assert_eq!(string("JSON.stringify({a: 1, 3: 2, b: 3}, [Object('a'), Object(3)])"), "{\"a\":1,\"3\":2}");
// Methods on Object.prototype do not reach the objects; methods on their own prototypes do.
assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; JSON.stringify(Object('abc'))"), "\"abc\"");
assert_eq!(string("Object.prototype.valueOf = function () { return 9; }; JSON.stringify(Object(3))"), "3");
assert_eq!(string("Object.getPrototypeOf(Object(1)).valueOf = function () { return 4; }; JSON.stringify(Object(3))"), "4");
assert_eq!(string("Object.getPrototypeOf(Object('')).toString = function () { return 'q'; }; JSON.stringify(Object('abc'))"), "\"q\"");
}
#[test]
fn json_parse_counts_a_string_while_it_reserves_the_property_for_it() {
// The limits around the parsed value's size include one where the reservation for the string's
// property is what starts a collection. The text is a concatenation, so the parse also flattens
// it. Whenever the parse fits, the heap total counts everything that is live.
let setup = |text: &str| format!("var s = new Array(4097).join('x'); var text = {text}; 0");
for text in ["'[\"' + s + '\"]'", "'{\"k\":\"' + s + '\"}'"] {
let setup = setup(text);
let parse = "var a = JSON.parse(text); 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0)));
let full = measure.interp().heap.bytes;
let (mut fits, mut over) = (0, 0);
for heap_bytes in (full - 8192..=full + 256).step_by(4) {
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0)));
realm.interp().collect_garbage();
match realm.run(parse) {
Ok(_) => {
let counted = realm.interp().heap.bytes;
let live = realm.collect_garbage();
assert!(counted >= live, "{text}: {counted} bytes counted with {live} live under a limit of {heap_bytes}");
fits += 1;
}
Err(RunError { kind: RunErrorKind::Limit(LimitKind::Heap), .. }) => over += 1,
Err(e) => panic!("{text}: {e:?}"),
}
}
assert!(fits > 0 && over > 0, "{text}: {fits} limits fit and {over} did not");
}
}
#[test]
fn json_parse_keeps_its_text_counted_for_the_whole_parse() {
// The text exists only as the ToString result of the argument, and the parsed value needs a
// collection. Without the text the value fits under the limit; with it the value does not.
let setup = "var ws = new Array(8193).join(' '); var items = new Array(257).join('{},'); \
var o = {toString: function () { return ws + '[' + items + '{}]'; }}; 0";
let parse = "var a = JSON.parse(o); 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
assert_eq!(measure.run(parse), Ok(JsValue::Number(0.0)));
let full = measure.interp().heap.bytes;
let text = 2 * (8192 + 2 + 3 * 256 + 2);
let mut realm = small(Limits { heap_bytes: full - text / 2, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
realm.interp().collect_garbage();
assert_eq!(limit(&mut realm, parse), LimitKind::Heap);
// The text is released before the reviver runs, so the reviver can use the room it took.
let setup = "var ws = new Array(65537).join(' '); var o = {toString: function () { return ws + '1'; }}; 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0)));
let heap_bytes = measure.collect_garbage() + 2 * 65537 + (16 << 10);
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(realm.run("JSON.parse(o, function () { return new Array(40001).join('x').length; })"), Ok(JsValue::Number(40000.0)));
}
#[test]
fn json_parse_counts_names_that_wait_for_their_values() {
// An unterminated text: every name read waits for a value that never comes, so nothing but
// the parser holds them, and the names together pass the limit before the text ends.
let setup = "var k = new Array(2049).join('k'); var text = new Array(33).join('{\"' + k + '\":'); 0";
let mut measure = small(Limits::default());
assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0)));
let heap_bytes = measure.collect_garbage() + (96 << 10);
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(limit(&mut realm, "JSON.parse(text)"), LimitKind::Heap);
// A complete text gives its names back as it stores them, so one that fits parses.
let mut realm = small(Limits { heap_bytes: heap_bytes + (64 << 10), ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
assert_eq!(realm.run("var a = JSON.parse('{\"' + k + '\":{\"' + k + 'x\":1}}'); 0"), Ok(JsValue::Number(0.0)));
}
#[test]
fn deep_json_nesting_needs_no_native_stack() {
let handle = std::thread::Builder::new()
.stack_size(256 << 10)
.spawn(|| {
let mut realm = Realm::new();
realm.run(
"var depth = 100000; var s = ''; for (var i = 0; i < depth; i++) s += '['; for (i = 0; i < depth; i++) s += ']'; \
var v = JSON.parse(s); var back = JSON.stringify(v); var n = 0; JSON.parse(s, function (k, v) { n++; return v; }); \
back.length === 2 * depth && n === depth",
)
})
.unwrap();
assert_eq!(handle.join().unwrap(), Ok(JsValue::Bool(true)));
}
// ---- global functions ----
#[test]
fn global_number_functions() {
truth("parseInt(' 0x1F') === 31 && parseInt('-12px') === -12 && parseInt('z', 36) === 35 && isNaN(parseInt('', 10)) && isNaN(parseInt('1', 1))");
truth(
"parseInt('011') === 11 && parseInt('11', 2) === 3 && parseInt('0x10', 16) === 16 && isNaN(parseInt('0x', 16)) && parseInt('12', 3.9) === 5",
);
truth("parseInt('123456789012345678901234567890') === 1.2345678901234568e29 && 1 / parseInt('-0') === -Infinity");
truth(
"parseFloat(' 3.5e2x') === 350 && parseFloat('-.5') === -0.5 && parseFloat('Infinityx') === Infinity && isNaN(parseFloat('e5')) && parseFloat('1e') === 1",
);
truth("isNaN('x') && !isNaN('1') && isFinite('1') && !isFinite(Infinity) && !isFinite(NaN)");
truth("NaN !== NaN && Infinity === 1 / 0 && undefined === void 0 && !Object.getOwnPropertyDescriptor(this, 'NaN').writable");
}
#[test]
fn uri_functions_encode_and_decode_utf8() {
assert_eq!(string("encodeURIComponent('a b&c/\\u00e9\\ud83d\\ude00')"), "a%20b%26c%2F%C3%A9%F0%9F%98%80");
assert_eq!(string("encodeURI('http://x.y/a b?c=d#e')"), "http://x.y/a%20b?c=d#e");
assert_eq!(string("decodeURIComponent('a%20b%26%C3%A9%F0%9F%98%80') === 'a b&\\u00e9\\ud83d\\ude00' ? 'ok' : 'no'"), "ok");
assert_eq!(string("decodeURI('%3B%2F%20%23')"), "%3B%2F %23");
for bad in [
"encodeURI('\\ud800')",
"encodeURIComponent('\\udc00x')",
"decodeURI('%')",
"decodeURI('%C3')",
"decodeURI('%C0%80')",
"decodeURI('%ED%A0%80')",
"decodeURI('%FF')",
] {
throws(bad, "URIError");
}
}
#[test]
fn escape_and_unescape_follow_annex_b() {
assert_eq!(string("escape('a b+/@\\u00e9\\u0100')"), "a%20b+/@%E9%u0100");
assert_eq!(string("unescape('a%20b%E9%u0100%zz%u12') === 'a b\\u00e9\\u0100%zz%u12' ? 'ok' : 'no'"), "ok");
}
// ---- robustness ----
/// Calls of built-ins with arguments of every kind.
const CALLS: &[&str] = &[
"[1,,3].join(X)",
"[3,1,2].sort(X)",
"[1,2,3].splice(X, X, X)",
"[1,2].slice(X, X)",
"[].concat(X, [X])",
"[1,2,3].map(X)",
"[1,2].reduce(X, X)",
"[1,2].indexOf(X, X)",
"[1,2].lastIndexOf(X, X)",
"Array.prototype.push.call(X, X)",
"Array.prototype.unshift.call(X, X)",
"Array.prototype.reverse.call(X)",
"Array(X)",
"Object.keys(X)",
"Object.freeze(X)",
"Object.defineProperty(X, X, X)",
"Object.create(X, X)",
"Object.getOwnPropertyNames(X)",
"JSON.stringify(X, X, X)",
"JSON.parse(X, X)",
"Math.max(X, X)",
"Math.pow(X, X)",
"parseInt(X, X)",
"parseFloat(X)",
"decodeURIComponent(X)",
"encodeURI(X)",
"unescape(X)",
"escape(X)",
"Function.prototype.bind.call(X, X)",
];
const ARGS: &[&str] = &[
"undefined",
"null",
"0",
"-1",
"4294967295",
"NaN",
"'%E0'",
"'[1,{\"a\":2}]'",
"''",
"[]",
"{length: 4294967295}",
"{}",
"function (a, b) { return b - a; }",
"{valueOf: function () { throw 1; }}",
"{get length() { return 3; }}",
"Object.freeze([1, 2])",
"{value: 1, get: function () {}}",
];
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_built_in_calls_never_panic(call in 0..CALLS.len(), args in proptest::collection::vec(0..ARGS.len(), 3)) {
let mut src = CALLS[call].to_string();
for a in &args {
src = src.replacen('X', ARGS[*a], 1);
}
let src = src.replace('X', "1");
let mut realm = small(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 });
if let Err(e) = realm.run(&src) {
prop_assert!(e.kind != RunErrorKind::Internal, "{src}: {e:?}");
}
}
}
+28 -4
View File
@@ -170,11 +170,14 @@ pub(crate) enum NativeKind {
Eval,
}
/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section
/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given.
#[derive(Clone)]
pub(crate) enum Func {
Closure { code: Rc<Code>, env: EnvId },
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
/// A sloppy mode `arguments` object's link to its function's parameters (section 10.6): index
@@ -204,8 +207,8 @@ pub(crate) enum Class {
Function(Func),
Error,
Arguments(Box<ArgMap>),
/// A `Boolean` object made by ToObject.
Boolean,
/// A `Boolean` object made by ToObject, with its primitive value.
Boolean(bool),
/// A `Number` object and its number.
Number(f64),
String(Str),
@@ -213,18 +216,24 @@ pub(crate) enum Class {
/// A `Date` object and its time value.
Date(f64),
ForIn(Box<ForIn>),
/// The `Math` object (section 15.8).
Math,
/// The `JSON` object (section 15.12).
Json,
}
impl Class {
/// The `[[Class]]` name `Object.prototype.toString` reports.
pub(crate) fn name(&self) -> &'static str {
match self {
Class::Math => "Math",
Class::Json => "JSON",
Class::Ordinary | Class::ForIn(_) => "Object",
Class::Array { .. } => "Array",
Class::Function(_) => "Function",
Class::Error => "Error",
Class::Arguments(_) => "Arguments",
Class::Boolean => "Boolean",
Class::Boolean(_) => "Boolean",
Class::Number(_) => "Number",
Class::String(_) => "String",
Class::RegExp(_) => "RegExp",
@@ -292,6 +301,8 @@ pub(crate) const OBJECT_BYTES: usize = 96;
pub(crate) const ENV_BYTES: usize = 64;
/// The estimated bytes of a rope node.
pub(crate) const ROPE_BYTES: usize = 48;
/// The estimated bytes of each argument a bound function holds.
pub(crate) const BOUND_ARG_BYTES: usize = 24;
/// The estimated bytes of a piece of code without its instructions, tables and strings.
const CODE_BYTES: usize = 384;
@@ -308,6 +319,10 @@ pub(crate) struct Heap {
pub(crate) bytes: usize,
/// How many collections have run.
pub(crate) collections: usize,
/// Counts the properties whose names start with a digit (the array indices among them) added
/// to any object, so an operation that lists the indices an object has can tell whether a
/// script added one since.
pub(crate) index_epoch: u64,
}
impl Heap {
@@ -524,11 +539,20 @@ fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> u
Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)),
}
}
let mut extra = 0;
match &o.class {
Class::Function(Func::Closure { env, code }) => {
work.push(env.0);
mark_code(code, strings);
}
Class::Function(Func::Bound { target, this, args }) => {
work.push(target.0);
mark_value(this, work, strings);
for a in args.iter() {
mark_value(a, work, strings);
}
extra = BOUND_ARG_BYTES * args.len();
}
Class::Arguments(map) => work.push(map.env.0),
Class::String(s) => strings.add(s),
Class::RegExp(re) => {
@@ -542,7 +566,7 @@ fn trace_object(o: &Object, work: &mut Vec<u32>, strings: &mut StringSizes) -> u
}
_ => {}
}
OBJECT_BYTES + PROP_BYTES * o.props.len()
OBJECT_BYTES + PROP_BYTES * o.props.len() + extra
}
fn trace_env(e: &Env, work: &mut Vec<u32>, strings: &mut StringSizes) -> usize {
+376 -28
View File
@@ -164,6 +164,17 @@ pub(crate) struct Interp {
pub(crate) throw_site: Option<ThrowSite>,
/// The last exception a run did not catch.
pub(crate) last_thrown: Value,
/// The state of `Math.random`'s generator.
pub(crate) random: u64,
/// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a
/// cycle back to one of them joins as an empty string.
pub(crate) join_stack: Vec<ObjId>,
/// Bytes of the string buffers native functions are building outside the heap. They are
/// charged to `heap.bytes` and added back after each collection, and a native call's
/// buffers are released when it returns.
pub(crate) held_bytes: usize,
/// Why installing the built-ins failed; a realm with this set runs no script.
pub(crate) install_error: Option<String>,
/// The clock and time zone `Date` reads.
pub(crate) date_host: crate::date_builtins::DateHost,
/// The bytes native code holds outside the heap's cells through [`HeldBytes`].
@@ -239,6 +250,15 @@ fn index_keys_bytes(len: usize) -> usize {
total
}
/// A seed for `Math.random` that differs from realm to realm, from the standard library's
/// randomly keyed hasher; never zero.
fn random_seed() -> u64 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(0x9e37_79b9_7f4a_7c15);
h.finish() | 1
}
/// The estimated bytes of a binding or property named by `len` code units.
fn binding_bytes(len: usize) -> usize {
PROP_BYTES + string_bytes(len)
@@ -341,6 +361,10 @@ impl Interp {
source_units: 0,
throw_site: None,
last_thrown: Value::Undefined,
random: random_seed(),
join_stack: Vec::new(),
held_bytes: 0,
install_error: None,
date_host: crate::date_builtins::DateHost::default(),
held: Rc::default(),
};
@@ -515,7 +539,7 @@ impl Interp {
self.heap.collect(&roots);
// The collection counts the live total again, so bytes already released are not in it.
self.held.released.set(0);
self.heap.bytes = self.heap.bytes.saturating_add(self.held.now.get());
self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get());
}
/// Runs frames until the boundary frame on top returns, and returns its value.
@@ -1058,13 +1082,219 @@ impl Interp {
*length = i + 1;
}
let name_len = k.len();
let index = new && k.first().is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u));
o.props.insert(k, prop);
if new {
self.heap.bytes += PROP_BYTES + string_bytes(name_len);
}
if index {
self.heap.index_epoch = self.heap.index_epoch.wrapping_add(1);
}
Ok(())
}
/// [[DefineOwnProperty]] (section 8.12.9, with the array rules of section 15.4.5.1 and the
/// arguments object rules of section 10.6). Returns whether the property was defined; with
/// `throw` a rejection throws a `TypeError` instead.
pub(crate) fn define_own_property(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult<bool> {
match &self.object(id)?.class {
Class::Array { length, length_writable } => {
let (length, length_writable) = (*length, *length_writable);
if is(&k, "length") {
return self.define_array_length(id, desc, throw);
}
if let Some(i) = array_index(&k)
&& i >= length
&& !length_writable
{
return self.reject_bool(throw, &k, "the array's length is read-only");
}
self.define_ordinary(id, k, desc, throw)
}
Class::Arguments(map) => {
let mapped = array_index(&k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref).cloned();
if !self.define_ordinary(id, k.clone(), desc, false)? {
return self.reject_bool(throw, &k, "the property cannot be redefined");
}
if let Some(name) = mapped {
let env = match &self.object(id)?.class {
Class::Arguments(map) => Some(map.env),
_ => None,
};
if let Some(v) = desc.value.clone().filter(|_| !desc.is_accessor())
&& let Some(env) = env
&& let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env)
&& let Some(b) = bindings.get_mut(&name)
{
b.value = v;
}
if (desc.is_accessor() || desc.writable == Some(false))
&& let Class::Arguments(map) = &mut self.object_mut(id)?.class
&& let Some(slot) = array_index(&k).and_then(|i| map.names.get_mut(i as usize))
{
*slot = None;
}
}
Ok(true)
}
_ => self.define_ordinary(id, k, desc, throw),
}
}
fn reject_bool(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<bool> {
if throw {
let message = format!("cannot define property '{}': {why}", String::from_utf16_lossy(k));
return Err(self.type_error(&message));
}
Ok(false)
}
/// The default [[DefineOwnProperty]] (section 8.12.9).
fn define_ordinary(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult<bool> {
let current = self.get_own_property(id, &k)?;
let o = self.object(id)?;
let virtual_string = matches!(&o.class, Class::String(s) if is(&k, "length") || array_index(&k).is_some_and(|i| (i as usize) < s.len()));
let Some(current) = current else {
if !o.extensible {
return self.reject_bool(throw, &k, "the object is not extensible");
}
// The object and the descriptor's values stay rooted while the heap is checked.
let mut roots = vec![Value::Object(id)];
roots.extend(desc.value.iter().cloned());
roots.extend(desc.get.flatten().into_iter().chain(desc.set.flatten()).map(Value::Object));
self.rooted(&roots, |i| i.reserve_heap(binding_bytes(k.len())))?;
let flags = desc.flags_or(0);
let prop = if desc.is_accessor() {
Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: flags & !WRITABLE }
} else {
Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags }
};
self.define(id, k, prop)?;
return Ok(true);
};
let Some(merged) = merge_desc(&current, desc) else { return self.reject_bool(throw, &k, "the property cannot be redefined") };
// An index or the length of a String object cannot change, so a definition that passes
// the checks leaves it as it is.
if virtual_string {
return Ok(true);
}
self.define(id, k, merged)?;
Ok(true)
}
/// Defining an array's `length` (section 15.4.5.1, step 3).
fn define_array_length(&mut self, id: ObjId, desc: &Desc, throw: bool) -> JsResult<bool> {
let length_key = key("length");
let Some(v) = desc.value.clone() else {
let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") };
let Some(merged) = merge_desc(&current, desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") };
if let Class::Array { length_writable, .. } = &mut self.object_mut(id)?.class {
*length_writable = merged.flags() & WRITABLE != 0;
}
return Ok(true);
};
let new = to_uint32(self.to_number(&v)?);
if f64::from(new) != self.to_number(&v)? {
return Err(self.throw(ErrorType::Range, "invalid array length"));
}
let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") };
let Class::Array { length: old, length_writable } = self.object(id)?.class else { return internal("not an array") };
let new_desc = Desc { value: Some(Value::Number(f64::from(new))), ..desc.clone() };
if new >= old {
let Some(merged) = merge_desc(&current, &new_desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") };
if let Class::Array { length, length_writable } = &mut self.object_mut(id)?.class {
*length = new;
*length_writable = merged.flags() & WRITABLE != 0;
}
return Ok(true);
}
if !length_writable {
return self.reject_bool(throw, &length_key, "it is read-only");
}
let checked = Desc { writable: None, ..new_desc };
if merge_desc(&current, &checked).is_none() {
return self.reject_bool(throw, &length_key, "the length cannot be redefined");
}
let read_only = desc.writable == Some(false);
if !self.shrink_array(id, new, read_only)? {
return self.reject_bool(throw, &length_key, "an element cannot be deleted");
}
Ok(true)
}
/// Sets an array's length to `new`, deleting the elements at and past it from the last one
/// down (section 15.4.5.1, step 3.l). An element that cannot be deleted stops it, leaving
/// the length one past that element, and gives `false`. Each property takes a unit of fuel
/// before anything is removed. With `read_only` the length is made read-only afterwards
/// either way.
fn shrink_array(&mut self, id: ObjId, new: u32, read_only: bool) -> JsResult<bool> {
let Class::Array { length: old, .. } = self.object(id)?.class else { return internal("not an array") };
let mut ok = true;
let mut length = new;
if new < old {
for _ in 0..self.object(id)?.props.len() {
self.use_fuel()?;
}
let o = self.object_mut(id)?;
let mut doomed: Vec<(u32, Key)> =
o.props.iter().filter_map(|(k, _)| array_index(k).filter(|&i| i >= new).map(|i| (i, k.clone()))).collect();
doomed.sort_unstable_by_key(|d| std::cmp::Reverse(d.0));
for (i, k) in doomed {
if o.props.get(&k).is_some_and(|p| !p.configurable()) {
length = i + 1;
ok = false;
break;
}
o.props.remove(&k);
}
}
if let Class::Array { length: l, length_writable } = &mut self.object_mut(id)?.class {
*l = length;
if read_only {
*length_writable = false;
}
}
Ok(ok)
}
/// The names of an object's own properties (section 15.2.3.4): a String object's indices and
/// `length` first, then the rest in the order they were added, which is the order `for-in`
/// visits them, then an array's `length`. Each name takes a unit of fuel, and the heap for the
/// list is checked first.
pub(crate) fn own_keys(&mut self, id: ObjId) -> JsResult<Vec<Key>> {
let o = self.object(id)?;
let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 };
let is_string = matches!(o.class, Class::String(_));
let is_array = matches!(o.class, Class::Array { .. });
let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(is_string || is_array));
self.reserve_heap(index_keys_bytes(string_len).saturating_add(count.saturating_mul(KEY_ENTRY_BYTES)))?;
let mut out = Vec::with_capacity(count);
for i in 0..string_len {
self.use_fuel()?;
out.push(index_key(u32::try_from(i).unwrap_or(u32::MAX)));
}
if is_string {
out.push(key("length"));
}
for _ in 0..self.object(id)?.props.len() {
self.use_fuel()?;
}
out.extend(self.object(id)?.props.keys());
if is_array {
out.push(key("length"));
}
Ok(out)
}
/// Runs `f` with `values` kept reachable for any collection it starts.
pub(crate) fn rooted<T>(&mut self, values: &[Value], f: impl FnOnce(&mut Self) -> JsResult<T>) -> JsResult<T> {
let mark = self.temp_roots.len();
self.temp_roots.extend(values.iter().cloned());
let r = f(self);
self.temp_roots.truncate(mark);
r
}
fn reject(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<()> {
if throw {
let message = format!("cannot assign to property '{}': {why}", String::from_utf16_lossy(k));
@@ -1131,32 +1361,19 @@ impl Interp {
}
}
/// Assigning to an array's `length` (section 15.4.5.1).
/// Assigning to an array's `length` (section 15.4.5.1). [[Put]] defines the value, so the
/// value is converted once with ToUint32 and once with ToNumber, as in steps 3.c and 3.d.
fn set_array_length(&mut self, id: ObjId, v: &Value, throw: bool) -> JsResult<()> {
let n = self.to_number(v)?;
let new = to_uint32(n);
if f64::from(new) != n {
let new = to_uint32(self.to_number(v)?);
if f64::from(new) != self.to_number(v)? {
return Err(self.throw(ErrorType::Range, "invalid array length"));
}
let o = self.object(id)?;
let Class::Array { length: old, length_writable } = o.class else { return internal("not an array") };
let Class::Array { length_writable, .. } = self.object(id)?.class else { return internal("not an array") };
if !length_writable {
return self.reject(throw, &key("length"), "it is read-only");
}
if new < old {
// Shrinking visits every property: each takes fuel before anything is removed.
for _ in 0..o.props.len() {
self.use_fuel()?;
}
}
let o = self.object_mut(id)?;
let Class::Array { length, .. } = &mut o.class else { return internal("not an array") };
*length = new;
if new < old {
let doomed: Vec<Key> = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect();
for k in doomed {
o.props.remove(&k);
}
if !self.shrink_array(id, new, false)? {
return self.reject(throw, &key("length"), "an element cannot be deleted");
}
Ok(())
}
@@ -1576,13 +1793,25 @@ impl Interp {
this = args.first().cloned().unwrap_or(Value::Undefined);
args = self.list_from_array_like(&list)?;
}
Func::Bound { target, this: bound_this, args: bound_args } => {
self.use_fuel()?;
let mut all = Vec::with_capacity(bound_args.len().saturating_add(args.len()));
all.extend(bound_args.iter().cloned());
all.append(&mut args);
args = all;
this = bound_this;
f = Value::Object(target);
}
Func::Native { f: native, .. } => {
// A list from `apply` is not on the operand stack, and a native function can run
// JavaScript that collects, so the arguments stay rooted for the call.
// A list from `apply` and the arguments a bound function adds are not on the
// operand stack, and a native function can run JavaScript that collects, so the
// arguments stay rooted for the call.
let mark = self.temp_roots.len();
self.temp_roots.push(this.clone());
self.temp_roots.extend(args.iter().cloned());
let held = self.held_bytes;
let r = native(self, this, &args, false);
self.held_bytes = held;
self.temp_roots.truncate(mark);
return Ok(Prepared::Done(r?));
}
@@ -1660,8 +1889,17 @@ impl Interp {
let n = argc as usize;
let start =
self.stack.len().checked_sub(n + 1).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("new below the stack".into()))?;
let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined);
let args: Vec<Value> = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default();
let mut f = self.stack.get(start).cloned().unwrap_or(Value::Undefined);
let mut args: Vec<Value> = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default();
// A bound function constructs its target with its arguments first (section 15.3.4.5.2);
// the bound function on the stack keeps them reachable.
while let Some(Func::Bound { target, args: bound_args, .. }) = self.func_of(&f) {
self.use_fuel()?;
let mut all = bound_args.to_vec();
all.append(&mut args);
args = all;
f = Value::Object(target);
}
match self.func_of(&f) {
Some(Func::Closure { code, env }) => {
let Some(callee) = f.as_object() else { return internal("callee is not an object") };
@@ -1674,7 +1912,10 @@ impl Interp {
self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false)
}
Some(Func::Native { f: native, constructor: true, .. }) => {
let v = native(self, Value::Undefined, &args, true)?;
let held = self.held_bytes;
let r = native(self, Value::Undefined, &args, true);
self.held_bytes = held;
let v = r?;
self.stack.truncate(start);
self.push(v)
}
@@ -2081,7 +2322,7 @@ impl Interp {
let (class, proto) = match v {
Value::Object(o) => return Ok(*o),
Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")),
Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto),
Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto),
Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto),
Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto),
};
@@ -2226,7 +2467,13 @@ impl Interp {
if !self.is_callable(f) {
return Err(self.type_error("the right side of 'instanceof' is not a function"));
}
let Some(fid) = f.as_object() else { return Ok(false) };
let Some(mut fid) = f.as_object() else { return Ok(false) };
// A bound function answers for its target (section 15.3.4.5.3).
while let Some(Func::Bound { target, .. }) = self.func_of(&Value::Object(fid)) {
self.use_fuel()?;
fid = target;
}
let f = &Value::Object(fid);
let Value::Object(mut o) = v.clone() else { return Ok(false) };
let Value::Object(proto) = self.get_from(fid, &key("prototype"), f)? else {
return Err(self.type_error("the function's prototype is not an object"));
@@ -2301,6 +2548,107 @@ pub(crate) enum CompileError {
Internal(String),
}
/// A property descriptor (section 8.10): each field is `None` when it is absent. `get` and `set`
/// are `Some(None)` when present as `undefined`.
#[derive(Clone, Default, Debug)]
pub(crate) struct Desc {
pub(crate) value: Option<Value>,
pub(crate) get: Option<Option<ObjId>>,
pub(crate) set: Option<Option<ObjId>>,
pub(crate) writable: Option<bool>,
pub(crate) enumerable: Option<bool>,
pub(crate) configurable: Option<bool>,
}
impl Desc {
/// A data descriptor with every attribute given.
pub(crate) fn data(value: Value, flags: u8) -> Self {
Desc {
value: Some(value),
writable: Some(flags & WRITABLE != 0),
enumerable: Some(flags & ENUMERABLE != 0),
configurable: Some(flags & CONFIGURABLE != 0),
..Desc::default()
}
}
pub(crate) fn is_accessor(&self) -> bool {
self.get.is_some() || self.set.is_some()
}
pub(crate) fn is_data(&self) -> bool {
self.value.is_some() || self.writable.is_some()
}
/// The attribute flags the descriptor gives, each absent one taken from `default`.
fn flags_or(&self, default: u8) -> u8 {
let pick = |field: Option<bool>, bit: u8| match field {
Some(true) => bit,
Some(false) => 0,
None => default & bit,
};
pick(self.writable, WRITABLE) | pick(self.enumerable, ENUMERABLE) | pick(self.configurable, CONFIGURABLE)
}
}
/// SameValue (section 9.12).
pub(crate) fn same_value(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.is_nan() && y.is_nan()) || (x == y && x.is_sign_negative() == y.is_sign_negative()),
_ => strict_eq(a, b),
}
}
/// Steps 5 to 12 of [[DefineOwnProperty]] (section 8.12.9) for an existing property: the
/// property after `desc` is applied, or `None` when the change is not allowed.
fn merge_desc(current: &Prop, desc: &Desc) -> Option<Prop> {
let flags = current.flags();
let configurable = flags & CONFIGURABLE != 0;
if !configurable {
if desc.configurable == Some(true) {
return None;
}
if desc.enumerable.is_some_and(|e| e != (flags & ENUMERABLE != 0)) {
return None;
}
}
let attrs = |base: u8, writable_bit: bool| {
let f = desc.flags_or(base);
if writable_bit { f } else { f & !WRITABLE }
};
match current {
Prop::Data { .. } if desc.is_accessor() => {
if !configurable {
return None;
}
Some(Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: attrs(flags & !WRITABLE, false) })
}
Prop::Accessor { .. } if desc.is_data() => {
if !configurable {
return None;
}
Some(Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags: attrs(flags & !WRITABLE, true) })
}
Prop::Data { value, .. } => {
if !configurable && flags & WRITABLE == 0 {
if desc.writable == Some(true) {
return None;
}
if desc.value.as_ref().is_some_and(|v| !same_value(v, value)) {
return None;
}
}
Some(Prop::Data { value: desc.value.clone().unwrap_or_else(|| value.clone()), flags: attrs(flags, true) })
}
Prop::Accessor { get, set, .. } => {
if !configurable && (desc.get.is_some_and(|g| g != *get) || desc.set.is_some_and(|s| s != *set)) {
return None;
}
Some(Prop::Accessor { get: desc.get.unwrap_or(*get), set: desc.set.unwrap_or(*set), flags: attrs(flags, false) })
}
}
}
/// The strict equality comparison (section 11.9.6).
pub(crate) fn strict_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
+12 -6
View File
@@ -24,12 +24,16 @@
//! deep recursion in a script does not use the native stack. Objects and environments live in an
//! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped
//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these
//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with
//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`,
//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a
//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every
//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and
//! [`Realm::set_time_zone`]. Every run is bounded
//! built-ins: the global functions and constants (15.1, with Annex B's `escape` and `unescape`),
//! `Object` (15.2), `Function` with `call`, `apply` and `bind` (15.3), `Array` (15.4), `String`
//! (15.5), `Boolean.prototype`'s `toString` and `valueOf` (15.6.4), `Number` (15.7), `Math` (15.8),
//! `Date` (15.9), `RegExp` (15.10), the error types (15.11) and `JSON` (15.12). The array methods
//! visit only the indices an array or array-like object has, so a sparse array of length 2^32-1
//! costs as much as its elements, and `JSON.parse`, `JSON.stringify` and the reviver walk use no
//! native recursion. Regular expressions run on a backtracking matcher that keeps its choice points
//! on its own stack and takes fuel for every step; `Date` reads the clock and time zone the host
//! sets with [`Realm::set_clock`] and [`Realm::set_time_zone`]. The prototype methods are writable
//! and configurable, so scripts can replace them. Every run is bounded
//! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an
//! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with
//! [`RunErrorKind::Limit`], which a script's `try` cannot catch.
@@ -178,6 +182,8 @@ impl LineTable {
}
}
#[cfg(test)]
mod builtins_tests;
#[cfg(test)]
mod interp_tests;
#[cfg(test)]

Some files were not shown because too many files have changed in this diff Show More