Merge main into feat/plugin-effect-paint

This commit is contained in:
dotCooCoo
2026-11-08 17:59:33 -08:00
145 changed files with 5805 additions and 400 deletions
+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;
+1
View File
@@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId};
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
pub use live_shape::{LiveShape, Scaling};
pub use opacity::{Knockout, OpacityMask, whiten};
pub use ops::{flattens, keeps_gradient_fit};
use wire::NodeWire;
pub use wire::{MAX_READ_NODES, with_node_budget};
+4
View File
@@ -329,6 +329,10 @@ pub struct Scaling {
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
pub patterns: bool,
/// Gradients fitted to the object stay fitted under a transform that flattens it to a line or
/// a point (no area left), as a Transform effect leaves them, instead of being fixed to their
/// fit and flattened to one color with the object.
pub keep_fit_when_flat: bool,
}
impl Scaling {
+14 -3
View File
@@ -433,7 +433,7 @@ impl Node {
// Type and symbols in perspective keep looking the same, moved by `a`.
self.transform_projection(a);
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) {
self.pin_gradients();
}
let k = Scaling::factor(a);
@@ -772,13 +772,24 @@ impl Node {
}
}
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool {
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The
/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a
/// reflection and an uneven scale give false, and so does a map that flattens the object
/// ([`flattens`]), which callers that keep a fitted gradient there check apart.
pub fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-12 * m0.abs().max(1.0);
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the
/// squares of its terms), or is it not finite?
pub fn flattens(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3;
!a.is_finite() || a.determinant().abs() <= 1e-12 * norm
}
impl Node {
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
/// everywhere for objects without them, such as type, whose boxes stand in).
+2 -1
View File
@@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) {
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
const KEEP_CORNERS: Scaling =
Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false };
#[test]
fn uneven_scales_keep_live_corners_circular() {
+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
+133
View File
@@ -210,6 +210,139 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo
assert_eq!(baked(&fitted, distort()).1.geom, None);
}
/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a
/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped.
/// Moves and even scales leave it fitted.
#[test]
fn a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone()))));
let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None };
// The geometry of the baked fill (`own`: the Transform effect is the fill's own effect).
let baked = |kind, effects: &[Effect], own: bool| {
let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0);
if own {
*ap.items[0].effects_mut() = effects.to_vec();
}
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
if !own {
n.appearance.effects = effects.to_vec();
}
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
if let Paint::Gradient(g) = c.appearance.fill_paint() {
found.push(g.geom);
}
});
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
found[0]
};
let t = |params| fx("distort.transform", params);
let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"}));
let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]});
let linear = GradientKind::Linear;
let cases = [
// A quarter turn about the center: the left to right ramp runs from the bottom up.
(linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))),
// Reflected left to right: the ramp runs from right to left.
(linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))),
// With Scale Strokes & Effects on, the same.
(linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))),
// Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt.
(linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))),
// Stretched to twice the width: the circle of radius 50 about the center becomes an
// ellipse 100 pt across and 50 pt tall.
(GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }),
// Turned, then moved one width right by Free Distort: the fixed gradient moves with it.
(linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))),
];
for (kind, effects, want) in cases {
for own in [false, true] {
let what = format!("{kind:?} {effects:?}, own {own}");
let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
assert_geom_near(got, want, &what);
}
}
// A move and an even scale are what a refit gives: the gradient stays fitted.
for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] {
for own in [false, true] {
assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}");
}
}
// A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102,
// with Scale Strokes & Effects off or on.
let stroked = |effects: &[Effect], own: bool| {
let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0);
let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
if own {
*stroke.effects_mut() = effects.to_vec();
}
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
if !own {
n.appearance.effects = effects.to_vec();
}
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let AppearanceItem::Stroke(s) = item
&& let Paint::Gradient(g) = &s.paint
{
found.push(g.geom);
}
}
});
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
found[0]
};
for strokes in [false, true] {
for own in [false, true] {
let what = format!("stroke, strokes {strokes}, own {own}");
let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what);
}
}
}
/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted:
/// mapping it with the effect would flatten it to one color.
#[test]
fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
// With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them.
for (options, strokes) in [
(json!({}), 1),
(json!({"strokes": true}), 1),
(json!({"patterns": true}), 1),
(json!({"copies": 2}), 3),
(json!({"strokes": true, "copies": 2}), 3),
(json!({"patterns": true, "copies": 2}), 3),
] {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0));
let mut p = json!({"scaleH": 0.0, "rotate": 30.0});
if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {
p.extend(o.clone());
}
n.appearance.effects = vec![fx("distort.transform", p)];
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let AppearanceItem::Stroke(s) = item
&& let Paint::Gradient(g) = &s.paint
{
found.push(g.geom);
}
}
});
assert_eq!(found, vec![None; strokes], "{options}");
}
}
#[test]
fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+10 -2
View File
@@ -1,4 +1,4 @@
use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
@@ -80,6 +80,7 @@ fn lengths_of(id: &str) -> Lengths {
"path.offsetPath" => always(&["offset"]),
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.classicExtrude" => always(&["depth", "bevelHeight"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
@@ -199,6 +200,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
CLASSIC_EXTRUDE,
"Extrude & Bevel (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
),
g(
"convertToShape.rectangle",
"Rectangle…",
@@ -239,7 +247,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
),
g(
+40 -15
View File
@@ -178,22 +178,32 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R
(out.to_bezpath(), carry)
}
/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and
/// the Warp effects move and scale them from the object's bounds before the effect to its bounds
/// after it. For the other geometry effects it is empty. It does not change gradients fitted to
/// the object.
/// How gradients follow geometry effects. The Transform effect without copies maps placed
/// gradients as it maps the object (with copies, `crate::reshape` gives each copy its own). Free
/// Distort and the Warp effects move and scale them from the object's bounds before the effect to
/// its bounds after it. For the other geometry effects it is empty. A gradient fitted to the
/// object fits the geometry each effect makes, until a Transform effect turns, reflects or scales
/// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its
/// fit on the geometry that effect takes, and then follows that effect and the ones after it as a
/// placed gradient does.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Carry(Vec<CarryStep>);
/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit
/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map
/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it
/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]).
pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool {
!vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum CarryStep {
Affine(vectorcraft_geom::Affine),
/// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient
/// fits before `m` moves it (none: fitted gradients stay fitted).
Affine { m: vectorcraft_geom::Affine, from: Option<Rect> },
/// From box `from` to box `to` (strokes: their stroke-inflated boxes).
Boxes {
from: Rect,
to: Rect,
},
Boxes { from: Rect, to: Rect },
}
impl Carry {
@@ -233,7 +243,7 @@ impl Carry {
return Carry::default();
}
let step = match id {
"distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)),
"distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }),
_ => match (from, to) {
(Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => {
Some(CarryStep::Boxes { from, to })
@@ -253,17 +263,32 @@ impl Carry {
/// freeform gradient's own points included)? Callers check first, so an appearance shared with
/// other objects is not copied for nothing.
pub fn moves(&self, ap: &Appearance) -> bool {
!self.0.is_empty() && ap.has_positioned_gradient()
!self.0.is_empty() && (ap.has_positioned_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient()))
}
/// Move the placed gradients of `ap` along, through each step in turn.
/// The first step that fixes gradients fitted to the object: a map a refit would not
/// reproduce, on geometry whose bounds are known.
fn pin(&self) -> Option<usize> {
self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m)))
}
/// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones
/// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps
/// after it.
pub fn apply(&self, ap: &mut Appearance) {
if !self.moves(ap) {
return;
}
for s in &self.0 {
// The steps move placed gradients only; fitted ones become placed at the pin.
let pin = self.pin();
for (k, s) in self.0.iter().enumerate() {
if Some(k) == pin
&& let CarryStep::Affine { from: Some(b), .. } = *s
{
ap.pin_gradients(b);
}
match *s {
CarryStep::Affine(m) => ap.transform_gradients(m),
CarryStep::Affine { m, .. } => ap.transform_gradients(m),
CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to),
}
}
+64
View File
@@ -598,6 +598,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
}
}
/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the
/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own
/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients
/// fitted.
#[test]
fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::ImageObject;
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let square = |id: u64, x: f64| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0)))
};
let mut image = Node::new(
NodeId(4),
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::translate((30.0, 0.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
// A symbol instance whose symbol is not in a document is measured as the 20 pt square about
// its origin: here 22.5..27.5 across and 2.5..7.5 down.
let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25);
let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at });
instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
// Ends of each gradient by object id; none for a gradient still fitted.
let ends = |params: serde_json::Value| {
let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]);
inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]);
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut out = std::collections::BTreeMap::new();
art.walk(&mut |c: &Node| {
if let Paint::Gradient(p) = c.appearance.fill_paint() {
out.insert(c.id.0, p.geom.map(|g| (g.start, g.end)));
}
});
out
};
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
// A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp
// from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1).
let turned = ends(serde_json::json!({"rotate": 90}));
let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)];
for (id, x0, x1) in want {
let got = turned.get(&id).copied().flatten();
let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1)));
assert!(ok, "object {id}: {got:?} in {turned:?}");
}
// The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70.
let moved = ends(serde_json::json!({"moveH": 30}));
let group = moved.get(&10).copied().flatten();
assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}");
assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}");
}
#[test]
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
+3 -2
View File
@@ -85,8 +85,9 @@ pub use reshape::{
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options,
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as,
validate_solid, validate_three_d, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
+16 -5
View File
@@ -762,7 +762,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
}
if separate {
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
let made = copy_leaf(leaf, &path, m, copies, scaling, room);
used = used.minus(before).plus(made);
return;
}
@@ -772,8 +773,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
return;
};
used = used.minus(before).plus(Budget::of(&out));
if follows && !boxes && leaf.appearance.has_positioned_gradient() {
crate::Carry::of(&e.id, &params, bounds, None, None, seed).apply(&mut leaf.appearance);
if follows && !boxes {
// A gradient fitted to the path fits the path's own bounds.
let carry = crate::Carry::of(&e.id, &params, bounds, path.bounds(), None, seed);
if carry.moves(&leaf.appearance) {
carry.apply(&mut leaf.appearance);
}
}
set_geometry(leaf, out, rule);
});
@@ -828,6 +833,8 @@ fn transform_leaf(
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
keep_fit_when_flat: true,
..Default::default()
};
if !options.objects {
@@ -1253,7 +1260,7 @@ fn is_member_group(n: &Node) -> bool {
}
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
/// gradient: the child indices that lead to it from the art, its geometric bounds, and
/// whether a path below it that its bounds measure kept its shape.
type GroupMark = (Vec<usize>, Option<Rect>, bool);
@@ -1270,7 +1277,7 @@ fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
if is_member_group(c) && c.appearance.has_positioned_gradient() {
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
at.pop();
@@ -1424,6 +1431,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
let src = (**c).clone();
let placed = |xf: Affine| {
let mut one = src.clone();
// Gradients fitted to it are fixed to their fit first when a refit would not match.
if crate::geometry::fixes_fit(xf) {
one.pin_gradients();
}
match &mut one.kind {
NodeKind::Image(im) => im.xf = xf * im.xf,
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
+8 -2
View File
@@ -66,19 +66,25 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
validate_revolve_params(p)?;
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
return Err("Revolve requires at least two anchors".into());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
Ok(())
}
fn profile(n: &Node) -> Option<PathData> {
+32 -8
View File
@@ -1,5 +1,5 @@
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
use serde_json::Value;
@@ -14,11 +14,14 @@ use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
mod classic;
pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
pub fn is_three_d(id: &str) -> bool {
@@ -119,6 +122,7 @@ pub fn extrude_options(p: &Value) -> Extrude {
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
hidden: false,
}
}
@@ -172,21 +176,32 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not
/// need the object.
fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
if id == CLASSIC_EXTRUDE {
classic::validate(p)?;
}
crate::lighting::validate(p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
Ok(())
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
@@ -273,6 +288,13 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
@@ -384,7 +406,8 @@ fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let colored = view_options(&p).has_colored_lights();
let classic = fx.id == CLASSIC_EXTRUDE;
let colored = !classic && view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -432,7 +455,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
.map(|(item, member, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item, member);
(depths, surface(n, f, &paint, opacity, blend, colored))
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
(depths, art)
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
+220
View File
@@ -0,0 +1,220 @@
//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic
//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading
//! color.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer};
use vectorcraft_geom::Rect;
use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light};
use crate::util::{flag, num, text};
pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude";
/// The width of a Wireframe edge, in points.
const WIRE: f64 = 0.5;
/// The Shading Color choices: black, a custom color, or none.
pub const SHADING_COLORS: [&str; 3] = ["black", "custom", "none"];
/// The surface settings of Extrude & Bevel (Classic)'s parameters.
pub fn classic_shading(p: &Value) -> ClassicShading {
let d = ClassicShading::default();
ClassicShading {
surface: ClassicSurface::parse(text(p, "surface", "plastic")).unwrap_or_default(),
ambient: num(p, "ambient", d.ambient * 100.0) / 100.0,
highlight_intensity: num(p, "highlightIntensity", d.highlight_intensity * 100.0) / 100.0,
highlight_size: num(p, "highlightSize", d.highlight_size * 100.0) / 100.0,
blend_steps: num(p, "blendSteps", f64::from(d.blend_steps)).round().clamp(1.0, 256.0) as u32,
}
}
/// The solid of Extrude & Bevel (Classic)'s parameters: its depth, cap and bevel (as tall as it is
/// wide, added around the shape or carved into it), with the faces turned away from the viewer kept
/// for Draw Hidden Faces and for the Wireframe surface.
pub fn classic_extrude_options(p: &Value) -> Extrude {
let height = num(p, "bevelHeight", 4.0);
Extrude {
depth: num(p, "depth", 50.0),
cap: flag(p, "cap", true),
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
shape,
width: height,
height,
inside: flag(p, "bevelInside", false),
..Bevel::default()
}),
hidden: flag(p, "drawHiddenFaces", false) || classic_shading(p).surface == ClassicSurface::Wireframe,
..Extrude::default()
}
}
/// The color faces darken toward: black, the custom color, or none (faces keep their paint and only
/// the highlight lightens them).
fn shading_color(p: &Value) -> Option<Color> {
match text(p, "shadingColor", "black") {
"none" => None,
"custom" => Some(p.get("shadingCustom").and_then(Value::as_str).and_then(parse_hex).unwrap_or(Color::BLACK)),
_ => Some(Color::BLACK),
}
}
fn parse_hex(s: &str) -> Option<Color> {
(s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())).then(|| Color::from_hex(s)).flatten()
}
/// The lights: the `lights` list when it is given, else the one light of `lightRotation`,
/// `lightHeight`, `lightBehind` and `lightIntensity`. Each is its direction toward the light in view
/// space and its intensity (0..1).
fn lights(p: &Value) -> Vec<([f64; 3], f64)> {
if let Some(list) = crate::lighting::lights(p) {
return list.iter().map(|l| (l.direction(), l.intensity)).collect();
}
let light = Light {
rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
behind: flag(p, "lightBehind", false),
..Light::default()
};
vec![(light.direction(), num(p, "lightIntensity", 100.0) / 100.0)]
}
/// The numeric parameters: (key, lowest, highest, whole numbers only).
const RANGES: [(&str, f64, f64, bool); 13] = [
("depth", 0.0, 2000.0, false),
("bevelHeight", 1.0, 100.0, false),
("rotationX", -180.0, 180.0, false),
("rotationY", -180.0, 180.0, false),
("rotationZ", -180.0, 180.0, false),
("perspectiveAngle", 0.0, 160.0, false),
("lightRotation", -180.0, 180.0, false),
("lightHeight", 0.0, 90.0, false),
("lightIntensity", 0.0, 100.0, false),
("ambient", 0.0, 100.0, false),
("highlightIntensity", 0.0, 100.0, false),
("highlightSize", 0.0, 100.0, false),
("blendSteps", 1.0, 256.0, true),
];
const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"];
/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of
/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
/// `shadingCustom` name a surface, a shading color and a #rrggbb color.
pub fn validate(p: &Value) -> Result<(), String> {
let Some(map) = p.as_object() else { return Ok(()) };
let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null);
let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default();
for key in map.keys() {
if !known.contains(&key.as_str()) {
let keys = known.join(", ");
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
}
}
for (key, min, max, whole) in RANGES {
let Some(v) = map.get(key) else { continue };
let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) };
if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) {
return Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})"));
}
if !(min..=max).contains(&x) {
return Err(format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
for key in FLAGS {
if map.get(key).is_some_and(|v| !v.is_boolean()) {
return Err(format!("`{key}` must be true or false"));
}
}
let one_of = |key: &str, ids: &[&str]| -> Result<(), String> {
match map.get(key) {
Some(v) if !v.as_str().is_some_and(|s| ids.contains(&s)) => Err(format!("`{key}` must be one of {}", ids.join(", "))),
_ => Ok(()),
}
};
let surfaces: Vec<&str> = ClassicSurface::ALL.iter().map(|s| s.id()).collect();
one_of("surface", &surfaces)?;
one_of("shadingColor", &SHADING_COLORS)?;
let key = "shadingCustom";
if map.get(key).is_some_and(|v| !v.as_str().is_some_and(|s| parse_hex(s).is_some())) {
return Err(format!("`{key}` must be a #rrggbb color"));
}
Ok(())
}
/// `c` with tone `tone` (0 the shading color, 1 the color itself) and highlight `spec` (toward
/// white) on top.
fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color {
let base = c.to_rgb();
let dark = shade.map_or(base, |s| s.to_rgb());
let (tone, spec) = (tone as f32, spec as f32);
let [r, g, b] = [0, 1, 2].map(|i| {
let mixed = dark[i] + (base[i] - dark[i]) * tone;
(mixed + (1.0 - mixed) * spec).clamp(0.0, 1.0)
});
Color::rgb(r, g, b)
}
/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
/// its own transparency, so the shading follows the gradient's coverage.
pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
let wire = shading.surface == ClassicSurface::Wireframe;
let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) };
let paint = &paint;
let children = facets
.into_iter()
.map(|face| {
if wire {
let mut edge = StrokeLayer::new(paint.clone(), WIRE);
edge.join = vectorcraft_doc::LineJoin::Round;
return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() }));
}
let (tone, spec) = shading.tone(face.normal, &lights);
// A narrow stroke of the face's own paint closes the hairline seams between faces.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
let items = match paint.color() {
// Unshaded (No Shading, no shading color, or a face in full light) with no
// highlight: the paint itself, its color space and swatch kept.
Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => {
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]
}
Some(c) => {
let fill = Paint::solid(shaded(c, tone, spec, shade));
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))]
}
// Gradients and patterns keep their paint, darkened by a layer of the shading color
// and lightened by one of white. As with the other 3D effects' pattern shadow, the
// layers cover the whole face, transparent pattern cells included.
None => {
let mut items =
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))];
for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] {
if let Some(color) = color.filter(|_| share > 0.0) {
let mut layer = FillLayer::new(Paint::solid(color));
layer.opacity = share as f32;
items.push(AppearanceItem::Fill(layer));
}
}
items
}
};
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
})
.collect();
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
if let Some(mask) = mask {
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
}
group
}
+164 -2
View File
@@ -45,10 +45,15 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
// hue.
// hue. The classic Plastic surface, its default, also lightens a face toward white, which
// scales every channel's distance from white by the same factor and so keeps r - g to
// r - b at 6 to 7.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4;
let highlighted =
id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4;
assert!(shaded || highlighted || id == ROTATE, "{id}: {r} {g} {b}");
}
// The art follows the source when it moves.
let before = art.visual_bounds().unwrap();
@@ -595,3 +600,160 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
assert!(err.contains("clipping groups"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`.
fn classic(params: Value) -> Node {
let mut n = square_node();
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params));
n
}
fn classic_faces(params: Value) -> Vec<Node> {
let art = solid_art(&classic(params)).unwrap();
faces(&art).into_iter().cloned().collect()
}
fn close3(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4)
}
#[test]
fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() {
// Wireframe: the edges of every face, the hidden ones too, in the paint; no fills.
let wire = classic_faces(json!({"surface": "wireframe"}));
let plastic = classic_faces(json!({}));
assert_eq!(plastic.len(), 3, "a box shows three faces");
assert_eq!(wire.len(), 6, "and has six");
for f in &wire {
let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) };
assert_eq!(s.paint, Paint::solid(RED));
}
// No Shading: every face in the art's own paint, a CMYK color kept as it is.
assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb())));
let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 });
let mut n = classic(json!({"surface": "none"}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = cmyk.clone();
}
for f in faces(&solid_art(&n).unwrap()) {
let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") };
assert_eq!(fill.paint, cmyk);
}
// Diffuse: the red darkened toward black by how far each face turns from the light, so the
// faces differ but keep their hue.
let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect();
assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}");
assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}");
// Plastic: the same faces, one of them lightened toward white by the highlight.
let shiny: Vec<[f32; 3]> =
classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect();
let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}");
assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4));
// Without highlight, Plastic shades as Diffuse.
let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b)));
}
#[test]
fn classic_shading_color_blend_steps_and_lights() {
let base = json!({"surface": "diffuse", "ambient": 20});
let with = |extra: Value| {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
classic_faces(p).iter().map(fill_rgb).collect::<Vec<_>>()
};
// A custom shading color darkens toward that color; None keeps the paint.
assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01));
assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb())));
// One blend step: every face fully lit or fully in shadow.
let stepped = with(json!({"blendSteps": 1, "ambient": 0}));
assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}");
// No light: the ambient share of the red. Two lights add up.
let dark = with(json!({"lightIntensity": 0, "blendSteps": 256}));
assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}");
let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256}));
let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256}));
assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01));
// Draw Hidden Faces keeps the faces turned away, drawn first.
assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6);
}
#[test]
fn classic_bevel_height_extent_and_cap_shape_the_solid() {
let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap();
let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10});
let out = bounds(front.clone());
let mut inside = front.clone();
inside["bevelInside"] = json!(true);
// Straight on, a bevel added around the shape widens it; one carved into it keeps its size.
assert!(out.width() > 100.0 + 15.0, "{out:?}");
let carved = bounds(inside);
assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}");
// Hollow: no caps, so the walls seen from inside show too.
assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len());
// The depth sets how far back the solid goes.
let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0});
let mut deep = side.clone();
deep["depth"] = json!(120);
assert!((bounds(deep).width() - 120.0).abs() < 1.0);
}
/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so
/// the shading does not cover the transparent part; an opaque gradient needs no mask.
#[test]
fn classic_shading_keeps_a_gradients_transparency() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
for (end, masked) in [(0.25, true), (1.0, false)] {
let mut stop = GradientStop::new(1.0, RED);
stop.opacity = end;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop]));
let mut n = classic(json!({}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = Paint::Gradient(Box::new(gradient));
}
let art = solid_art(&n).unwrap();
let surface = &art.children().unwrap()[0];
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
let mut opaque = true;
surface.walk(&mut |c| {
for item in &c.appearance.items {
if let AppearanceItem::Fill(f) = item
&& let Paint::Gradient(g) = &f.paint
{
opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0);
}
}
});
assert!(opaque, "{end}: the faces are drawn opaque");
}
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));
let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p));
assert!(check(json!({})).is_ok());
assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok());
for (p, says) in [
(json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"),
(json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
(json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"),
(json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"),
(json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"),
(json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"highlightSize": "big"}), "`highlightSize` must be a number"),
(json!({"cap": "yes"}), "`cap` must be true or false"),
(json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"),
(json!({"bevel": "fancy"}), "bevel must be none or one of"),
(json!({"twist": 10}), "unknown parameter `twist`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
] {
let err = check(p.clone()).unwrap_err();
assert!(err.starts_with(says), "{p}: {err}");
}
}
+24 -6
View File
@@ -71,24 +71,39 @@ pub fn specs() -> Vec<CommandSpec> {
/// other open documents (or an earlier active document) defined. Run after each command and when
/// another document takes the front, so an undo, a revert, an opened or closed file or a document
/// switch draws type in the active document's own composite fonts, and type naming one it does
/// not define shows as missing. Whether a composite font was added, changed or removed.
pub(crate) fn sync(s: &mut Session) -> bool {
/// not define shows as missing. The lowercased names of the composite fonts added, changed or
/// removed.
pub(crate) fn sync(s: &mut Session) -> Vec<String> {
let db = FontDb::global();
let mut names = Vec::new();
let mut changed = false;
let mut changed = Vec::new();
for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() {
changed |= db.set_composite_font(&f);
names.push(f.name.to_lowercase());
let name = f.name.to_lowercase();
if db.set_composite_font(&f) {
changed.push(name.clone());
}
names.push(name);
}
let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
let stale: Vec<String> = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect();
for gone in stale {
changed |= db.remove_composite_font(&gone);
if db.remove_composite_font(&gone) {
changed.push(gone);
}
}
s.composite_names_registered.extend(names.iter().chain(&changed).cloned());
s.composite_names = names;
changed
}
/// The lowercased names of the composite fonts the open documents define, the font database holds
/// and the session has registered before: the names type may have been laid out in.
pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet<String> {
let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase());
defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect()
}
/// Leave out a file's unusable composite fonts before registering and measuring its type.
pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) {
let db = FontDb::global();
@@ -172,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec<CompositeFont>) -> Result<()
}
let changed: Vec<String> =
before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect();
// A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name
// listed here that the document doesn't define.
s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase()));
let db = FontDb::global();
for f in &before {
if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) {
@@ -395,6 +395,150 @@ fn opened_document_measures_missing_composite_font_as_missing() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// The native file of a document whose composite font `name` sets Roman at full size, with type
/// "Ab12" set in it → (the file, the type's id, its width).
fn full_roman_file(name: &str) -> (Vec<u8>, u64, f64) {
let mut s = session();
let mut full = font(name);
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap();
let width = text(&s, id).cached_bounds.unwrap().width();
(vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width)
}
/// Type in a missing font that no composite font is named after keeps the bounds it was measured
/// with when its file was read: opening the document does not lay it out again.
#[test]
fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0);
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
t.cached_bounds = Some(kept);
}
s.add_document(doc, None);
assert_eq!(text(&s, id).cached_bounds, Some(kept));
}
/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with
/// the graphic at the size the opened document's own definition gives it.
#[test]
fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() {
let mut s = session();
let mut full = font("CF Inline");
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap();
s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap();
let width = text(&s, id).cached_bounds.unwrap().width();
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6);
}
/// Type read for opening while a composite font of its name was registered is measured again when
/// it opens, even after the document that defined the font has closed in between.
#[test]
fn opening_measures_type_read_in_a_composite_font_closed_since() {
let mut s = session();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap();
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
assert!(s.close_document(1));
crate::cmd::fileio::open_read(&mut s, file).unwrap();
let actual = text(&s, id);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A file read for opening lays out its type in the composite fonts registered then (the front
/// document's, which defines the name with Roman at half size); once the file opens, its type is
/// measured again in its own.
#[test]
fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
let (bytes, id, width) = full_roman_file("CF Opened");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
let actual = text(&s, id);
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
/// when it comes to the front, not in the front document's.
#[test]
fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() {
let (bytes, id, width) = full_roman_file("CF Replaced");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert!(s.set_active(0));
let actual = text(&s, id);
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// So is one that comes to the front because the front document closes.
#[test]
fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
let (bytes, id, width) = full_roman_file("CF Closed");
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert!(s.close_document(1));
let actual = text(&s, id);
assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A failed batch takes back the composite fonts it set, in the font database too.
#[test]
fn a_failed_batch_takes_back_its_composite_fonts() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
let steps = json!([
{"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}},
{"command": "no.such.command"},
]);
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
assert!(names(&s).is_empty());
assert!(db.composite_font("CF Failed Batch").is_none());
}
/// A composite font whose edit is refused, because it makes selected type too wide for the canvas,
/// is not left in the font database.
#[test]
fn a_refused_edit_takes_back_its_new_composite_font() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
let x = crate::MAX_COORD - 1000.0;
s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap();
let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}});
let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string();
assert!(e.contains("exceed the canvas"), "{e}");
assert!(names(&s).is_empty());
assert!(db.composite_font("CF Refused Edit").is_none());
}
/// A loaded composite font cannot replace an installed family used by existing type.
#[test]
fn loaded_composite_font_cannot_take_an_installed_family_name() {
+17 -4
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}",
has_doc,
lighting_preset
),
@@ -505,6 +505,13 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
// Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options.
if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) {
return Err(bad(
C,
format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0),
));
}
}
let mut options = p.clone();
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
@@ -516,7 +523,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one
/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still
/// fit. → `{ids}`.
/// fit; a hidden one has its given values checked. → `{ids}`.
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -574,7 +581,6 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(e) = effect
&& effects::is_three_d(&e.id)
&& visible != Some(false)
{
let to = kind.unwrap_or(&e.id);
let mut prospective =
@@ -582,7 +588,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, &params)) {
cur.extend(new);
}
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
if visible != Some(false) {
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
} else {
// Hidden, the effect draws nothing, so its object need not fit. The values given are
// checked, and after a change of 3D type so is every value it keeps.
let checked = if to == e.id { effects::upgrade_params(to, &params) } else { prospective };
effects::validate_three_d_params(to, &checked).map_err(|e| bad(C, e))?;
}
}
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| {
@@ -91,3 +91,27 @@ fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}
#[test]
fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection_unchanged() {
let (mut s, first) = setup("threeD.rotate", Value::Null);
let classic = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect":"threeD.classicExtrude","item":null,"params":{"surface":"diffuse","lightRotation":40}})).unwrap();
for ids in [vec![classic], vec![first.0, classic]] {
for preset in LIGHTING_PRESETS {
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
let p = json!({"ids":ids,"index":0,"item":null,"preset":preset.id});
let err = s.execute("effect.lightingPreset", &p).unwrap_err().to_string();
assert!(err.contains(&format!("object {classic}: Extrude & Bevel (Classic) at index 0 has no lighting presets")), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}
}
// Its lights are set with its own parameters instead.
s.execute("effect.setParams", &json!({"ids":[classic],"index":0,"item":null,"params":{"lightRotation":135,"lightHeight":45}})).unwrap();
let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params;
assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0)));
}
+50
View File
@@ -46,6 +46,56 @@ fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
assert_eq!(effects_for_test(&s, id), before);
}
/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it:
/// a bad value is refused and the document is left as it was, and good values are stored.
#[test]
fn hiding_a_3d_effect_still_checks_its_new_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let rect = |s: &mut Session, x: u32| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap())
};
let classic = rect(&mut s, 20);
s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap();
let extrude = rect(&mut s, 120);
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap();
let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap();
for (id, params, why) in [
(classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"),
(classic, json!({"surface": "glossy"}), "`surface` must be one of"),
(classic, json!({"bevel": "wavy"}), "bevel must be none or one of"),
(extrude, json!({"lights": []}), "lights must contain one to ten lights"),
(extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"),
(profile, json!({"edge": "top"}), "Revolve edge must be left or right"),
] {
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false});
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
assert!(e.contains(why), "{params}: {e}");
assert_eq!(s.doc().unwrap().doc, before, "{params}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}");
}
// A hidden switch of 3D type checks the values given with it for the new type.
let before = s.doc().unwrap().doc.clone();
let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false});
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
assert!(e.contains("lights must contain one to ten lights"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
// Hiding alone checks nothing.
s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap();
assert!(!node(&s, extrude).appearance.effects[0].visible);
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap();
let e = &node(&s, classic).appearance.effects[0];
assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false));
// A value the effect already holds is not checked again: a key it does not take, as a file
// can carry, does not stop a hidden edit of another value.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1);
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap();
assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0));
}
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
/// exported, expanded and undone; one 3D effect per object.
#[test]
+7 -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,7 @@ mod encode;
pub mod eps;
mod export;
pub(crate) mod imagemap;
mod importer;
mod legacy_ai;
mod load;
mod mesh3d;
@@ -135,9 +138,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)]
+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)]
+2 -2
View File
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.<id>\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File Formats",
[],
None,
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.<plug-in id>; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.<plug-in id>; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
always,
formats
),
+136 -23
View File
@@ -659,11 +659,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 +677,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 +789,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 +809,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 +854,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 +865,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 +958,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)
}
+15 -3
View File
@@ -567,10 +567,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 })
@@ -681,7 +686,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);
+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);
}
+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
+35 -15
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);
}
@@ -161,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)
}
@@ -449,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) {
@@ -463,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)
@@ -538,8 +550,8 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool {
}
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id
/// unless `grant` lists them all, one that would take another module's place ([`takes_over`])
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very
/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`])
/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with
/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
@@ -584,8 +596,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
));
}
// A plug-in that lists permissions installs once the user grants them: whoever installs it
// asks them first, unless they already granted all of them (with a plug-in store, to this
// module).
// asks them first, unless they already granted this very module all of them.
let m = plugin.manifest();
let wanted = m.permissions();
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
@@ -593,13 +604,22 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
let asked = permissions::names(&wanted).join(", ");
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
}
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
// grant only once it is there; when the grant can't be written, the store gets the module it
// held back. A failed write of that module is only logged, and the grant then doesn't cover
// the new module. A stored module that can't be read back isn't replaced by one that needs a
// grant.
let previous = s.stored_plugin_bytes(plugin.id());
let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes);
if m.asks_permissions() {
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
registry::grant(g.clone());
s.store_grant(&m.id, Some(g));
if stored && !s.store_grant(&m.id, Some(g)) {
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
stored = false;
}
}
// Installed plug-ins stay installed after a restart (#2541), with their settings and cache.
let stored = s.store_plugin(plugin.id(), &bytes);
// It gets the settings and cache kept for its id.
s.attach_plugin_storage(plugin.id());
// Installing a disabled plug-in again (an updated version) turns it on for the next launch too,
// also when it is disabled by its stored file alone (it stopped a launch while loading). When
@@ -854,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), 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)}",
"{} → {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
),
@@ -881,7 +901,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
@@ -890,7 +910,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
always,
inspect
),
@@ -917,7 +937,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reload Plug-ins",
["Object", "Plug-ins"],
None,
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}",
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}",
always,
reload
),
+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;
+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();
}
}
}
+7
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)>,
@@ -175,6 +179,9 @@ pub struct Session {
/// The composite fonts (lowercase names) that the open documents defined when the font
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
pub(crate) composite_names: Vec<String>,
/// Every composite font (lowercase name) this session has registered in the font database:
/// type read for opening may have been laid out in one that is gone by the time it opens.
pub(crate) composite_names_registered: std::collections::HashSet<String>,
/// While a command that changes nothing in the document runs (a query, or one of
/// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol
/// instance's art.
+109 -34
View File
@@ -1,3 +1,7 @@
use std::collections::HashSet;
use vectorcraft_doc::{Node, TextObject};
use super::{
Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result,
Session, ViewInfo, cmd, command_specs, file_access,
@@ -42,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,
@@ -84,6 +89,7 @@ impl Session {
batch_steps: None,
batch_paint_docs: Vec::new(),
composite_names: Vec::new(),
composite_names_registered: HashSet::new(),
reading: false,
place_hand_off: None,
queue_hand_off: None,
@@ -182,10 +188,8 @@ impl Session {
self.reset_tool_for_doc_switch();
}
self.active = Some(index);
// Type is drawn in the composite fonts of the document now in front, and measured again
// when they differ from those of the document before.
if switched && cmd::compositefonts::sync(self) {
self.refresh_document_text_bounds(index);
if switched {
self.sync_front_composite_fonts();
}
true
} else {
@@ -193,6 +197,16 @@ impl Session {
}
}
/// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]),
/// after a switch or a close brought it there. Its type set in a composite font that differs
/// from the one of that name before (added, changed or removed) is measured again, and type not
/// measured yet (in a document replaced while in the back) is measured now.
fn sync_front_composite_fonts(&mut self) {
let changed: HashSet<String> = cmd::compositefonts::sync(self).into_iter().collect();
let Some(index) = self.active else { return };
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed));
}
/// The name [`Session::next_untitled`] gives next (New Document's Name field).
pub fn peek_untitled(&self) -> String {
format!("Untitled-{}", self.untitled_counter + 1)
@@ -211,19 +225,44 @@ impl Session {
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
refresh_tree_text_bounds(tree);
let mut uncached = |t: &TextObject| t.cached_bounds.is_none();
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
}
/// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an
/// edit: a saved document stays saved, and its undo history keeps its documents as they are.
fn refresh_document_text_bounds(&mut self, index: usize) {
if let Some(st) = self.docs.get_mut(index) {
st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc)));
/// Measure the type in open document `index` that `stale` picks (in its trees as
/// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved
/// document stays saved, and its undo history keeps its documents as they are. The document is
/// left as it is when `stale` picks nothing.
fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) {
let Some(st) = self.docs.get_mut(index) else { return };
if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) {
return;
}
st.change_unsaved(|doc| {
let doc = Arc::make_mut(doc);
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
// Inline graphics take their size from their symbols' art, whose type may just have
// been measured again: the text holding one whose size changed is measured again too.
if !doc.resolve_inline_art().is_empty() {
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
}
});
}
/// Register the composite fonts of open document `index`, which is in front, and measure its
/// type not measured yet and its type set in a font of a name that a composite font of an open
/// document or of the font database has, or had before: that type may have been laid out in
/// another document's composite fonts (those registered while its file was read for opening).
fn sync_opened_composite_fonts(&mut self, index: usize) {
let mut names = cmd::compositefonts::names_in_use(self);
names.extend(cmd::compositefonts::sync(self));
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names));
}
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
@@ -234,6 +273,8 @@ impl Session {
// (importers and the native reader have seen to theirs already, with a warning).
doc.limit_depth();
cmd::compositefonts::filter_loaded(&mut doc);
// Inline graphics in text take their size from their symbols' art (not saved).
doc.resolve_inline_art();
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
cmd::dimension::conform(&mut doc);
@@ -244,8 +285,7 @@ impl Session {
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
cmd::compositefonts::sync(self);
self.refresh_document_text_bounds(i);
self.sync_opened_composite_fonts(i);
if self.depth == 0
&& let Some(label) = self.explicit_undo_group.clone()
{
@@ -262,11 +302,26 @@ impl Session {
}
doc.limit_depth();
cmd::compositefonts::filter_loaded(&mut doc);
doc.resolve_inline_art();
doc.adopt_guides();
cmd::dimension::conform(&mut doc);
// In the back, type set in a font named like a composite font is measured when the document
// comes to the front and its own composite fonts are registered, not in the front
// document's.
let mut back_names = None;
if self.active == Some(index) {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
} else {
let mut names = cmd::compositefonts::names_in_use(self);
names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
for tree in text_trees_mut(&mut doc) {
each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| {
t.cached_bounds = None;
t.cached_baselines.clear();
});
}
back_names = Some(names);
}
let old = &self.docs[index];
let mut st = DocState::new(doc, old.path.clone());
@@ -297,12 +352,10 @@ impl Session {
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
// Type in the composite fonts of a document in the back is measured again when it comes to
// the front.
if self.active == Some(index) {
cmd::compositefonts::sync(self);
match back_names {
Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)),
None => self.sync_opened_composite_fonts(index),
}
self.refresh_document_text_bounds(index);
true
}
@@ -319,7 +372,7 @@ impl Session {
stash.push(old);
}
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
cmd::compositefonts::sync(self);
self.sync_front_composite_fonts();
true
}
@@ -351,27 +404,49 @@ impl Session {
}
}
/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the
/// nodes on the way to such type are copied.
fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
fn stale(n: &vectorcraft_doc::Node) -> bool {
matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none())
|| n.extras().mask.as_deref().is_some_and(|m| stale(&m.art))
|| n.children().is_some_and(|ch| ch.iter().any(|c| stale(c)))
}
if !stale(n) {
/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased.
fn sets_in_any(t: &TextObject, names: &HashSet<String>) -> bool {
!names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase()))
}
/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern
/// tiles.
fn text_trees(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art));
let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art));
trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter()))
}
/// [`text_trees`], to change.
fn text_trees_mut(doc: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
}
/// Whether tree `n` (opacity mask art included) holds type that `pick` picks.
fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool {
matches!(&n.kind, NodeKind::Text(t) if pick(t))
|| n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick))
|| n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick)))
}
/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes
/// on the way to such type are copied.
fn each_text_where(n: &mut Arc<Node>, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) {
if !has_text_where(n, pick) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Text(t) = &mut n.kind
&& t.cached_bounds.is_none()
&& pick(t)
{
cmd::typecmd::refresh_bounds_for_first_frame(t);
act(t);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
refresh_tree_text_bounds(&mut m.art);
each_text_where(&mut m.art, pick, act);
}
for c in n.children_mut().into_iter().flatten() {
refresh_tree_text_bounds(c);
each_text_where(c, pick, act);
}
}
+9
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 }))?;
@@ -135,6 +138,9 @@ impl Session {
let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) };
if r.is_err() && self.depth == 0 {
self.batch_paint_docs.clear();
// A failed or panicked command's documents are put back: type is drawn in their
// composite fonts again, not in those the command set.
cmd::compositefonts::sync(self);
}
let r = r?;
if self.depth == 0 {
@@ -147,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)
+44 -18
View File
@@ -289,8 +289,8 @@ impl Session {
/// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user
/// disabled (matched by file name, which is made from the id). The launch marker names each
/// file while it loads. A module that doesn't load, and one that lists permissions the grants
/// there don't grant it, is skipped with a log record. A grant covers only the module it was
/// given to ([`grant_covers`]).
/// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and
/// stays in the folder with its grant.
pub fn load_stored_plugins(&mut self) {
let Some(dir) = self.plugin_dir.clone() else { return };
if safe_mode::active() {
@@ -376,9 +376,9 @@ impl Session {
written && merged.complete()
}
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
/// granted every one it lists, in the grants of the attached plug-in store when there is one
/// (for this module alone, [`grant_covers`]), else in this process's
/// Whether `plugin` installs without asking the user: it lists no permissions, or this very
/// module (its id and SHA-256) was granted every one it lists, in the grants of the attached
/// plug-in store when there is one ([`grant_covers`]), else in this process's
/// ([`vectorcraft_plugins::registry::approved`]).
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
self.plugin_approver()(plugin)
@@ -403,6 +403,29 @@ impl Session {
self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes))
}
/// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and
/// an error, with a log record, when the file can't be read or is larger than the module limit.
pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result<Option<Vec<u8>>, String> {
let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) };
let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id)));
if !path.exists() {
return Ok(None);
}
let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64;
read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display()))
}
/// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a
/// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed.
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) {
match previous {
Some(bytes) => {
self.store_plugin(id, bytes);
}
None => self.unstore_plugin(id),
}
}
/// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it
/// (`None` without a plug-in folder).
pub(crate) fn stored_plugin_file(&self, id: &str) -> Option<String> {
@@ -413,14 +436,15 @@ impl Session {
/// other grants as they are; with none left, remove the file. Entries this version can't use
/// are written back as they were, except one for `id`. A grants file that is too large, can't
/// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded;
/// so is one whose new content would be larger than `MAX_GRANTS_FILE`.
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) {
let Some(dir) = &self.plugin_dir else { return };
/// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now
/// holds what was asked (`false` without a folder).
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool {
let Some(dir) = &self.plugin_dir else { return false };
let mut file = match read_grants(dir) {
Ok(file) => file,
Err(e) => {
log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
return;
return false;
}
};
let others = file.other.len();
@@ -430,22 +454,23 @@ impl Session {
None => file.grants.remove(id).is_some(),
} || file.other.len() != others;
if !changed {
return;
return true;
}
let mut entries: Vec<serde_json::Value> = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect();
entries.extend(file.other);
if entries.is_empty() {
take(dir, GRANTS_FILE);
return;
return true;
}
// The file is read back only up to its cap: written compactly when pretty-printed is larger,
// and not at all when that is too.
let fits = |bytes: &Vec<u8>| bytes.len() as u64 <= MAX_GRANTS_FILE;
match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) {
Some(bytes) => {
put(dir, GRANTS_FILE, &bytes);
Some(bytes) => put(dir, GRANTS_FILE, &bytes),
None => {
log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE));
false
}
None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)),
}
}
@@ -577,12 +602,13 @@ fn io(e: &std::io::Error) -> StorageError {
StorageError::Io(e.to_string())
}
/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every
/// Does grant `g` cover `plugin`: was it given to this module (the same id and SHA-256) for every
/// permission the module lists? A module with other bytes under the id needs the user's approval
/// again, and so doesn't get the settings and cache kept for the id without it, and so does every
/// module when the grant records no hash.
/// again, and so doesn't get the settings and cache kept for the id without it. A grant whose hash
/// is missing or not a valid SHA-256 covers no module.
fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool {
!g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions())
let m = plugin.manifest();
g.covers(&m.id, plugin.hash(), &m.permissions())
}
/// Is `path` a link (a symbolic link or, on Windows, a junction)?
+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();
+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();
+27
View File
@@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_
}
}
/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand
/// of the fill makes strips that draw the same.
#[test]
fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
// The gray level at (x, y).
let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32;
// The object's own effect, and the fill's (the first appearance item).
for (params, item) in
[(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))]
{
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false);
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item}));
let before = render(&s);
if params["rotate"] == 90 {
// The white to black ramp runs up or down the turned square, not across it.
let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130));
let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100));
assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}");
}
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
let diff = mean_diff(&before, &after);
assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}");
}
}
#[test]
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
+229 -7
View File
@@ -1199,9 +1199,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one
/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in
/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A
/// plug-in without `permissions` installs as before, with the ones its kind implies.
#[test]
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
let mut s = session();
@@ -1237,11 +1237,11 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(installed["permissions"], json!(["doc.read"]));
let g = vectorcraft_plugins::registry::granted(id).unwrap();
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
// Replaced with another module that lists the same permissions: no new grant needed, and the
// grant now records the new module's hash.
// Replaced with another module that lists the same permissions: granted again, the grant now
// records the new module's hash.
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true}));
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
// One that asks for more is refused until it is granted all of them.
let more = data(r#"["doc.read","doc.write"]"#);
@@ -1261,6 +1261,221 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
}
/// A module with other bytes under an id that was granted is not covered, even when it lists the
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
/// install again without asking, and a module that lists no permissions never asks.
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
use vectorcraft_plugins::permissions::sha256_hex;
let mut s = session();
let id = "test.engine.permissions.bytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
// The same bytes need no new grant.
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
// A new grant installs the changed module, and the old bytes are no longer covered.
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true}));
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
run(&mut s, "plugin.remove", json!({"id": id}));
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
let exempt = "test.engine.permissions.exempt";
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true}));
}
run(&mut s, "plugin.remove", json!({"id": exempt}));
}
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
/// With a store attached, a matching grant held only by the process doesn't count.
#[test]
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-recorded-module");
let id = "test.engine.permissions.storedbytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::fs::read_dir(&folder.0)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
.expect("the stored module");
let forget = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
};
let starts = || {
forget();
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).is_some()
};
assert!(starts(), "the module that was approved loads");
std::fs::write(&module, &changed).unwrap();
assert!(!starts(), "other bytes under the old grant are skipped");
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
std::fs::write(&module, &first).unwrap();
assert!(starts(), "the approved bytes load again");
// A malformed hash is no wildcard, even for the module on disk.
let upper = sha256_hex(&first).to_uppercase();
for hash in ["", "00", upper.as_str()] {
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
assert!(!starts(), "hash {hash:?}");
}
// The store's grants are the ones that count, even when the process holds one for these bytes.
std::fs::write(&grants, &recorded).unwrap();
assert!(starts());
let mut attached = session();
attached.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
run(&mut attached, "plugin.remove", json!({"id": id}));
}
/// A write to the plug-in store that fails leaves the store's module beside the grant that approved
/// it, so the old module loads at the next start: a module that can't be written doesn't update the
/// grant, and a grant that can't be written puts the old module back. On a first install, it takes
/// the new module out again.
#[test]
fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
use vectorcraft_plugins::permissions::sha256_hex;
let folder = crate::tests_links::Folder::new("plugin-store-failed-write");
let id = "test.engine.permissions.failedwrite";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
let first_data = vectorcraft_format::base64_encode(&first);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap());
let starts = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string())
};
let block = |path: &std::path::Path| {
std::fs::remove_file(path).unwrap();
std::fs::create_dir_all(path.join("block")).unwrap();
};
// The module can't be written: the grant stays the old module's.
block(&module);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored");
std::fs::remove_dir_all(&module).unwrap();
std::fs::write(&module, &first).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write");
// The grants file is full: the new grant, which is longer, doesn't fit.
let cap = 1 << 20;
let mut entries: Vec<Value> = serde_json::from_slice(&recorded).unwrap();
entries.push(json!({"id": "", "pad": ""}));
let room = cap - serde_json::to_vec(&entries).unwrap().len();
*entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)});
let full = serde_json::to_vec(&entries).unwrap();
assert_eq!(full.len(), cap);
std::fs::write(&grants, &full).unwrap();
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write");
// The grants file can't be read, so nothing can be recorded in it.
block(&grants);
run(&mut s, "plugin.install", update.clone());
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable");
// A stored module that can't be read back isn't replaced, so a failed grant write can't lose it.
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap();
block(&grants);
run(&mut s, "plugin.install", update);
assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
std::fs::write(&module, &first).unwrap();
// A first install whose grant can't be written leaves no module without a grant in the store.
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(!module.exists() && !grants.exists());
std::fs::create_dir_all(grants.join("block")).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
assert!(!module.exists(), "the store holds no module its grants don't cover");
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
}
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
/// granted on its own, the changed module loads from the folder. A session without a store uses the
/// grants held by the process.
#[test]
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
let mut s = session();
let id = "test.engine.permissions.folderbytes";
let plain = "test.engine.permissions.folderplain";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
let dir = crate::tests_links::Folder::new("plugin-folder-changed-module");
let file = dir.0.join("asks.wasm");
std::fs::write(&file, &first).unwrap();
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
let path = json!({"path": dir.0.display().to_string()});
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
std::fs::write(&file, &changed).unwrap();
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
let failed = r["failed"].as_array().unwrap();
assert_eq!(failed.len(), 1, "{r}");
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
let why = failed[0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true}));
let r = run(&mut s, "plugin.reload", path);
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
run(&mut s, "plugin.remove", json!({"id": id}));
run(&mut s, "plugin.remove", json!({"id": plain}));
}
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
/// `state` it is given no data kept with the document and may not write any.
@@ -1435,18 +1650,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() {
std::fs::write(&stored[0], &other).unwrap();
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
vectorcraft_plugins::registry::set_storage(id, None);
let no_storage = |how: &str| {
assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache");
};
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant");
no_storage("the next start");
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
std::fs::write(dir.0.join("other.wasm"), &other).unwrap();
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([]));
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval"), "{why}");
no_storage("a folder load");
let data = vectorcraft_format::base64_encode(&other);
let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "{e}");
no_storage("a refused install");
// Granted, it reads the settings the first module set.
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
+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();
+28
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,
@@ -764,6 +790,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") {
+80 -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![
@@ -538,6 +561,24 @@ proptest! {
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}")))?;
@@ -592,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}"),
],
@@ -611,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);
@@ -650,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 => (
@@ -681,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);
}
+19
View File
@@ -198,6 +198,25 @@ fn installing_a_disabled_plug_in_again_turns_it_on() {
let _ = std::fs::remove_dir_all(&dir);
}
/// When the grant of an update can't be stored, the store keeps the old module, and it stays disabled.
#[test]
fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() {
let _f = fresh();
let dir = tmp_dir("reinstall-no-grant");
let mut s = launch(&dir);
let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#;
let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap());
s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap();
s.execute("plugin.setEnabled", &json!({"id": "test.sm.g", "enabled": false})).unwrap();
let grants = dir.join("grants.json");
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir_all(grants.join("block")).unwrap();
s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[],"error":null}"#), "grant": ["doc.read"], "replace": true})).unwrap();
assert_eq!(safe_mode::disabled().len(), 1, "the module the update could not replace stays disabled");
safe_mode::set_disabled(vec![]);
let _ = std::fs::remove_dir_all(&dir);
}
/// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by
/// its stored file alone (as Disable and Continue saves it), and the next launch skips it.
fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session {
+2 -1
View File
@@ -22,7 +22,8 @@ pub(super) enum Page {
/// How art moved from a page to its place in the layers' document transforms: its pattern tiles
/// move with it.
pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true };
pub(super) const PAGE_MOVE: Scaling =
Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true, keep_fit_when_flat: false };
/// The page (`visible`, the file read as its page) and the place of its area in the layers'
/// document.
+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,
+9 -2
View File
@@ -295,11 +295,18 @@ impl Headless {
Ok(out)
}
/// `app.open {path}`: any readable file as a new active document (`document.open`).
/// `app.open {path, importer?, params?}`: any readable file as a new active document
/// (`document.open`, with the importer plug-in `importer` names and its parameters `params`).
pub fn open(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?;
let mut params = json!({"path": path});
for k in ["importer", "params"] {
if let Some(v) = p.get(k).filter(|v| !v.is_null()) {
params[k] = v.clone();
}
}
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string())
file_access::confine(roots.as_ref(), || self.session.execute("document.open", &params)).map_err(|e| e.to_string())
}
/// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and
+43
View File
@@ -415,3 +415,46 @@ fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
assert!(bad.is_error, "{bad:?}");
}
/// `open_file`'s `importer` reaches `document.open`: an SVG opens with the SVG reader without it,
/// and naming an importer that isn't installed is an error that names it.
#[test]
fn open_file_passes_the_importer_on() {
let tools = tool_definitions();
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["inputSchema"]["properties"].get("importer").is_some(), "{open}");
let svg = tmp("importer.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="5" height="5"/></svg>"#).unwrap();
let mut h = Headless::new();
assert_eq!(text(&call_tool(&mut h, "open_file", &json!({"path": svg})))["format"], "svg");
let r = call_tool(&mut h, "open_file", &json!({"path": svg, "importer": "plugin.test.mcp.absent"}));
assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}");
assert_eq!(h.session.documents().len(), 1);
}
/// `open_file` sends `app.open` the importer's `params` with the importer.
#[test]
fn open_file_passes_the_importer_s_params_on() {
struct Recording(Vec<(String, Value)>);
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.0.push((method.to_string(), params));
Ok(json!({}))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"recording".into()
}
}
let tools = tool_definitions();
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert_eq!(open["inputSchema"]["properties"]["params"]["type"], "object", "{open}");
let mut b = Recording(vec![]);
let args = json!({"path": "/in/a.tup", "importer": "plugin.test.mcp.params", "params": {"density": 7}});
assert!(!call_tool(&mut b, "open_file", &args).is_error);
let [(method, params)] = &b.0[..] else { panic!("one call: {:?}", b.0) };
assert_eq!(method, "app.open");
assert_eq!(params, &args);
}
+9 -2
View File
@@ -357,10 +357,17 @@ pub fn tool_definitions() -> Vec<Value> {
"open_file",
"Open file",
&format!(
"Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).",
"Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads, and `params` gives that importer's parameters.",
OPEN_EXTS.join(", .")
),
obj(json!({"path": string("File path")}), &["path"]),
obj(
json!({
"path": string("File path"),
"importer": string("An importer plug-in's format id, plugin.<id> (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"),
"params": {"type": "object", "description": "The importer plug-in's parameters (see run_command plugin.list; default: the defaults its manifest declares)"},
}),
&["path"],
),
false,
),
tool(
+7 -1
View File
@@ -191,7 +191,13 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
"screenshot" => screenshot(b, a),
"open_file" => {
let path = absolute_path(b, req_str(a, "path")?)?;
j(b.call("app.open", json!({"path": path}))?)
let mut p = json!({"path": path});
for k in ["importer", "params"] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
p[k] = v.clone();
}
}
j(b.call("app.open", p)?)
}
"save_file" => {
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?});
+3 -2
View File
@@ -75,7 +75,8 @@ pub struct Target<'a> {
/// The limits of an export by `plugin`: its own, with the budgets of an export.
pub fn limits(plugin: &Plugin) -> Limits {
Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }
let own = plugin.limits();
Limits { fuel_base: own.export_fuel, wall_time_ms: own.export_wall_time_ms, ..own.clone() }
}
/// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against
@@ -83,7 +84,7 @@ pub fn limits(plugin: &Plugin) -> Limits {
pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result<Exported> {
let m = plugin.manifest();
if m.kind != Kind::Exporter {
return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name())));
return Err(Error::Params(format!("`{}` is a plug-in of kind `{}`, not an exporter", m.id, m.kind.name())));
}
let mut params = Value::Object(m.resolve_params(params)?);
params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name});
+184
View File
@@ -0,0 +1,184 @@
//! Importer plug-ins: a plug-in of kind `importer` reads files in a format of its own, such as a
//! pen plotter's or a cutting machine's, into a new document.
//!
//! File › Open opens a file with an importer only when no built-in format reads it: the built-in
//! formats are found by the file's content first and then by its extension, and an importer is
//! picked by an extension it lists. When several importers list the extension, the one whose id
//! sorts first opens it ([`for_extension`]). File › Open With and `document.open {importer}` pick an
//! importer by its format id (`plugin.<id>`), also for an extension a built-in format reads.
//!
//! The host hands the importer the file's bytes as they are, at most [`MAX_INPUT_BYTES`] of them,
//! and reads back the objects of the new document in the same form filters return them
//! ([`crate::objects`]): `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline.
//! An import is one run the user asked for, so it gets an export's budget ([`crate::export::FUEL`]
//! and [`crate::export::WALL_TIME_MS`]), and Cancel stops it between two slices of the budget
//! ([`Error::Cancelled`]).
use std::sync::Arc;
use serde_json::{Value, json};
use crate::objects::{OutObject, declined, printable};
use crate::{Error, Kind, Limits, Plugin, Result, registry};
/// The largest file an importer is given, in bytes: 128 MiB, a quarter of a plug-in's memory
/// limit, so the file and the objects made from it fit beside each other.
pub const MAX_INPUT_BYTES: usize = 128 << 20;
/// The keys an importer's output may have.
const OUTPUT_KEYS: &[&str] = &["objects", "warnings", "error"];
/// The installed importers, by plug-in id.
pub fn installed() -> Vec<Arc<Plugin>> {
registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Importer).collect()
}
/// The installed importer of format id `format_id` (`plugin.<id>`).
pub fn by_format(format_id: &str) -> Option<Arc<Plugin>> {
registry::get(crate::export::plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Importer)
}
/// The installed importer `importer` names: its format id (`plugin.<id>`) or its plug-in id. A
/// plug-in of another kind, and one that isn't installed, are errors that say so.
pub fn named(importer: &str) -> Result<Arc<Plugin>> {
let id = crate::export::plugin_id(importer).unwrap_or(importer);
let plugin = registry::get(id).ok_or_else(|| Error::NotFound(id.into()))?;
match plugin.manifest().kind {
Kind::Importer => Ok(plugin),
_ => Err(not_importer(plugin.manifest())),
}
}
/// The error of plug-in `m`, which is not an importer, asked to import.
fn not_importer(m: &crate::Manifest) -> Error {
Error::Params(format!("`{}` is a plug-in of kind `{}`, not an importer", m.id, m.kind.name()))
}
/// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in
/// id: the first is the one File › Open uses.
pub fn for_extension(ext: &str) -> Vec<Arc<Plugin>> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
installed().into_iter().filter(|p| p.manifest().extensions.contains(&ext)).collect()
}
/// What an importer returned.
#[derive(Clone, Debug, PartialEq)]
pub struct Imported {
/// The objects of the new document, in paint order (bottom first), checked as a filter's are.
pub objects: Vec<OutObject>,
/// What the import left out or changed, for the user (at most
/// [`crate::export::MAX_WARNINGS`]).
pub warnings: Vec<String>,
}
/// The file an import reads, as the importer is told in `_context`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Source<'a> {
/// Its extension, without the dot: one the importer lists.
pub extension: &'a str,
/// Its name without the folder and extension.
pub name: &'a str,
}
/// The error of a file larger than [`MAX_INPUT_BYTES`], which no importer is given.
pub fn too_large() -> Error {
Error::Io(format!("the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads"))
}
/// The limits of an import by `plugin`: its own, with the budgets of an export.
pub fn limits(plugin: &Plugin) -> Limits {
crate::export::limits(plugin)
}
/// Runs importer `plugin` on `file`, the bytes of the file being opened, with `params` (checked
/// against its manifest: missing ones take their defaults) for the file `source`.
pub fn run(plugin: &Plugin, file: &[u8], params: &Value, source: Source) -> Result<Imported> {
let m = plugin.manifest();
if m.kind != Kind::Importer {
return Err(not_importer(m));
}
if file.len() > MAX_INPUT_BYTES {
return Err(too_large());
}
let mut params = Value::Object(m.resolve_params(params)?);
params["_context"] = json!({"mode": "import", "extension": source.extension, "name": source.name});
let params = serde_json::to_vec(&params).map_err(|e| Error::Params(e.to_string()))?;
decode(&plugin.run_limited(file, &params, &limits(plugin))?)
}
/// An importer's output: `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline
/// with a message for the user. The objects are new ones (no `id`), checked as a filter's are; an
/// output without any, or with keys of its own, is refused.
pub fn decode(bytes: &[u8]) -> Result<Imported> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
if let Some(e) = declined(&v) {
return Err(e);
}
if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !OUTPUT_KEYS.contains(&k.as_str()))) {
let key = printable(&json!(key), 64);
return Err(Error::Output(format!("an importer returns `objects` and `warnings`, not `{key}`")));
}
let objects = crate::objects::decode(bytes)?;
if objects.is_empty() {
return Err(Error::Output("the importer returned no objects".into()));
}
let warnings = match v.get("warnings") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(w)) => w.iter().take(crate::export::MAX_WARNINGS).map(|w| printable(w, crate::export::MAX_WARNING_CHARS)).collect(),
Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())),
};
Ok(Imported { objects, warnings })
}
#[cfg(test)]
mod tests {
use super::*;
const SQUARE: &str = r#"{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]}]}]}}"#;
#[test]
fn output_is_the_objects_and_the_warnings_or_a_decline() {
let out = decode(format!(r#"{{"objects":[{SQUARE}],"warnings":["a",2]}}"#).as_bytes()).unwrap();
assert_eq!((out.objects.len(), out.warnings), (1, vec!["a".to_string(), "2".to_string()]));
assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some()));
assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::<String>::new());
assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into())));
// A decline is reported as one, also with keys the host doesn't read beside it.
assert_eq!(decode(br#"{"error": "No.", "layers": []}"#), Err(Error::Failed("No.".into())));
}
/// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys
/// the host doesn't read, bad warnings and bad objects (coordinates out of range, unknown paints)
/// are all errors.
#[test]
fn bad_output_is_refused() {
let far = r#"{"objects":[{"path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#;
for (bad, says) in [
(&b"[]"[..], "objects"),
(b"{}", "objects"),
(b"not json", "not JSON"),
(br#"{"objects":[]}"#, "no objects"),
(br#"{"objects":{}}"#, "objects"),
(br#"{"objects":[1]}"#, "JSON object"),
(far.as_bytes(), "out of range"),
(br#"{"objects":[{"path":{"subpaths":[]},"fills":[{"type":"hatch"}]}]}"#, "objects[0]"),
(br#"{"objects":[],"artboard":[0,0,1,1]}"#, "not `artboard`"),
] {
let e = decode(bad).unwrap_err().to_string();
assert!(e.contains(says), "{}: {e}", String::from_utf8_lossy(bad));
}
let warns = format!(r#"{{"objects":[{SQUARE}],"warnings":"x"}}"#);
assert!(decode(warns.as_bytes()).unwrap_err().to_string().contains("`warnings`"));
}
#[test]
fn warnings_are_capped_in_number_and_length() {
let many: Vec<String> = (0..crate::export::MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect();
let square: Value = serde_json::from_str(SQUARE).unwrap();
let out = decode(json!({"objects": [square], "warnings": many}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.len(), crate::export::MAX_WARNINGS);
let long = "x".repeat(crate::export::MAX_WARNING_CHARS + 1);
let out = decode(json!({"objects": [square], "warnings": [long]}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(crate::export::MAX_WARNING_CHARS));
}
}
+4 -2
View File
@@ -23,7 +23,8 @@
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns
//! ([`painted`]); an **exporter** ([`export`]) writes the whole
//! document in a file format of its own. The process-wide [`registry`] holds the installed
//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of
//! its own into a new document. The process-wide [`registry`] holds the installed
//! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. A
@@ -35,6 +36,7 @@ pub mod dialog;
pub mod effect;
pub mod export;
pub mod host;
pub mod import;
pub mod manifest;
pub mod objects;
pub mod painted;
@@ -51,7 +53,7 @@ pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`], [`import`]).
pub const PREFIX: &str = "plugin.";
/// A plug-in error. Every way a module can misbehave ends here, never in a panic.
+43 -11
View File
@@ -18,7 +18,7 @@ pub const MAX_UNGROUPED: usize = 64;
pub const MAX_TEXT: usize = 4_096;
/// Most numbers a `numberList` parameter may hold.
pub const MAX_LIST: usize = 256;
/// Most file extensions an exporter may declare.
/// Most file extensions an exporter or an importer may declare.
pub const MAX_EXTENSIONS: usize = 8;
/// Most menu commands a plug-in may add (`commands`).
pub const MAX_COMMANDS: usize = 32;
@@ -38,6 +38,9 @@ pub enum Kind {
/// An exporter: writes the document in a file format of its own, whose extensions the manifest
/// lists (File › Export › Export As, `document.export`; see [`crate::export`]).
Exporter,
/// An importer: reads a file in a format of its own, whose extensions the manifest lists, into
/// a new document (File › Open, `document.open`; see [`crate::import`]).
Importer,
}
impl Kind {
@@ -46,6 +49,7 @@ impl Kind {
Kind::Filter => "filter",
Kind::Effect => "effect",
Kind::Exporter => "exporter",
Kind::Importer => "importer",
}
}
}
@@ -281,8 +285,8 @@ pub struct Manifest {
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, Param)>,
/// An exporter's file extensions, lower case and without the dot; the first is the one its
/// files get. Only exporters declare them.
/// An exporter's or an importer's file extensions, lower case and without the dot; the first is
/// the one an exporter's files get. Only exporters and importers declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
/// How much a run may compute: only a filter may be [`Budget::Heavy`].
@@ -378,6 +382,9 @@ impl Manifest {
(Budget::Heavy, Kind::Exporter) => {
return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into()));
}
(Budget::Heavy, Kind::Importer) => {
return Err(Error::Manifest("an importer always gets an import's budget: leave `budget` out".into()));
}
}
self.validate_params()?;
self.validate_commands()
@@ -405,7 +412,7 @@ impl Manifest {
if self.commands.is_empty() {
return Ok(());
}
if self.kind == Kind::Exporter {
if matches!(self.kind, Kind::Exporter | Kind::Importer) {
return Err(Error::Manifest("only a filter or a live effect declares `commands`".into()));
}
if self.budget == Budget::Heavy {
@@ -439,17 +446,17 @@ impl Manifest {
Ok(())
}
/// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case
/// letters and digits; other kinds list none.
/// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16
/// lower-case letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
let exts = &self.extensions;
let count_ok = !exts.is_empty() && exts.len() <= MAX_EXTENSIONS;
match self.kind {
Kind::Exporter => {}
Kind::Exporter if !count_ok => return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))),
Kind::Importer if !count_ok => return Err(Error::Manifest(format!("an importer declares 1 to {MAX_EXTENSIONS} `extensions`"))),
Kind::Exporter | Kind::Importer => {}
_ if exts.is_empty() => return Ok(()),
_ => return Err(Error::Manifest("only an exporter declares `extensions`".into())),
}
if exts.is_empty() || exts.len() > MAX_EXTENSIONS {
return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`")));
_ => return Err(Error::Manifest("only an exporter or an importer declares `extensions`".into())),
}
for (i, e) in exts.iter().enumerate() {
if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) {
@@ -830,6 +837,31 @@ mod tests {
}
}
/// An importer lists the extensions it reads by the exporter's rules, and gets an import's
/// budget and the `import.read` permission.
#[test]
fn an_importer_lists_its_extensions() {
let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"importer","extensions":["plt","hpgl"]}"#).unwrap();
assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Importer, &["plt".to_string(), "hpgl".to_string()][..]));
assert_eq!(serde_json::to_value(&m).unwrap()["kind"], json!("importer"));
assert_eq!(m.permissions(), [Permission::ImportRead]);
for (bad, says) in [
(r#"{"id":"a","name":"x","kind":"importer"}"#, "an importer declares 1 to 8"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":[]}"#, "an importer declares 1 to 8"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["a","b","c","d","e","f","g","h","i"]}"#, "an importer declares 1 to 8"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["Plt"]}"#, "lower-case"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":[".plt"]}"#, "without the dot"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["abcdefghijklmnopq"]}"#, "1-16"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt","plt"]}"#, "listed twice"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"budget":"heavy"}"#, "an import's budget"),
(r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"permissions":["doc.read"]}"#, "doesn't apply"),
(r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#, "only an exporter or an importer"),
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
}
/// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an
/// exporter has an export's budget, so they can't. Light, the default, isn't written back.
#[test]
+62 -14
View File
@@ -54,6 +54,9 @@ pub enum Permission {
/// Write the document as a file in its own format (an exporter).
#[serde(rename = "export.write")]
ExportWrite,
/// Read a file the user opens and make a new document from it (an importer).
#[serde(rename = "import.read")]
ImportRead,
/// Report how far a run has got, see that the user asked it to stop and write log lines: the
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
#[serde(rename = "progress")]
@@ -67,12 +70,13 @@ pub enum Permission {
}
/// Every permission this host grants, in the order prompts list them.
pub const ALL: [Permission; 8] = [
pub const ALL: [Permission; 9] = [
Permission::DocRead,
Permission::DocWrite,
Permission::State,
Permission::EffectRender,
Permission::ExportWrite,
Permission::ImportRead,
Permission::Progress,
Permission::Settings,
Permission::Cache,
@@ -91,6 +95,7 @@ impl Permission {
Permission::State => "state",
Permission::EffectRender => "effect.render",
Permission::ExportWrite => "export.write",
Permission::ImportRead => "import.read",
Permission::Progress => "progress",
Permission::Settings => "settings",
Permission::Cache => "cache",
@@ -110,6 +115,7 @@ impl Permission {
Permission::State => "Keep data of its own with the document",
Permission::EffectRender => "Change how the objects its effect is applied to are drawn",
Permission::ExportWrite => "Write the document as a file in its own format",
Permission::ImportRead => "Read files you open in its format and make documents from them",
Permission::Progress => "Show its progress, see Cancel and write log lines",
Permission::Settings => "Keep settings of its own",
Permission::Cache => "Keep a cache of its own, which you can clear",
@@ -129,6 +135,7 @@ impl Permission {
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
Kind::Effect => &[Permission::DocRead, Permission::EffectRender],
Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite],
Kind::Importer => &[Permission::ImportRead],
}
}
@@ -144,12 +151,12 @@ impl Permission {
}
/// The permissions a plug-in of `kind` can't work without, which its list must hold: a live
/// effect and an exporter are always handed objects, and only draw or write. A filter may
/// leave any of its permissions out.
/// effect and an exporter are always handed objects, and only draw or write; an importer is
/// always handed the file being opened. A filter may leave any of its permissions out.
fn required(kind: Kind) -> &'static [Permission] {
match kind {
Kind::Filter => &[],
Kind::Effect | Kind::Exporter => Permission::implied(kind),
Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind),
}
}
}
@@ -235,6 +242,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits?
fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
/// ([`sha256_hex`]) and the permissions it was granted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -245,9 +257,10 @@ pub struct Grant {
}
impl Grant {
/// Does it grant every one of `wanted`?
pub fn covers(&self, wanted: &[Permission]) -> bool {
wanted.iter().all(|w| self.permissions.contains(w))
/// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant
/// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing.
pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool {
self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w))
}
}
@@ -272,12 +285,16 @@ mod tests {
assert_eq!(validate(Kind::Filter, false, &[]).unwrap(), []);
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render"])).is_ok());
assert!(validate(Kind::Exporter, false, &list(&["export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Importer, false, &list(&["import.read"])).unwrap(), [Permission::ImportRead]);
// Any kind may list `progress`, which no kind implies.
assert_eq!(validate(Kind::Filter, false, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
assert!(validate(Kind::Effect, false, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Effect, true, &list(&["doc.read", "effect.render", "progress"])).is_ok());
assert!(validate(Kind::Exporter, false, &list(&["progress", "export.write", "doc.read"])).is_ok());
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
assert_eq!(validate(Kind::Importer, false, &list(&["progress", "import.read"])).unwrap(), [Permission::ImportRead, Permission::Progress]);
assert!(
[Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))
);
// So may `settings` and `cache`, which no kind implies either.
assert_eq!(
validate(Kind::Effect, false, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(),
@@ -285,7 +302,7 @@ mod tests {
);
assert!(validate(Kind::Exporter, false, &list(&["settings", "export.write", "doc.read"])).is_ok());
assert_eq!(validate(Kind::Filter, false, &list(&["cache"])).unwrap(), [Permission::Cache]);
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] {
for kind in [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer] {
for commands in [false, true] {
assert!(HOST.iter().all(|p| p.applies(kind, commands) && !Permission::implied(kind).contains(p)));
}
@@ -311,6 +328,10 @@ mod tests {
(Kind::Exporter, false, &["export.write"], "needs the permission `doc.read`"),
(Kind::Filter, false, &["cache", "cache"], "`cache` is listed twice"),
(Kind::Filter, false, &["storage"], "unknown permission `storage`"),
(Kind::Importer, false, &["import.read", "doc.read"], "`doc.read` doesn't apply to a plug-in of kind `importer`"),
(Kind::Importer, true, &["import.read", "doc.write"], "`doc.write` doesn't apply to a plug-in of kind `importer`"),
(Kind::Importer, false, &[], "needs the permission `import.read`"),
(Kind::Exporter, false, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"),
] {
let e = validate(kind, commands, &list(names)).unwrap_err().to_string();
assert!(e.contains(says), "{names:?}: {e}");
@@ -343,10 +364,37 @@ mod tests {
#[test]
fn a_grant_covers_its_permissions_and_no_more() {
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers(&[Permission::DocRead]));
assert!(g.covers(&[]));
assert!(!g.covers(&[Permission::State]));
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
let hash = sha256_hex(b"module");
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers("a", &hash, &[Permission::DocRead]));
assert!(g.covers("a", &hash, &[]));
assert!(!g.covers("a", &hash, &[Permission::State]));
assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State]));
}
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
let hash = sha256_hex(b"module");
let wanted = [Permission::DocRead];
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] };
assert!(g.covers("a", &hash, &wanted));
assert!(!g.covers("b", &hash, &wanted), "another id");
assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes");
assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted");
}
/// Damaged data can't stand for every module, even when the hash asked about is just as damaged.
#[test]
fn a_grant_with_an_invalid_hash_covers_nothing() {
let upper = sha256_hex(b"module").to_uppercase();
let short = "ab".repeat(31);
let long = "ab".repeat(33);
let not_hex = "g".repeat(64);
let wide = "é".repeat(32);
for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] {
let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] };
assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}");
assert!(!g.covers("a", hash, &[]), "{hash:?}");
}
}
}
+28 -4
View File
@@ -56,11 +56,11 @@ pub fn revoke(id: &str) -> bool {
}
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
/// already grant its id every permission it lists? A module with other bytes but no new permission
/// needs no new approval.
/// already grant this very module (its id and SHA-256) every permission it lists? A module with
/// other bytes needs a new approval, even when it lists the same permissions.
pub fn approved(plugin: &Plugin) -> bool {
let m = plugin.manifest();
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
}
/// Installs `plugin`, replacing one with the same id.
@@ -153,7 +153,7 @@ pub struct FolderReport {
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
/// which installs only once the user agrees, and one that lists permissions the user has not
/// granted its id ([`approved`]).
/// granted this very module ([`approved`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()), approved)
@@ -213,3 +213,27 @@ pub fn load_folder_where(
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
/// A cache keyed by the revision can't store the old list under the new revision: an install
/// waiting for the set leaves the revision alone until it holds the set.
#[test]
fn an_install_changes_the_revision_only_while_it_holds_the_set() {
let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap();
let id = plugin.id().to_string();
let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner());
let before = revision();
let installing = std::thread::spawn(move || install(plugin));
let moved = (0..1_000_000).any(|_| {
std::thread::yield_now();
revision() != before
});
drop(held);
installing.join().unwrap();
assert!(!moved, "the revision changed while the install waited for the set");
assert!(revision() > before && remove(&id));
}
}
+6
View File
@@ -61,6 +61,10 @@ pub struct Limits {
/// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more.
pub heavy_fuel: u64,
pub heavy_wall_time_ms: u64,
/// What one export or import is given instead of `fuel_base` and `wall_time_ms`
/// ([`crate::export::limits`]).
pub export_fuel: u64,
pub export_wall_time_ms: u64,
}
impl Default for Limits {
@@ -75,6 +79,8 @@ impl Default for Limits {
wall_time_ms: 60_000,
heavy_fuel: HEAVY_FUEL,
heavy_wall_time_ms: HEAVY_WALL_TIME_MS,
export_fuel: crate::export::FUEL,
export_wall_time_ms: crate::export::WALL_TIME_MS,
}
}
}
+5
View File
@@ -167,6 +167,11 @@ pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
}
/// The manifest of a test importer `id` reading `extensions` (a JSON list) with `params`.
pub fn importer(id: &str, extensions: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"importer","extensions":{extensions},"params":{params}}}"#)
}
/// What [`echo_exporter`] writes as its file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Echo {
+40 -1
View File
@@ -273,7 +273,46 @@ fn an_exporter_returns_its_file_with_an_export_s_budget() {
// Its parameters are checked, and a filter is not an exporter.
assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_))));
let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap();
assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_))));
let e = export::run(&filter, b"{}", &Value::Null, target).unwrap_err();
assert!(matches!(e, Error::Params(_)) && e.to_string().contains("is a plug-in of kind `filter`, not an exporter"), "{e}");
let importer = Plugin::load(&build(&tpl::returning(&tpl::importer("test.reads", r#"["tst"]"#, "{}"), "{}")), Limits::default()).unwrap();
let e = export::run(&importer, b"{}", &Value::Null, target).unwrap_err();
assert!(e.to_string().contains("is a plug-in of kind `importer`, not an exporter"), "{e}");
}
/// An importer runs on an import's budget, which replaces its own instruction and time budgets and
/// keeps its other limits: a run that needs more than `fuel_base` finishes within `export_fuel`,
/// and a run that needs more than `export_fuel` ends there, however large `fuel_base` is.
#[test]
fn an_importer_runs_on_an_import_s_budget() {
use vectorcraft_plugins::export;
use vectorcraft_plugins::import::{self, Source};
let run = format!(
"(local $n i32) (local.set $n (i32.const 100000)) \
(loop $work (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}",
tpl::ECHO
);
let wasm = build(&tpl::module(&tpl::importer("test.import.budget", r#"["tst"]"#, "{}"), &run, "", 1));
let file = br#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1,0]}]}]}}]}"#;
let source = Source { extension: "tst", name: "a" };
let limits = import::limits(&Plugin::load(&wasm, Limits::default()).unwrap());
assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS));
let own = Limits {
fuel_base: 9_000,
fuel_per_byte: 0,
wall_time_ms: 8_000,
export_fuel: 10_000_000,
export_wall_time_ms: 7_000,
max_output_bytes: 6_000,
..Limits::default()
};
let p = Plugin::load(&wasm, own.clone()).unwrap();
assert_eq!(import::limits(&p), Limits { fuel_base: 10_000_000, wall_time_ms: 7_000, ..own });
assert_eq!(import::run(&p, file, &Value::Null, source).map(|i| i.objects.len()), Ok(1));
let p = Plugin::load(&wasm, Limits { fuel_base: 10_000_000, fuel_per_byte: 0, export_fuel: 9_000, ..Limits::default() }).unwrap();
assert_eq!(import::run(&p, file, &Value::Null, source), Err(Error::Limit("instruction budget".into())));
}
/// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is
+35
View File
@@ -857,6 +857,41 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() {
assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50));
}
/// The canvas turns and reflects a gradient fitted to the square with its Transform effect, on the
/// object and as the fill's own effect.
#[test]
fn a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas() {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let draw = |e: Effect, own: bool| {
let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0));
let mut n = Node::path(NodeId(0), square, Appearance::basic(fitted.clone(), Paint::None, 0.0));
if own {
n.appearance.items[0].effects_mut().push(e);
} else {
n.appearance.effects.push(e);
}
render(&doc_with(Document::new(100.0, 100.0), n))
};
let rb = |img: &Rendered, x: u32, y: u32| {
let p = img.pixel(x, y);
(p[0], p[2])
};
let red = |(r, b): (u8, u8)| r > 200 && b < 60;
let blue = |(r, b): (u8, u8)| b > 200 && r < 60;
for own in [false, true] {
// A quarter turn: the left (red) to right (blue) ramp runs from the bottom up.
let img = draw(fx("distort.transform", json!({"rotate": 90})), own);
let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63));
assert!(blue(top) && red(bottom), "own {own}: {top:?} {bottom:?}");
assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "own {own}: the same color across");
// Reflected left to right: blue on the left, red on the right.
let img = draw(fx("distort.transform", json!({"reflectX": true})), own);
let (left, right) = (rb(&img, 11, 50), rb(&img, 38, 50));
assert!(blue(left) && red(right), "own {own}: {left:?} {right:?}");
}
}
#[test]
fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() {
use vectorcraft_color::GradientGeom;
+15 -3
View File
@@ -31,6 +31,13 @@ impl FontDb {
{
let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner());
if map.get(&key).is_some_and(|f| **f == *font) {
// The same definition a guard lends: it stays when the guard drops.
let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner());
if let Some(loan) = loans.get_mut(&key)
&& map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font))
{
loan.adopted = true;
}
return false;
}
map.insert(key, Arc::new(font.clone()));
@@ -194,7 +201,7 @@ impl FontDb {
}
let font = Arc::new((*f).clone());
let previous = map.insert(key.clone(), font.clone());
loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 });
loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false });
lent.push((key, font));
changed = true;
}
@@ -300,6 +307,9 @@ pub(super) struct CompositeLoan {
previous: Option<Arc<CompositeFont>>,
/// How many guards lend it.
guards: usize,
/// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last
/// guard to drop then leaves it in place.
adopted: bool,
}
/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops.
@@ -327,9 +337,11 @@ impl Drop for LentCompositeFonts<'_> {
continue;
}
let previous = loan.previous.take();
let adopted = loan.adopted;
loans.remove(key);
// Given back unless a font of that name has been set or removed since.
if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) {
// Given back unless a font of that name has been set (the same definition included)
// or removed since.
if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) {
match previous {
Some(p) => map.insert(key.clone(), p),
None => map.remove(key),
+2 -2
View File
@@ -7,8 +7,8 @@ use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2};
use unicode_bidi::{BidiInfo, Level};
use vectorcraft_doc::text::{Kashida, ListAlign};
use vectorcraft_doc::{
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject,
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle,
PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject,
};
use vectorcraft_geom::{ArcPath, PathData};
+146 -3
View File
@@ -1,7 +1,8 @@
use super::region::Region;
use super::vertical::stands_upright;
use super::{
Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph,
no_line_end, no_line_start, points_for, soft_line_start,
Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph,
hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start,
};
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
@@ -269,7 +270,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop
/// (`tabs`), with the line's content starting `x_start` from the tab origin.
/// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone
/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides
/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical
/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type.
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
@@ -281,6 +285,9 @@ pub(super) fn break_line(
hyphenate: Option<(&HyphenationOptions, f64)>,
burasagari: Burasagari,
kinsoku: Kinsoku,
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
) -> (usize, bool) {
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
@@ -331,6 +338,11 @@ pub(super) fn break_line(
}
return (end, false);
}
if let Some(end) =
push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical)
{
return (end, false);
}
break;
}
x += cluster_width;
@@ -493,6 +505,137 @@ fn kinsoku_between(before: char, after: Option<char>, kinsoku: Kinsoku) -> bool
}
}
/// The share of an em by which a push-in ([`push_in`]) may close each gap next to a CJK character
/// on the line, measured on the character before the gap.
pub(super) const PUSH_IN_SQUEEZE: f64 = 0.1;
/// The most glyphs after the overflowing cluster a push-in ([`push_in`]) takes onto the line; a
/// longer run of characters kinsoku keeps together pushes out.
const PUSH_IN_RUN: usize = 16;
/// Kinsoku Shori Type: the end of the line from `i` that overflows `width` at the cluster
/// `j..cluster_end` (the line is `x` wide through it) when the line pushes in, or None when it
/// pushes out. A line pushes in only where kinsoku rules out the break before `j`: a character
/// that may not end a line comes before it (an opening bracket or quotation mark, Latin ones
/// included), or one that may not start a line comes at `j`. It then takes the characters up to
/// the next break kinsoku allows (not at a soft hyphen), and the spaces after it, when closing
/// its gaps next to CJK characters, each by up to [`PUSH_IN_SQUEEZE`] of an em of the character
/// before it, makes room for them. Push In First tries this before it pushes out. Push Out First
/// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the
/// line short by more than an em of the character before `j`. Push Out Only never pushes in, and
/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in
/// reach a space after a character that may not end a line. With `hanging` (Optical Margin
/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]).
#[allow(clippy::too_many_arguments)]
fn push_in(
g: &[SGlyph],
i: usize,
j: usize,
cluster_end: usize,
x: f64,
width: f64,
pushed_out: Option<usize>,
kinsoku: Kinsoku,
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
) -> Option<usize> {
if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly {
return None;
}
let before = g.get(j.checked_sub(1)?)?;
if before.len == 0 || g.get(j)?.no_break || splits_no_break(g, j) || kinsoku_allows(g, j - 1, kinsoku) {
return None;
}
let allowed = |k: usize| {
k >= g.len()
|| (g[k].byte != g[k - 1].byte
&& kinsoku_allows(g, k - 1, kinsoku)
&& (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen())))
};
let (mut end, mut x) = (cluster_end, x);
while !allowed(end) {
let gl = &g[end];
// A space here follows a character kinsoku keeps off a line's end: the line pushes out.
if gl.is_space() || gl.ch == '\t' || gl.ch == '\u{2028}' || gl.no_break || end - cluster_end >= PUSH_IN_RUN {
return None;
}
x += gl.adv;
end += 1;
}
// A tab's width depends on where it falls; a line with one (other than a list marker's)
// pushes out.
if g[i..end].iter().any(|h| h.ch == '\t' && h.len > 0) {
return None;
}
// With `hanging`, punctuation keeps the gap after a mark that may hang at the line's start,
// and the gap before one that may hang at its end ([`optical_hangs`]): those gaps are not
// counted.
let mut last = end;
while last > i && g[last - 1].is_space() {
last -= 1;
}
// A line holding right-to-left text is reordered before it is drawn, and justification closes
// the gaps between its glyphs in that order: it pushes out.
if g[i..last].iter().any(|h| h.len > 0 && h.level.is_rtl()) {
return None;
}
let punct = |k: usize| g.get(k).is_some_and(optical_punct);
let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch));
let may_hang = |a: usize| {
hanging
&& ((a == i && punct(i))
|| (a == i + 1 && punct(i) && quote(i + 1))
|| (a + 2 == last && punct(last - 1))
|| (a + 3 == last && punct(last - 2) && quote(last - 1)))
};
let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
if x - width > room + EPS {
return None;
}
if kinsoku_type == KinsokuShoriType::PushOutFirst {
let mut kept = pushed_out.filter(|&b| b > i).unwrap_or(i);
while kept > i && g[kept - 1].is_space() {
kept -= 1;
}
let short = width - g[i..kept].iter().map(|h| h.adv).sum::<f64>();
if short <= squeeze_em(before, vertical) + EPS {
return None;
}
}
while g.get(end).is_some_and(SGlyph::is_space) {
end += 1;
}
if g.get(end).is_some_and(|h| h.ch == '\u{2028}') {
end += 1;
}
Some(end)
}
/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them
/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space,
/// a tab or an inline graphic before it.
fn closes(g: &[SGlyph], a: usize) -> bool {
let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false };
p.len > 0
&& p.byte != n.byte
&& p.inline.is_none()
&& !p.is_space()
&& !n.is_space()
&& p.ch != '\t'
&& !n.continues_tcy()
&& (is_cjk(p.ch) || is_cjk(n.ch))
}
/// The em a push-in measures in, along the line: `g`'s em at its Horizontal Scale, as Mojikumi's
/// spacing is, or at its Vertical Scale when it stands upright in vertical type and the font's
/// vertical metrics set its length down the column ([`super::vertical::upright_cell`]).
pub(super) fn squeeze_em(g: &SGlyph, vertical: bool) -> f64 {
let down_the_column = vertical && stands_upright(g) && g.face.vertical_glyph(g.gid).is_some();
g.face.units_per_em() * if down_the_column { g.sy } else { g.sx }
}
#[cfg(test)]
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
+58 -20
View File
@@ -1,16 +1,22 @@
use super::bidi::{is_rtl, para_bidi_with, visual_order};
use super::breaking::{
Metrics, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, roman_share,
set_width, tab_advance, word_width,
Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share,
roman_share, set_width, squeeze_em, tab_advance, word_width,
};
use super::region::Region;
use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin};
use super::{
Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle,
Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk,
is_word_space, kashida, mark_word_ends, style_metrics,
Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo,
ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose,
fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
};
/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First,
/// with kinsoku on)?
fn pushes_in(para: &ParaStyle) -> bool {
para.kinsoku != Kinsoku::None && para.kinsoku_type != KinsokuShoriType::PushOutOnly
}
/// Paragraphs longer than this (glyphs) are always broken line by line.
const MAX_COMPOSE_GLYPHS: usize = 200_000;
@@ -32,6 +38,8 @@ fn compose_para(
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
// single-line composer does).
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
// So is a paragraph that may push characters kinsoku keeps off a line's ends in onto a line.
let may_push_in = pushes_in(para) && sg.iter().any(|g| no_line_start(g.ch) || no_line_end(g.ch));
// A tab's width depends on where it falls in its line, which the composer's fixed glyph widths
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
// The tab of a list marker is on the first line, with the marker, and has a fixed width.
@@ -42,6 +50,7 @@ fn compose_para(
|| sg.len() < 2
|| sg.len() > MAX_COMPOSE_GLYPHS
|| may_hang
|| may_push_in
|| has_tab
|| has_soft_break
{
@@ -285,6 +294,8 @@ struct PlacementContext<'a> {
zone: f64,
index: usize,
para_line: usize,
/// Optical Margin Alignment or Roman Hanging Punctuation is on.
hanging: bool,
}
struct LinePosition {
@@ -293,13 +304,16 @@ struct LinePosition {
x1: f64,
end: usize,
hyph: bool,
/// Kinsoku Shori Type pushed characters in onto the line ([`break_line`]).
pushed_in: bool,
m: Metrics,
ind_l: f64,
ind_r: f64,
}
fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option<LinePosition> {
let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context;
let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } =
context;
let n = sg.len();
let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm };
let first_line = *para_line == 0 && !*continued;
@@ -328,10 +342,17 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
};
let width = x1 - x0 - ind_l - ind_r;
let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((&para.hyphenation, *zone));
let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph),
_ if *i < n => break_line(cx.text, sg, *i, width, &para.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku),
_ => (n, false),
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph, false),
_ if *i < n => {
let line =
|ty| break_line(cx.text, sg, *i, width, &para.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical);
let (end, hyph) = line(para.kinsoku_type);
// The line pushed characters in where it ends after the line that pushes out.
let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0;
(end, hyph, pushed_in)
}
_ => (n, false, false),
};
let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm);
baseline += if same_baseline || (pen.regions.is_none() && first_in_region) {
@@ -353,7 +374,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
pen.queued.clear();
continue;
}
return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r });
return Some(LinePosition { baseline, x0, x1, end, hyph, pushed_in, m, ind_l, ind_r });
}
}
@@ -381,6 +402,10 @@ struct LineJustification {
per_space: f64,
per_gap: f64,
per_cjk: f64,
/// Kinsoku Shori Type: the share of an em (of the glyph before it, at most
/// [`PUSH_IN_SQUEEZE`]) each gap next to a CJK character closes by on a line that pushed
/// characters in; 0 on other lines.
squeeze: f64,
fit: LineFit,
start_x: f64,
after: Vec<Option<usize>>,
@@ -415,6 +440,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
zone: paragraph.zone,
index,
para_line: line_in_paragraph,
hanging: t.optical_margin || paragraph.para.roman_hanging,
};
let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false };
hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 };
@@ -572,12 +598,13 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option<usize>]) -> Option<us
}
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
// gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko block or a shaped
// cluster.
fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph]) -> bool {
!held.contains(&glyph)
&& drawn_after(index, glyph, after)
.and_then(|next| sg.get(glyph).zip(sg.get(next)))
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
&& drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| {
a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))
})
}
// What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph
@@ -639,7 +666,18 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
let width = line.render_width + kashidas.iter().map(|k| k.1).sum::<f64>();
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
let mut fit = LineFit::default();
if justify && (room - width).abs() > EPS {
let mut squeeze = 0.0;
// Kinsoku Shori Type: a line of area type that pushed characters in is wider than its
// measure, and closes its gaps next to CJK characters to fit, however it is aligned: each by
// the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`].
let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS;
if pushed_in {
let ems: f64 =
(line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], cx.vertical)).sum();
if ems > EPS {
squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE);
}
} else if justify && (room - width).abs() > EPS {
if cjk_gaps > 0 && room > width {
// Spread over the CJK gaps and the word spaces alike.
per_space = (room - width) / (cjk_gaps + spaces) as f64;
@@ -667,7 +705,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
}
}
let position = &line.position;
let start_x = if justify {
let start_x = if justify || pushed_in {
line.ax0 - line.opt_l
} else if paragraph.regions.is_none() {
match align {
@@ -682,7 +720,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
_ => line.ax1 + line.opt_r - width,
}
};
LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas }
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas }
}
fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) {
@@ -738,9 +776,9 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
}
if glyph.is_space() {
advance += justification.per_space;
} else if justification.per_cjk != 0.0 {
} else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
advance += justification.per_cjk;
advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical);
}
} else if !line.held.contains(&glyph_index)
&& drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy())
+136 -8
View File
@@ -78,7 +78,20 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
}
let cands = candidates(&text, &glyphs, Some(&opts), Kinsoku::None);
assert_eq!(cands.iter().filter(|b| b.hyphen > 0.0).map(|b| b.end).collect::<Vec<_>>(), (2..16).step_by(2).collect::<Vec<_>>(), "{tag}");
let (end, hyphen) = break_line(&text, &glyphs, 0, 4.5, &[], 0.0, Some((&opts, 0.0)), Burasagari::None, Kinsoku::None);
let (end, hyphen) = break_line(
&text,
&glyphs,
0,
4.5,
&[],
0.0,
Some((&opts, 0.0)),
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
);
assert_eq!((end, hyphen), (4, true), "{tag}");
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &params).expect("every-line composition");
@@ -148,7 +161,8 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() {
let text = "بَت";
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
let (end, hyphenated) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -161,11 +175,13 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() {
let text = "بَتَث";
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
let (first_end, _) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
let (second_end, hyphenated) =
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -220,7 +236,8 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() {
fn no_break_keeps_marked_characters_on_one_line() {
let text = "A B";
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
let (end, hyphen) =
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
assert!(!hyphen);
assert_eq!(end, 2, "without the mark the line ends after the space");
@@ -228,7 +245,8 @@ fn no_break_keeps_marked_characters_on_one_line() {
for g in &mut marked {
g.no_break = true;
}
let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
let (end, hyphen) =
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
assert!(!hyphen);
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
@@ -237,11 +255,121 @@ fn no_break_keeps_marked_characters_on_one_line() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]);
glyphs[2].no_break = true;
glyphs[3].no_break = true;
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
assert_eq!(end, 2, "the marked pair moves to the next line together");
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}");
}
/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em.
fn full_width(text: &str) -> Vec<SGlyph> {
let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect();
let mut glyphs = synthetic_glyphs(&specs);
for g in &mut glyphs {
g.sx = 10.0 / g.face.units_per_em();
g.sy = g.sx;
}
glyphs
}
/// Kinsoku Shori Type, in a measure of 15 full-width characters: a closing bracket that would
/// start the next line is pushed in onto the line with Push In First, and the character before
/// it is pushed out with it with Push Out First and Push Out Only. Push Out First pushes in when
/// pushing out would leave the line two characters short (an opening bracket goes too). A line
/// that cannot close up enough pushes out, and with Kinsoku None the types do nothing.
#[test]
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| {
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0
};
let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false);
let text = "一二三四五六七八九十一二三四五」六";
assert_eq!(end(text, 150.0, Kinsoku::Hard, PushInFirst), 16, "」 is pushed in");
assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutFirst), 14, "五 is pushed out with 」");
assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutOnly), 14, "五 is pushed out with 」");
assert_eq!(end(text, 150.0, Kinsoku::Soft, PushInFirst), 16, "Soft keeps 」 off the line start too");
assert_eq!(end(text, 150.0, Kinsoku::None, PushInFirst), 15, "no kinsoku: 」 starts the next line");
let bracketed = "一二三四五六七八九十一二三「四」六";
assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutFirst), 16, "pushing out 「四 would leave two ems");
assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushInFirst), 16);
assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutOnly), 13);
// Latin brackets and quotation marks are pushed in too: an opening one that would end the
// line, and a closing one after a Latin letter that would start the next.
for opening in ["一二三四五六七八九十一二三四(五六", "一二三四五六七八九十一二三四\u{201c}五六"] {
assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushInFirst), 16, "{opening}: the bracket and 五 are pushed in");
assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushOutOnly), 14, "{opening}: the bracket is pushed out with 五");
}
let latin = "一二三四五六七八九十一二三四a)";
assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushInFirst), 16, ") is pushed in");
assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushOutOnly), 14, "a is pushed out with )");
// A push-in does not end at a soft hyphen: taking `b` and the soft hyphen in would fit, the
// whole word `bc` does not, so the line pushes out.
let shy = format!("{}a)b\u{ad}c", "一".repeat(31));
assert_eq!(end(&shy, 320.0, Kinsoku::Hard, PushInFirst), 31);
assert_eq!(end(&shy, 320.0, Kinsoku::Hard, PushOutOnly), 31);
// Nor at a space after an opening bracket, which would leave the bracket ending the line.
let spaced = format!("{}「「 A", "一".repeat(14));
assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushInFirst), 14);
assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushOutOnly), 14);
// With hanging punctuation on, the gap before a full stop that may hang at the line's end is
// not counted: the other nine gaps close by 9 pt, short of the 10 pt the full stop needs. With
// it off, all ten gaps close and the full stop is pushed in.
let stop = format!("{}五.", "一".repeat(9));
assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushInFirst, true), 9);
assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushOutOnly, true), 9);
assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushInFirst), 11);
assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushOutOnly), 9);
// Two gaps close by at most 2 pt, not the 5 pt 」 needs.
let short = "一二」三";
assert_eq!(end(short, 25.0, Kinsoku::Hard, PushInFirst), 1, "too little to close up: 二 is pushed out");
// A line holding a tab pushes out. Here the tab reaches the default stop at 36 pt, so pushing
// out leaves the line one em short, though the tab glyph's own advance is 10 pt.
let tabbed = "一\t二三四五六七八九十一二」六";
assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12);
assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12);
assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12);
}
/// Kinsoku Shori Type counts only the gaps justification closes: a line holding right-to-left
/// text pushes out, the gaps close by a tenth of the em at the characters' Horizontal Scale, and
/// no gap closes inside a shaped cluster.
#[test]
fn kinsoku_shori_type_counts_the_gaps_that_close() {
use KinsokuShoriType::{PushInFirst, PushOutOnly};
// A line holding right-to-left text is reordered before it is drawn, so the gaps that close
// differ from the ones counted here: it pushes out.
let mixed = "一二三א五六七八九十一二三四五」六";
let mut glyphs = full_width(mixed);
glyphs[3].level = unicode_bidi::Level::rtl();
let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」");
assert_eq!(mixed_end(PushOutOnly), 14);
// The gaps close by a tenth of the em at the characters' Horizontal Scale: at 200% Vertical
// Scale five gaps still close by 5 pt, short of the 10 pt 」 needs.
let tall = "一二三四五」六";
let mut glyphs = full_width(tall);
for g in &mut glyphs {
g.sy *= 2.0;
}
let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」");
// No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to
// close by 5 pt, short of the 6 pt it overflows by.
let mut glyphs = full_width(tall);
glyphs[1].adv = 5.0;
let half = glyphs[1].clone();
glyphs.insert(2, half);
let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0;
assert_eq!(split_end(54.0), 5, "五 is pushed out with 」");
assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
}
+7
View File
@@ -129,6 +129,13 @@ fn lent_composite_fonts_last_as_long_as_a_guard() {
db.set_composite_font(&replaced);
drop(lent);
assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0);
// So does the same definition set while lent, after every guard lending it drops.
assert!(db.remove_composite_font("Lent"));
let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1]));
assert!(!db.set_composite_font(&fonts[0]));
drop(one);
drop(two);
assert!(db.composite_font("Lent").is_some());
// A guard whose font was removed doesn't give back a later guard's loan of that name.
assert!(db.remove_composite_font("Lent"));
let old = db.lend_composite_fonts(&fonts[..1]);
+80
View File
@@ -855,6 +855,86 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
}
}
/// Kinsoku Shori Type: in a measure of fifteen ems, the closing bracket after the fifteenth
/// character ends the first line with Push In First, which closes the line's gaps to keep it inside
/// the frame whatever its alignment; Push Out Only moves the fifteenth character to the next line
/// with the bracket. Horizontal and vertical. Needs a font with full-width Japanese (skipped
/// without one).
#[test]
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
use vectorcraft_doc::KinsokuShoriType;
let text = "一二三四五六七八九十一二三四五」六";
let lay = |ty: KinsokuShoriType, justify: Justify, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.kinsoku_type = ty;
t.para.justify = justify;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 300.0) } else { Rect::new(0.0, 0.0, 300.0, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(FontDb::global(), &t)
};
if (lay(KinsokuShoriType::PushOutOnly, Justify::Left, false).glyphs[0].advance - 20.0).abs() > 2.0 {
return; // no font with full-width Japanese here
}
for vertical_type in [false, true] {
let lines = |ty| lay(ty, Justify::Left, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
let mut pushed_in = vec![0; 16];
pushed_in.push(1);
assert_eq!(lines(KinsokuShoriType::PushInFirst), pushed_in, "vertical {vertical_type}");
let mut pushed_out = vec![0; 14];
pushed_out.extend([1, 1, 1]);
assert_eq!(lines(KinsokuShoriType::PushOutOnly), pushed_out, "vertical {vertical_type}");
assert_eq!(lines(KinsokuShoriType::PushOutFirst), pushed_out, "vertical {vertical_type}: a push-out leaves one em");
}
for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] {
let l = lay(KinsokuShoriType::PushInFirst, justify, false);
let (first, bracket) = (&l.glyphs[0], &l.glyphs[15]);
assert!(first.origin.x.abs() < 0.01, "{justify:?}: the line starts at the frame's left: {:?}", first.origin);
assert!(bracket.origin.x + bracket.advance <= 300.01, "{justify:?}: 」 ends inside the frame: {:?}", bracket.origin);
assert!(l.glyphs[1].origin.x < 20.0 - 0.5, "{justify:?}: the gaps close up");
for g in &l.glyphs[..15] {
assert!(g.advance >= 18.0 - 0.01, "{justify:?}: a gap closes by at most a tenth of an em: {}", g.advance);
}
}
// A No Break run wider than the frame did not push anything in, and keeps its gaps.
let mut t = TextObject::point(Point::ZERO, "一二三四五六七八九十", CharStyle { size: 20.0, no_break: true, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.para.kinsoku_type = KinsokuShoriType::PushInFirst;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 150.0, 200.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin);
}
/// Kinsoku Shori Type at 200% Vertical Scale in a column of 225 pt: the font's vertical metrics set
/// each character 40 pt long, and its five gaps close by up to 4 pt each, room for the 15 pt 」
/// overflows by, so 」 is pushed in; Push Out Only moves 五 to the next column with it. Needs
/// craft-fonts' Shippori Mincho (skipped without it).
#[test]
fn kinsoku_shori_type_closes_a_column_by_the_em_down_the_column() {
use vectorcraft_doc::KinsokuShoriType;
if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let lay = |ty: KinsokuShoriType| {
let mut t = vertical_styled(
"一二三四五」六",
CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), v_scale: 200.0, ..CharStyle::default() },
);
t.para.kinsoku_type = ty;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 225.0).to_path(0.1)) };
layout(FontDb::global(), &t)
};
let lines = |ty| lay(ty).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
let out = lay(KinsokuShoriType::PushOutOnly);
assert!((out.glyphs[0].advance - 40.0).abs() < 0.01, "40 pt down the column: {}", out.glyphs[0].advance);
assert_eq!(lines(KinsokuShoriType::PushInFirst), [0, 0, 0, 0, 0, 0, 1]);
let bracket = &lay(KinsokuShoriType::PushInFirst).glyphs[5];
assert!(bracket.origin.y + bracket.advance <= 225.01, "」 ends inside the column: {:?} +{}", bracket.origin, bracket.advance);
assert_eq!(lines(KinsokuShoriType::PushOutOnly), [0, 0, 0, 0, 1, 1, 1]);
}
/// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional
/// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in
/// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`
+1 -1
View File
@@ -11,7 +11,7 @@ mod visibility;
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane};
pub use visibility::{VisibleFace, visible_faces};
mod shading;
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient};
mod lights;
pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
+93 -1
View File
@@ -1,7 +1,99 @@
//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle.
//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle, and the
//! flat surface shading of the classic 3D effects.
use vectorcraft_geom::{Point, Vec2};
/// The surface of a classic 3D effect: how its faces are drawn and lit.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClassicSurface {
/// Only the outline of every face, hidden ones included; the faces are transparent.
Wireframe,
/// Every face in the art's own paint, unlit.
NoShading,
/// Soft light: each face as bright as it faces the light.
Diffuse,
/// Diffuse light plus a glossy highlight where a face mirrors the light toward the viewer.
#[default]
Plastic,
}
impl ClassicSurface {
pub const ALL: [ClassicSurface; 4] = [Self::Wireframe, Self::NoShading, Self::Diffuse, Self::Plastic];
pub fn id(self) -> &'static str {
match self {
Self::Wireframe => "wireframe",
Self::NoShading => "none",
Self::Diffuse => "diffuse",
Self::Plastic => "plastic",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == s)
}
}
/// The surface settings of a classic 3D effect. Shares are 0..1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClassicShading {
pub surface: ClassicSurface,
/// Light every face gets, whichever way it faces.
pub ambient: f64,
/// How bright the highlight is at its center (Plastic only).
pub highlight_intensity: f64,
/// How wide the highlight spreads: 1 broad, 0 a pinpoint (Plastic only).
pub highlight_size: f64,
/// How many levels the shading and the highlight are rounded to, 1..256.
pub blend_steps: u32,
}
impl Default for ClassicShading {
fn default() -> Self {
Self { surface: ClassicSurface::Plastic, ambient: 0.5, highlight_intensity: 0.6, highlight_size: 0.9, blend_steps: 25 }
}
}
impl ClassicShading {
/// The shine exponent of the highlight: 2 at the largest size, 256 at the smallest.
fn shininess(self) -> f64 {
2f64.powf(1.0 + 7.0 * (1.0 - self.highlight_size.clamp(0.0, 1.0)))
}
/// `x` (0..1) rounded to the nearest of the blend steps' levels.
fn stepped(self, x: f64) -> f64 {
let steps = f64::from(self.blend_steps.clamp(1, 256));
((x.clamp(0.0, 1.0) * steps).round() / steps).clamp(0.0, 1.0)
}
/// The tone (0 in full shadow, 1 fully lit) and the highlight (0 none, 1 white) of a face with
/// view-space unit normal `normal`, seen from the front and lit by `lights`: each a unit
/// direction toward the light and its intensity (0..1). The lights add up. Wireframe and No
/// Shading are unlit: tone 1, no highlight.
pub fn tone(self, normal: [f64; 3], lights: &[([f64; 3], f64)]) -> (f64, f64) {
if matches!(self.surface, ClassicSurface::Wireframe | ClassicSurface::NoShading) {
return (1.0, 0.0);
}
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let (mut tone, mut spec) = (self.ambient.clamp(0.0, 1.0), 0.0);
for &(light, intensity) in lights {
let intensity = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 };
let lit = dot(normal, light).max(0.0);
tone += intensity * lit;
// The halfway direction between the light and the eye in front of the page.
let half = [light[0], light[1], light[2] + 1.0];
let length = dot(half, half).sqrt();
if self.surface == ClassicSurface::Plastic && length > 1e-12 && lit > 0.0 {
spec += intensity * (dot(normal, half) / length).max(0.0).powf(self.shininess()) * self.highlight_intensity.clamp(0.0, 1.0);
}
}
if !tone.is_finite() || !spec.is_finite() {
return (1.0, 0.0);
}
(self.stepped(tone), self.stepped(spec))
}
}
/// Document-space gradient placement and the brightness at its two ends. Clipping a
/// face must keep this placement unchanged, so newly cut edges retain their lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
+7 -1
View File
@@ -205,11 +205,14 @@ pub struct Extrude {
/// Closed ends (a solid) or open ones (a hollow tube).
pub cap: bool,
pub bevel: Option<Bevel>,
/// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces,
/// and the classic Wireframe surface), drawn behind the faces turned toward it.
pub hidden: bool,
}
impl Default for Extrude {
fn default() -> Self {
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None }
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false }
}
}
@@ -243,6 +246,9 @@ pub struct Facet {
pub depth: f64,
pub brightness: f64,
pub illumination: [f64; 3],
/// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the
/// side the viewer sees.
pub normal: [f64; 3],
/// Smooth lighting on an inflated triangle, before clipping individual RGB channels.
pub rgb_gradient: Option<crate::RgbGradient>,
/// The contours in view space, before projection, for tests that measure depths.
+7 -3
View File
@@ -54,6 +54,9 @@ pub(super) struct Builder<'a> {
/// A closed solid hides the faces turned away from the viewer; an open one shows their
/// insides.
pub(super) closed: bool,
/// A closed solid keeps the faces turned away from the viewer too, with their outward normals,
/// drawn behind the others.
pub(super) hidden: bool,
pub(super) facets: Vec<Facet>,
/// With `Some`, every face goes here as it is, none hidden or projected, for a mesh.
pub(super) mesh: Option<Vec<Face3>>,
@@ -99,7 +102,7 @@ impl Builder<'_> {
// leave gaps.
let (n, behind) = if facing > 0.0 {
(n, false)
} else if self.closed && facing > -0.02 {
} else if self.closed && (facing > -0.02 || self.hidden) {
(n, true)
} else if self.closed {
return Ok(());
@@ -127,6 +130,7 @@ impl Builder<'_> {
depth,
brightness: self.camera.brightness(n),
illumination: crate::illumination(&self.camera.view, n, self.camera.light),
normal: [n.0, n.1, n.2],
rgb_gradient: None,
#[cfg(test)]
view,
@@ -405,7 +409,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
}
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) };
let steps = steps(&o);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
@@ -463,7 +467,7 @@ pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: V
check_bounds(bounds)?;
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
// A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so
// it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in.
+1 -1
View File
@@ -125,7 +125,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let smooth = view.shade && view.has_colored_lights();
let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach));
// Without its back cap the object is a shell open behind, its inside showing.
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let half = o.depth / 2.0;
// The back: turned and scaled by the twist and taper, its puff too.
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
+23
View File
@@ -76,6 +76,28 @@ fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4));
}
#[test]
fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
let all = extrude(&p, FillRule::EvenOdd, b, Extrude { hidden: true, ..Extrude::default() }, v).unwrap();
// A box: the three faces turned to the viewer, then the three turned away, drawn first.
assert_eq!(shown.len(), 3);
assert_eq!(all.len(), 6);
assert!(all.windows(2).all(|w| w[0].depth <= w[1].depth));
// The faces turned away keep their outward normals, so a light behind the solid lights them.
assert!(all[..3].iter().all(|f| f.normal[2] < 0.0), "{:?}", all.iter().map(|f| f.normal).collect::<Vec<_>>());
for f in shown.iter().chain(&all) {
let [x, y, z] = f.normal;
assert!(close(x.hypot(y).hypot(z), 1.0, 1e-9), "{:?}", f.normal);
}
// The visible faces keep their normals and their place at the front.
for (front, kept) in shown.iter().rev().zip(all.iter().rev()) {
assert_eq!(front.normal, kept.normal);
}
}
#[test]
fn taper_shrinks_the_back_and_twist_turns_it() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
@@ -232,6 +254,7 @@ fn malformed_and_degenerate_input_is_bounded() {
taper: -3.0,
cap: true,
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
hidden: false,
};
let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
+1
View File
@@ -1,6 +1,7 @@
use super::*;
mod caps;
mod classic;
mod lighting;
mod multiple_lights;
mod smooth;
+96
View File
@@ -0,0 +1,96 @@
use crate::*;
const TOWARD: [f64; 3] = [0.0, 0.0, 1.0];
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn one(light: [f64; 3], intensity: f64) -> [([f64; 3], f64); 1] {
[(light, intensity)]
}
#[test]
fn wireframe_and_no_shading_leave_every_face_unlit() {
for surface in [ClassicSurface::Wireframe, ClassicSurface::NoShading] {
let s = ClassicShading { surface, ..ClassicShading::default() };
for normal in [TOWARD, [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]] {
assert_eq!(s.tone(normal, &one(TOWARD, 1.0)), (1.0, 0.0), "{surface:?}");
}
}
}
#[test]
fn diffuse_adds_the_light_to_the_ambient_and_has_no_highlight() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.3, blend_steps: 256, ..ClassicShading::default() };
let (lit, spec) = s.tone(TOWARD, &one(TOWARD, 0.4));
assert!((lit - 0.7).abs() < 1.0 / 256.0, "{lit}");
assert_eq!(spec, 0.0);
// A face turned away from the light gets the ambient light only.
let (away, _) = s.tone(TOWARD, &one([1.0, 0.0, 0.0], 0.4));
assert!((away - 0.3).abs() < 1.0 / 256.0, "{away}");
// A face at 60° to the light gets half of it.
let (half, _) = s.tone(TOWARD, &one([3f64.sqrt() / 2.0, 0.0, 0.5], 0.4));
assert!((half - 0.5).abs() < 1.0 / 256.0, "{half}");
// The tone stays within full shadow and full light.
assert_eq!(ClassicShading { ambient: 1.0, ..s }.tone(TOWARD, &one(TOWARD, 1.0)).0, 1.0);
// Without lights a face gets the ambient light only.
assert!((s.tone(TOWARD, &[]).0 - 0.3).abs() < 1.0 / 256.0);
}
#[test]
fn lights_add_up() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 256, ..ClassicShading::default() };
let left = [-0.6, 0.0, 0.8];
let right = [0.6, 0.0, 0.8];
let both = s.tone(TOWARD, &[(left, 0.3), (right, 0.3)]).0;
let single = s.tone(TOWARD, &one(left, 0.3)).0;
assert!((both - 2.0 * single).abs() < 2.0 / 256.0, "{both} {single}");
// A light behind the object lights the faces turned away from the viewer only.
assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).0, 0.0);
assert!(s.tone([0.0, 0.0, -1.0], &one([0.0, 0.0, -1.0], 1.0)).0 > 0.99);
}
#[test]
fn plastic_highlight_peaks_where_a_face_mirrors_the_light_to_the_eye() {
let s = ClassicShading { highlight_intensity: 0.8, blend_steps: 256, ..ClassicShading::default() };
let light = one([0.6, 0.0, 0.8], 1.0);
// The face halfway between the light and the eye mirrors the light straight to the viewer.
let length = 0.6f64.hypot(1.8);
let half = [0.6 / length, 0.0, 1.8 / length];
let (_, peak) = s.tone(half, &light);
assert!((peak - 0.8).abs() < 1.0 / 256.0, "{peak}");
let (_, aside) = s.tone([-0.6, 0.0, 0.8], &light);
assert!(aside < peak / 4.0, "{aside}");
// A smaller highlight falls off faster.
let tilted = [0.3, 0.0, 0.954];
let broad = s.tone(tilted, &light).1;
let narrow = ClassicShading { highlight_size: 0.1, ..s }.tone(tilted, &light).1;
assert!(narrow < broad, "{narrow} {broad}");
// No highlight on a face the light does not reach, without Highlight Intensity, or on Diffuse.
assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).1, 0.0);
assert_eq!(ClassicShading { highlight_intensity: 0.0, ..s }.tone(half, &light).1, 0.0);
assert_eq!(ClassicShading { surface: ClassicSurface::Diffuse, ..s }.tone(half, &light).1, 0.0);
}
#[test]
fn blend_steps_round_the_shading_to_that_many_levels() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 2, ..ClassicShading::default() };
let tones: Vec<f64> = (0..=20).map(|k| f64::from(k) / 20.0).map(|c| s.tone(TOWARD, &one([(1.0 - c * c).sqrt(), 0.0, c], 0.5)).0).collect();
assert!(tones.iter().all(|t| close(*t, 0.0) || close(*t, 0.5)), "{tones:?}");
assert!(tones.iter().any(|t| close(*t, 0.0)) && tones.iter().any(|t| close(*t, 0.5)), "{tones:?}");
// Out-of-range step counts and intensities are kept within their ranges.
for steps in [0, 100_000] {
let t = ClassicShading { blend_steps: steps, ..s }.tone(TOWARD, &one(TOWARD, f64::NAN)).0;
assert!((0.0..=1.0).contains(&t));
}
}
#[test]
fn surfaces_round_trip_through_their_ids() {
for surface in ClassicSurface::ALL {
assert_eq!(ClassicSurface::parse(surface.id()), Some(surface));
}
assert_eq!(ClassicSurface::parse("glossy"), None);
assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic);
}
+4
View File
@@ -239,6 +239,10 @@ pub trait Tool: Send {
fn after_command(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// The undo history moved outside the tool (an edit, Undo or Redo the tool did not make), or
/// another document became active. The Blend tool drops its first pick and stops adding to the
/// blend it just made.
fn history_moved(&mut self) {}
/// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or
/// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker,
/// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly.
+5
View File
@@ -180,6 +180,11 @@ impl Tool for BlendTool {
fn chosen_again(&mut self, cx: &ToolContext) -> Vec<Action> {
self.deactivate(cx)
}
/// An edit, Undo or Redo made outside the tool drops the object picked first and stops adding to
/// the blend just made, so the next click picks a new first object.
fn history_moved(&mut self) {
(self.first, self.chained) = (None, false);
}
}
#[derive(Default)]
+15 -1
View File
@@ -443,7 +443,21 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))),
}
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.open" => match p.get("importer").filter(|v| !v.is_null()) {
// The importer's parameters, else their defaults: automation gets no dialog asking for them.
Some(importer) => {
let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({}));
wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params})))
}
// `params` go to the importer File › Open picks for the file, as in `document.open`.
None => {
let mut open = json!({"path": s("path")});
if let Some(params) = p.get("params").filter(|v| !v.is_null()) {
open["params"] = params.clone();
}
wrap(app.run("file.open", open))
}
},
"app.save" | "app.export" => {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
// `wait: true`: the file is written before the reply, whatever Background Save and
+4
View File
@@ -126,6 +126,7 @@ pub mod symbolism_options;
pub mod tate_chu_yoko;
mod text_export;
pub mod text_import;
mod three_d_classic;
mod tiff_bmp_tga;
pub mod tile_edge_color;
mod tools;
@@ -399,6 +400,7 @@ registry! {
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
Plugin: [plugin::KIND] => plugin::SPEC,
PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC,
PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC,
ScriptCheck: [script_check::KIND] => script_check::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
@@ -824,6 +826,8 @@ mod tests_plugin_export;
#[cfg(test)]
mod tests_plugin_fields;
#[cfg(test)]
mod tests_plugin_import;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]
mod tests_svg_options_range;
+11 -3
View File
@@ -350,15 +350,20 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
/// The parameters whose dialogs list their values in their own bodies, as (label, value), each
/// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI
/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context
/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled
/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d"
/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's
/// orientation, Rasterize's Color Model (its `document` value labeled
/// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and
/// the channel of Levels and Curves.
pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> {
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(vectorcraft_effects::EXTRUDE, "bevel") => vec![
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => {
vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")],
(vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![
(tl!("None"), "none"),
(tl!("Classic"), "classic"),
(c3("Rounded"), "rounded"),
@@ -919,6 +924,9 @@ mod tests {
for (effect, key) in [
(vectorcraft_effects::REVOLVE, "edge"),
(vectorcraft_effects::EXTRUDE, "bevel"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"),
("warp.arc", "orientation"),
("warp.twist", "orientation"),
(vectorcraft_effects::RASTERIZE, "background"),
+1 -1
View File
@@ -28,7 +28,7 @@ const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
/// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins.
pub(super) fn formats() -> Vec<&'static Format> {
BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect()
BUILT_IN.iter().copied().chain(fileio::plugin_formats().into_iter().filter(|f| f.write)).collect()
}
/// Is `f` an exporter plug-in's format?
+47 -1
View File
@@ -10,7 +10,9 @@
//! its own is a submenu named after it, holding its item and then its commands; a command with
//! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export ›
//! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its
//! Export button writes the file.
//! Export button writes the file. Importers open the files File › Open finds no built-in format
//! for, and File › Open With lists them, to open a file with one of them by name; one with
//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it.
use std::sync::Arc;
@@ -76,6 +78,18 @@ pub fn object_menu() -> Vec<Item> {
items
}
/// File › Open With's header while no importer plug-in is installed.
pub const NO_IMPORTERS: &str = "No importer plug-ins installed";
/// File › Open With: the installed importers (`file.openWith {importer}`), each by its name.
pub fn open_with_menu() -> Vec<Item> {
let items: Vec<Item> = vectorcraft_plugins::import::installed()
.iter()
.map(|p| Item::Cmd(label(&p.manifest().name, true), "file.openWith", json!({ "importer": vectorcraft_plugins::export::format_id(p.id()) })))
.collect();
if items.is_empty() { vec![Item::Header(NO_IMPORTERS)] } else { items }
}
/// Effect › Plug-ins: the live-effect plug-ins, each with its commands if it has any (`None`
/// while none is installed). Effects without options apply at once, as built-in ones do.
pub fn effect_menu() -> Option<Item> {
@@ -105,6 +119,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Kind::Filter => {}
Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)),
Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)),
Kind::Importer => return Err(format!("{} is an importer (File › Open With)", m.name)),
}
let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
if m.params.is_empty() || given.is_some() {
@@ -264,3 +279,34 @@ fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
app.ui.dialog = None;
crate::io::export(app, format.as_deref(), path, &p)
}
/// The dialog kind of an importer's parameters, after Open With picked the file.
pub const IMPORT_KIND: &str = "pluginImport";
/// Headed by the plug-in's name, as it is; Open opens the file.
pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: export_body,
confirm: import_confirm,
ok: Some("Open"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Open With with importer `plugin`, which has parameters: its fields at their defaults, and
/// `open`, Open With's own parameters (`path`, `importer`, and `picked` for a file from the
/// picker, which is read off the UI thread), kept for Open.
pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) {
let mut fields = fields_for(plugin.manifest());
fields.insert("__open".into(), open);
app.ui.dialog = Some(Dialog { kind: IMPORT_KIND.into(), fields });
}
/// Opens the file with Open With's parameters and the plug-in's, as `params`.
fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?;
p["params"] = form::params(d);
app.ui.dialog = None;
let picked = p["picked"] == true;
crate::io::open_with_on(app, &p, picked && crate::opening::available())
}
+2 -2
View File
@@ -604,8 +604,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
// Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it.
if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() {
let r = crate::picks::in_dialog(app, d, |app, d| {
let pick =
crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() };
let filters = crate::FilePick::filters(vectorcraft_engine::cmd::fileio::builtin_open_filters());
let pick = crate::FilePick { filters, ..Default::default() };
let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) };
let r = app.run("swatch.library.load", json!({ "path": path }))?;
d.fields.insert("limitTo".into(), r["library"].clone());
+27 -19
View File
@@ -1,11 +1,12 @@
//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate.
//! The object and the rotation and light gizmos live on the artboard. All document changes run
//! through the effect dialog's existing preview/commit commands.
//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate,
//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the
//! rotation and light gizmos live on the artboard. All document changes run through the effect
//! dialog's existing preview/commit commands.
use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -140,14 +141,15 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog)
}
/// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join).
fn c3(s: &'static str) -> &'static str {
pub(super) fn c3(s: &'static str) -> &'static str {
crate::i18n::tr_ctx(crate::i18n::current(), "3d", s)
}
/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab).
/// The rotation gizmo shows on the first two, the light gizmo on Lighting.
/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs
/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on
/// Lighting.
fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let object = id == EXTRUDE || id == INFLATE;
let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE;
let mut tabs = vec![];
if object {
tabs.push(tl!("Object"));
@@ -167,10 +169,16 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
}
ui.add_space(8.0);
if super::revolve_gizmo::lighting(d) {
lighting_controls(ui, d);
if id == CLASSIC_EXTRUDE {
super::three_d_classic::surface_controls(ui, d);
} else {
lighting_controls(ui, d);
}
} else if object && d.str("__tab") != "rotation" {
let unit = app.session.general_unit();
if id == EXTRUDE {
if id == CLASSIC_EXTRUDE {
super::three_d_classic::object_controls(ui, d, unit);
} else if id == EXTRUDE {
extrude_controls(ui, d, unit);
} else {
heading(ui, tl!("Object"));
@@ -249,7 +257,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
}
/// A dropdown of `options` (value, label) for `key`, with its reset dot.
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
let id = d.str("__effect");
let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null);
// A mixed setting (null) shows its default, with its reset dot filled.
@@ -283,7 +291,7 @@ fn flag(d: &Dialog, key: &str, default: bool) -> Option<bool> {
}
/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on.
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
pub(super) fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
ui.horizontal(|ui| {
let on = flag(d, key, default);
if widgets::check3(ui, name, on, ui.is_enabled()) {
@@ -386,11 +394,11 @@ pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve {
vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d)))
}
fn light_record(d: &Dialog) -> Value {
pub(super) fn light_record(d: &Dialog) -> Value {
Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect())
}
fn store_selected(d: &mut Dialog, selected: &Dialog) {
pub(super) fn store_selected(d: &mut Dialog, selected: &Dialog) {
if let Some(list) = explicit_lights(d) {
let index = light_index(d, list.len());
let mut list = list.clone();
@@ -413,7 +421,7 @@ pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) {
store_selected(d, &selected);
}
fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
pub(super) fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]);
let mut index = light_index(d, list.len());
let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect();
@@ -483,12 +491,12 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) {
});
}
fn heading(ui: &mut egui::Ui, text: &str) {
pub(super) fn heading(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text));
}
fn label(ui: &mut egui::Ui, name: &str) {
pub(super) fn label(ui: &mut egui::Ui, name: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover());
ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name);
@@ -501,7 +509,7 @@ fn rail_width(ui: &egui::Ui) -> f32 {
/// A value can be typed, dragged, scrubbed by its label, or moved on its slider.
#[allow(clippy::too_many_arguments)]
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
let from = |n| unit.map_or(n, |u| u.from_pt(n));
let to = |n| unit.map_or(n, |u| u.to_pt(n));
@@ -591,7 +599,7 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
}
}
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
pub(super) fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default")));
@@ -297,6 +297,68 @@ fn the_other_3d_effects_show_their_options_and_position_presets() {
}
}
/// Extrude & Bevel (Classic): its extrude and bevel options on the Object tab, Position on the
/// Rotation tab, and the surface with its lights, highlight, blend steps and shading color on the
/// Lighting tab; OK keeps the edit as one undo step.
#[test]
fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() {
use vectorcraft_effects::CLASSIC_EXTRUDE;
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_EXTRUDE, vec2(1400.0, 1000.0));
let before = h.app.session.doc().unwrap().doc.clone();
h.settle();
for text in ["Depth", "Cap", "Bevel Shape", "Height", "Bevel Inside"] {
h.rect(text);
}
assert!(!h.texts.iter().any(|(t, _)| t == "Twist" || t == "Taper" || t == "Width"), "the newer effect's options stay out");
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelHeight"))).unwrap().enabled(), "no bevel: its height is off");
h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation"));
h.settle();
for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] {
h.rect(text);
}
h.click(h.rect("Lighting").center());
h.settle();
for text in [
"Surface",
"Plastic",
"Light Intensity",
"Ambient Light",
"Highlight Intensity",
"Highlight Size",
"Blend Steps",
"Shading Color",
"Draw Hidden Faces",
] {
h.rect(text);
}
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled());
// The light gizmo is on for a lit surface.
assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°.");
// Diffuse has no highlight; Wireframe and No Shading take no light.
h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("diffuse"));
h.settle();
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled());
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled());
h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("wireframe"));
h.settle();
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled());
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "drawHiddenFaces"))).unwrap().enabled());
assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type.");
// A custom shading color shows its color row.
let d = h.app.ui.dialog.as_mut().unwrap();
d.fields.insert("surface".into(), json!("diffuse"));
d.fields.insert("shadingColor".into(), json!("custom"));
h.settle();
h.rect("#000000");
dialogs::confirm(&mut h.app).unwrap();
let state = h.app.session.doc().unwrap();
let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0];
assert_eq!(fx.id, CLASSIC_EXTRUDE);
assert_eq!((fx.params["surface"].clone(), fx.params["shadingColor"].clone()), (json!("diffuse"), json!("custom")));
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc().unwrap().doc, before);
}
#[test]
fn extrude_bevel_options_enable_with_a_bevel_shape() {
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0));
+12 -2
View File
@@ -20,8 +20,18 @@ pub(crate) fn lighting(d: &Dialog) -> bool {
d.str("__gizmo") == "light"
}
/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic
/// surface?
fn lit(d: &Dialog) -> bool {
if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE {
matches!(d.str("surface").as_str(), "diffuse" | "plastic")
} else {
d.bool("shade")
}
}
pub(crate) fn hint(d: &Dialog) -> &'static str {
if !d.bool("preview") || (lighting(d) && !d.bool("shade")) {
if !d.bool("preview") || (lighting(d) && !lit(d)) {
tl!("Drag values or labels. Double-click a value to type.")
} else if lighting(d) {
tl!("Drag the light, or its rings. Shift snaps to 15°.")
@@ -190,7 +200,7 @@ fn snap_height(height: f64, snap: bool) -> f64 {
pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
let light_mode = lighting(d);
if !d.bool("preview") || (light_mode && !d.bool("shade")) {
if !d.bool("preview") || (light_mode && !lit(d)) {
clear(ctx);
return;
}
@@ -0,0 +1,292 @@
//! Importer plug-ins in File › Open With and File › Open: listed by name under Open With, which
//! opens a file with the importer also when a built-in format reads its extension, and a file only
//! an importer reads is not read past the importer's input limit. Each test installs importers of
//! its own and removes them: plug-ins are process-wide, and an importer left installed would show
//! in other tests' Open dialog.
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat::{self as tpl, Given};
use super::tests_export::{frame, kind, set};
use super::*;
use crate::Services;
use crate::menus::Item;
/// An importer's output: one rectangle from (10, 20) to (110, 70).
const RECT: &str =
r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#;
/// An app that reads files from disk, refusing to read one past the importers' input limit.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.services.read = Some(Box::new(|p: &str| match std::fs::metadata(p) {
Ok(m) if m.len() > vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 => Err("read past the limit".into()),
_ => std::fs::read(p).map_err(|e| e.to_string()),
}));
app
}
fn install(app: &mut VectorcraftApp, src: &str) {
let wasm = wat::parse_str(src).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
}
/// A fresh folder for files this test writes.
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-ui-plugin-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The active document's first artboard, as `(x0, y0, x1, y1)`.
fn artboard(app: &VectorcraftApp) -> (f64, f64, f64, f64) {
let r = app.session.doc().unwrap().doc.artboards[0].rect;
(r.x0, r.y0, r.x1, r.y1)
}
/// Open With lists an importer by name; it opens an `.svg` with the importer, where File › Open
/// keeps the built-in SVG reader.
#[test]
fn open_with_lists_importers_and_opens_a_file_with_one() {
let dir = tmp_dir("with");
let mut app = app();
install(&mut app, &tpl::returning(&tpl::importer("test.ui.svgreader", r#"["svg","tui"]"#, "{}"), RECT));
let menu = plugin::open_with_menu();
let listed = menu.iter().any(|i| match i {
Item::Cmd(label, "file.openWith", p) => *label == "Test test.ui.svgreader…" && p["importer"] == "plugin.test.ui.svgreader",
_ => false,
});
assert!(listed, "{menu:?}");
let svg = dir.join("a.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="10" height="10"/></svg>"#).unwrap();
let svg = svg.to_string_lossy().into_owned();
crate::io::open_path(&mut app, &svg).unwrap();
assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader");
app.ui.recent_files.clear();
let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap();
assert_eq!(r["format"], "plugin.test.ui.svgreader");
assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer");
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.ui.recent_files, std::slice::from_ref(&svg), "Open With adds the file to Open Recent");
// `app.open {importer}` (MCP's open_file) opens as Open With does.
let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"}));
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
assert_eq!(r["result"]["format"], "plugin.test.ui.svgreader", "{r}");
assert_eq!(app.session.documents().len(), 3);
// An importer is not a filter: Object › Plug-ins' command says where it is.
let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err();
assert!(e.contains("is an importer (File › Open With)"), "{e}");
let e = app.run("file.openWith", json!({"path": svg})).unwrap_err();
assert!(e.contains("missing `importer`"), "{e}");
app.run("plugin.remove", json!({"id": "test.ui.svgreader"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// Open With asks for an importer's parameters, at their defaults, once the file is picked, and
/// opens the file with the values set; given `params`, it asks nothing, and `app.open` without
/// them runs the importer on their defaults.
#[test]
fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() {
let dir = tmp_dir("params");
let mut app = app();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
// RECT when the parameters hold density 7; the file, which isn't JSON, otherwise.
install(&mut app, &tpl::when_given(&tpl::importer("test.ui.importparams", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT));
let file = dir.join("a.tup");
std::fs::write(&file, "not json").unwrap();
let file = file.to_string_lossy().into_owned();
app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap();
assert_eq!(kind(&app), Some(plugin::IMPORT_KIND));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.importparams".to_string(), Some(&json!(4.0))));
assert!(app.session.documents().is_empty(), "nothing opened before Open");
frame(&mut app);
set(&mut app, "density", json!(7));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), 1);
assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0));
assert_eq!(app.ui.recent_files, std::slice::from_ref(&file));
// Only a file from the picker goes off the UI thread after the dialog: a caller naming `path`
// gets the document.
app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], false);
app.ui.dialog = None;
let picked = file.clone();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], true);
app.ui.dialog = None;
let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file, "params": {"density": 7}})).unwrap();
assert_eq!(r["format"], "plugin.test.ui.importparams");
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), 2);
// `app.open` (MCP's open_file) passes `params` on, and asks nothing without them.
let control = |app: &mut VectorcraftApp, p: serde_json::Value| {
let (req, _rx) = crate::control::ControlRequest::new("app.open", p);
let crate::control::Outcome::Done(r) = crate::control::handle(app, &egui::Context::default(), &req) else { panic!("not done") };
r
};
let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams", "params": {"density": 7}}));
assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}");
assert_eq!(app.session.documents().len(), 3);
let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams"}));
assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}");
assert_ne!(kind(&app), Some(plugin::IMPORT_KIND));
assert_eq!(app.session.documents().len(), 3);
// Without `importer`, `params` go to the importer File › Open picks for the file.
let other = dir.join("b.tup");
std::fs::write(&other, "not json").unwrap();
let other = other.to_string_lossy().into_owned();
let r = control(&mut app, json!({"path": other, "params": {"density": 7}}));
assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}");
assert_eq!(app.session.documents().len(), 4);
app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// File › Open's dialog lists an importer's files; the open dialogs whose files only the built-in
/// readers read (New from Template, Load Styles, the library panels' Other Library…) don't.
#[test]
fn only_file_open_s_dialog_lists_an_importer_s_files() {
let mut app = app();
install(&mut app, &tpl::module(&tpl::importer("test.ui.filters", r#"["tuf"]"#, "{}"), tpl::ECHO, "", 1));
let asked: std::rc::Rc<std::cell::RefCell<Vec<crate::FilePick>>> = Default::default();
let seen = asked.clone();
app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| {
seen.borrow_mut().push(pick.clone());
None
}));
let lists = |filters: &[(String, Vec<String>)]| filters.iter().any(|(_, exts)| exts.iter().any(|e| e == "tuf"));
let shown = |app: &mut VectorcraftApp, cmd: &str| {
let _ = app.run(cmd, json!({}));
let pick = asked.borrow_mut().pop().unwrap_or_else(|| panic!("{cmd} showed no open dialog"));
lists(&pick.filters)
};
assert!(shown(&mut app, "file.open"), "File › Open");
for cmd in ["file.newFromTemplate", "ui.loadCharStyles", "ui.loadParaStyles", "ui.loadAllStyles"] {
assert!(!shown(&mut app, cmd), "{cmd}");
}
for kind in ["swatches", "graphicStyles", "symbols"] {
let filters = crate::FilePick::filters(crate::panels::library_panel::library_pick_filters(kind));
assert!(!lists(&filters), "{kind}");
}
app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap();
}
/// Export As lists an installed exporter and not an installed importer, whose format can only be
/// read, and it does not preset the importer's format.
#[test]
fn export_as_lists_exporters_and_not_importers() {
let mut app = app();
install(&mut app, &tpl::module(&tpl::importer("test.ui.readonly", r#"["tur"]"#, "{}"), tpl::ECHO, "", 1));
install(&mut app, &tpl::module(&tpl::exporter("test.ui.writes", r#"["tuw"]"#, "{}"), tpl::ECHO, "", 1));
let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect();
assert!(listed.contains(&"plugin.test.ui.writes"), "{listed:?}");
assert!(!listed.contains(&"plugin.test.ui.readonly"), "{listed:?}");
app.run("file.exportAs", json!({"format": "plugin.test.ui.readonly"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "png");
app.run("plugin.remove", json!({"id": "test.ui.readonly"})).unwrap();
app.run("plugin.remove", json!({"id": "test.ui.writes"})).unwrap();
}
/// A file only an importer reads opens wherever it is dropped, also when an exporter lists its
/// extension; a file File › Place reads is still placed on the canvas.
#[test]
fn a_dropped_file_only_an_importer_reads_opens() {
use crate::place::DropTarget;
let mut app = app();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.sync_views();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)));
let on_canvas = Some(egui::pos2(400.0, 300.0));
let placed = |app: &VectorcraftApp, name, pos| matches!(app.drop_target(name, pos, false), DropTarget::Place(_));
assert!(placed(&app, "a.tud", on_canvas), "no importer lists the extension yet");
install(&mut app, &tpl::module(&tpl::importer("test.ui.dropped", r#"["tud","tus","png"]"#, "{}"), tpl::ECHO, "", 1));
install(&mut app, &tpl::module(&tpl::exporter("test.ui.dropshare", r#"["tus"]"#, "{}"), tpl::ECHO, "", 1));
for name in ["a.tud", "A.TUD", "a.tus"] {
assert_eq!(app.drop_target(name, on_canvas, false), DropTarget::Open, "{name} on the canvas");
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name} without a position");
}
assert!(placed(&app, "a.png", on_canvas), "File › Place reads the extension");
assert!(placed(&app, "a.png", None));
app.run("plugin.remove", json!({"id": "test.ui.dropped"})).unwrap();
app.run("plugin.remove", json!({"id": "test.ui.dropshare"})).unwrap();
}
/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the
/// ones File › Open installs, imports, checks or loads as something other than a document.
#[test]
fn importer_filters_leave_out_what_file_open_opens_as_something_else() {
use vectorcraft_engine::cmd::{self, fileio};
let lists: [&[&str]; 11] = [
cmd::plugin::EXTS,
cmd::shortcutset::EXTS,
cmd::scripts::EXTS,
cmd::flatten::PRESET_EXTS,
cmd::pdfcmds::PRESET_EXTS,
cmd::printpresets::PRESET_EXTS,
cmd::perspgrid::PRESET_EXTS,
fileio::SCREEN_EXPORT_PRESET_EXTS,
cmd::compositefonts::EXTS,
cmd::library::LIBRARY_EXTS,
cmd::swatchlib::LIBRARY_EXTS,
];
for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) {
assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}");
assert!(fileio::opens_as_other(ext), "{ext}");
}
for ext in ["svg", "tcl", "pdf"] {
assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}");
}
// File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there).
let mut app = app();
install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), tpl::ECHO, "", 1));
let asked: std::rc::Rc<std::cell::RefCell<Option<crate::FilePick>>> = Default::default();
let seen = asked.clone();
app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| {
*seen.borrow_mut() = Some(pick.clone());
None
}));
let _ = app.run("file.open", json!({}));
let pick = asked.borrow_mut().take().expect("File › Open showed its dialog");
let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone());
assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters);
assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]);
app.run("plugin.remove", json!({"id": "test.ui.claimed"})).unwrap();
}
/// File › Open reads a file of an extension only an importer lists, and refuses one past the
/// importer's input limit before reading it, as Open With does.
#[test]
fn file_open_refuses_an_importer_s_file_past_the_limit_before_reading_it() {
let dir = tmp_dir("limit");
let mut app = app();
install(&mut app, &tpl::module(&tpl::importer("test.ui.big", r#"["tub"]"#, "{}"), tpl::ECHO, "", 1));
let small = dir.join("small.tub");
std::fs::write(&small, RECT).unwrap();
crate::io::open_path(&mut app, &small.to_string_lossy()).unwrap();
assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0));
let big = dir.join("big.tub");
std::fs::File::create(&big).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap();
let big = big.to_string_lossy().into_owned();
let e = crate::io::open_path(&mut app, &big).unwrap_err();
assert!(e.contains("larger than"), "{e}");
let e = app.run("file.openWith", json!({"importer": "plugin.test.ui.big", "path": big})).unwrap_err();
assert!(e.contains("larger than"), "{e}");
assert_eq!(app.session.documents().len(), 1);
app.run("plugin.remove", json!({"id": "test.ui.big"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,85 @@
//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options
//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on
//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect
//! shares.
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_effects::CLASSIC_EXTRUDE;
use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle};
use crate::state::Dialog;
/// Depth and cap, then the bevel: its shape, its height and whether it is added around the shape
/// or carved into it.
pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
heading(ui, tl!("Object"));
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
ui.add_space(10.0);
heading(ui, c3("Bevel"));
choice(ui, d, "bevel", tl!("Bevel Shape"), &options("bevel"));
ui.add_enabled_ui(d.str("bevel") != "none", |ui| {
number(ui, d, "bevelHeight", tl!("Height"), (1.0, 100.0), "", 1, Some(unit));
toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false);
});
}
/// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic
/// only), Blend Steps and Shading Color; Draw Hidden Faces last.
pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) {
choice(ui, d, "surface", tl!("Surface"), &options("surface"));
let surface = d.str("surface");
ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| {
light_list(ui, d);
let mut selected = selected_light(d);
let before = light_record(&selected);
number(ui, &mut selected, "lightIntensity", tl!("Light Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false);
if light_record(&selected) != before {
store_selected(d, &selected);
}
number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None);
ui.add_enabled_ui(surface == "plastic", |ui| {
number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None);
});
number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None);
choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor"));
if d.str("shadingColor") == "custom" {
shading_custom(ui, d);
}
});
toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false);
}
/// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label).
fn options(key: &str) -> Vec<(Value, &'static str)> {
super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect()
}
/// The custom shading color: a color button, its hex value and its reset dot.
fn shading_custom(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Color"));
let color = d
.fields
.get("shadingCustom")
.and_then(Value::as_str)
.filter(|s| s.len() == 7 && s.starts_with('#'))
.and_then(vectorcraft_color::Color::from_hex)
.unwrap_or(vectorcraft_color::Color::BLACK);
let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8);
if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Shading Color")).changed() {
d.fields.insert("shadingCustom".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]));
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shadingCustom", rgb != [0; 3], "#000000") {
d.fields.insert("shadingCustom".into(), json!("#000000"));
}
});
}
+20
View File
@@ -1102,6 +1102,17 @@ font-technology Variable Променливи
Exporting {} Експортиране на {}
Export… Експортиране…
Extrude & Bevel… Избутване и фасетка…
3D (Classic) 3D (класически)
Extrude & Bevel (Classic)… Избутване и фасетка (класически)…
Surface Повърхност
Wireframe Телена рамка
No Shading Без засенчване
Plastic Пластмаса
Highlight Intensity Интензивност на отблясъка
Highlight Size Размер на отблясъка
Blend Steps Стъпки на преливане
Shading Color Цвят на засенчването
Draw Hidden Faces Изчертаване на скритите страни
Eyedropper Пипета
Eyedropper Applies: Пипетата прилага:
Eyedropper Options Опции за пипетата
@@ -1915,6 +1926,10 @@ font-technology Variable Променливи
Stylistic Стилистично
Mojikumi Set Набор Mojikumi
Kinsoku Set Набор Kinsoku
Kinsoku Shori Type Тип Kinsoku Shori
Push In First Първо прибиране
Push Out First Първо изтласкване
Push Out Only Само изтласкване
Hard Твърдо
Soft Меко
Monitor RGB RGB на монитора
@@ -6377,6 +6392,11 @@ breakText Glyphs Глифи
3D Box 3D кутия
3D Box Tool Инструмент за 3D кутия
# Importer plug-ins (File › Open With, document.open)
Open With Отваряне с
No importer plug-ins installed Няма инсталирани плъгини за импортиране
Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях
# Safe Mode
Safe Mode… Безопасен режим…
Safe Mode Безопасен режим
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -466,6 +466,17 @@
Inflate Napuhni
Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat.
Extrude & Bevel… Istiskivanje i zakošenje…
3D (Classic) 3D (klasično)
Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)…
Surface Površina
Wireframe Žičani model
No Shading Bez sjenčenja
Plastic Plastika
Highlight Intensity Intenzitet odsjaja
Highlight Size Veličina odsjaja
Blend Steps Koraci prelaza
Shading Color Boja sjenčenja
Draw Hidden Faces Crtaj skrivene strane
Revolve… Rotaciono tijelo…
Inflate… Napuhni…
Materials… Materijali…
@@ -1894,6 +1905,10 @@ digits Malayalam Malajalam
Stylistic Stilsko
Mojikumi Set Mojikumi skup
Kinsoku Set Kinsoku skup
Kinsoku Shori Type Vrsta Kinsoku Shori
Push In First Prvo uvlačenje
Push Out First Prvo izbacivanje
Push Out Only Samo izbacivanje
Hard Tvrdo
Right Indent Desna uvlaka
Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo
@@ -6427,6 +6442,11 @@ shape Open Otvoren
3D Box 3D kutija
3D Box Tool Alat za 3D kutiju
# Importer plug-ins (File › Open With, document.open)
Open With Otvori pomoću
No importer plug-ins installed Nisu instalirani dodaci za uvoz
Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente
# Safe Mode
Safe Mode… Sigurni način rada…
Safe Mode Sigurni način rada
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -1518,6 +1518,17 @@ antiAlias Sharp Nítid
Exporting {} S’està exportant {}
Export… Exporta…
Extrude & Bevel… Extrudeix i bisella…
3D (Classic) 3D (clàssic)
Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)…
Surface Superfície
Wireframe Filferro
No Shading Sense ombra
Plastic Plàstic
Highlight Intensity Intensitat de la brillantor
Highlight Size Mida de la brillantor
Blend Steps Passos de fusió
Shading Color Color de l’ombra
Draw Hidden Faces Dibuixa les cares ocultes
Eyedropper Comptagotes
Eyedropper Applies: El comptagotes aplica:
Eyedropper Options Opcions del comptagotes
@@ -2125,6 +2136,7 @@ digits Malayalam Malaiàlam
Keywords: Paraules clau:
Kind Tipus
Kinsoku Set Conjunt Kinsoku
Kinsoku Shori Type Tipus de Kinsoku Shori
Kite Estel
Knife Ganivet
Knockout Group Grup knockout
@@ -3195,6 +3207,9 @@ digits Malayalam Malaiàlam
Puppet Warp Pins Pins de deformació de titella
Puppet Warp Tool Eina de deformació de titella
Purple Porpra
Push In First Primer cap a dins
Push Out First Primer cap a fora
Push Out Only Només cap a fora
Put back in the toolbar Torna a posar a la barra d’eines
Quality Qualitat
Quality {quality} Qualitat {quality}
@@ -6557,6 +6572,24 @@ breakText Glyphs Glifs
3D Box Caixa 3D
3D Box Tool Eina Caixa 3D
# Importer plug-ins (File › Open With, document.open)
Open With Obre amb
No importer plug-ins installed No hi ha complements d’importació instal·lats
Read files you open in its format and make documents from them Llegir els fitxers que obriu en el seu format i fer-ne documents
@msg {_1} is an importer (File › Open With) {_1} és un importador (Fitxer › Obre amb)
@msg missing `importer` (see document.formats) falta `importer` (vegeu document.formats)
@msg `importer` must be a plug-in format id (see document.formats) `importer` ha de ser l’id de format d’un complement (vegeu document.formats)
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}`
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}}
@msg the importer returned no objects l’importador no ha retornat cap objecte
@msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}`
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un importador declara d’1 a {MAX_EXTENSIONS} `extensions`
@msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions`
@msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget`
# Safe Mode
Safe Mode… Mode segur…
Safe Mode Mode segur
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -475,6 +475,17 @@
Inflate Nafouknutí
Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt.
Extrude & Bevel… Vysunutí a zkosení…
3D (Classic) 3D (klasické)
Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)…
Surface Povrch
Wireframe Drátový model
No Shading Bez stínování
Plastic Plast
Highlight Intensity Intenzita odlesku
Highlight Size Velikost odlesku
Blend Steps Kroky prolnutí
Shading Color Barva stínování
Draw Hidden Faces Kreslit skryté stěny
Revolve… Rotační těleso…
Inflate… Nafouknutí…
Materials… Materiály…
@@ -1921,6 +1932,10 @@ digits Malayalam Malajálamské
Stylistic Stylistický
Mojikumi Set Sada Mojikumi
Kinsoku Set Sada Kinsoku
Kinsoku Shori Type Typ Kinsoku Shori
Push In First Nejprve vtáhnout
Push Out First Nejprve vysunout
Push Out Only Pouze vysunout
Hard Pevné
Right Indent Odsazení zprava
Right-to-Left Paragraph Direction Směr odstavce zprava doleva
@@ -6575,6 +6590,24 @@ breakText Glyphs Glyfy
3D Box 3D kvádr
3D Box Tool Nástroj 3D kvádr
# Importer plug-ins (File › Open With, document.open)
Open With Otevřít pomocí
No importer plug-ins installed Nejsou nainstalovány žádné importní zásuvné moduly
Read files you open in its format and make documents from them Číst soubory, které otevřete v jeho formátu, a vytvářet z nich dokumenty
@msg {_1} is an importer (File › Open With) {_1} je importní zásuvný modul (Soubor › Otevřít pomocí)
@msg missing `importer` (see document.formats) chybí `importer` (viz document.formats)
@msg `importer` must be a plug-in format id (see document.formats) `importer` musí být ID formátu zásuvného modulu (viz document.formats)
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}`
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` je zásuvný modul druhu `{_2}`, ne importní zásuvný modul
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` je zásuvný modul druhu `{_2}`, ne exportní zásuvný modul
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}}
@msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty
@msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}`
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads soubor je větší než {MAX_INPUT_BYTES} bajtů, které importní zásuvný modul čte
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` importní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions`
@msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul
@msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget`
# Safe Mode
Safe Mode… Nouzový režim…
Safe Mode Nouzový režim
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -433,6 +433,17 @@
Inflate Pust op
Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt.
Extrude & Bevel… Ekstrudér og facet…
3D (Classic) 3D (klassisk)
Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)…
Surface Overflade
Wireframe Trådmodel
No Shading Ingen skyggelægning
Plastic Plastik
Highlight Intensity Højlysintensitet
Highlight Size Højlysstørrelse
Blend Steps Overgangstrin
Shading Color Skyggefarve
Draw Hidden Faces Tegn skjulte flader
Revolve… Omdrej…
Inflate… Pust op…
Materials… Materialer…
@@ -1727,6 +1738,10 @@ digits Malayalam Malayalam
Stylistic Stilistisk
Mojikumi Set Mojikumi-sæt
Kinsoku Set Kinsoku-sæt
Kinsoku Shori Type Kinsoku Shori-type
Push In First Skub ind først
Push Out First Skub ud først
Push Out Only Kun skub ud
Hard Hård
Right Indent Højre indrykning
Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre
@@ -6391,6 +6406,11 @@ breakText Glyphs Glyffer
3D Box 3D-boks
3D Box Tool 3D-boksværktøj
# Importer plug-ins (File › Open With, document.open)
Open With Åbn med
No importer plug-ins installed Ingen import-plug-ins installeret
Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem
# Safe Mode
Safe Mode… Sikker tilstand…
Safe Mode Sikker tilstand
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -1107,6 +1107,17 @@ font-technology Variable Variabel
Exporting {} {} wird exportiert
Export… Exportieren…
Extrude & Bevel… Extrudieren und abgeflachte Kante…
3D (Classic) 3D (klassisch)
Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)…
Surface Oberfläche
Wireframe Drahtmodell
No Shading Keine Schattierung
Plastic Kunststoff
Highlight Intensity Glanzpunktintensität
Highlight Size Glanzpunktgröße
Blend Steps Angleichungsstufen
Shading Color Schattierungsfarbe
Draw Hidden Faces Verdeckte Flächen zeichnen
Eyedropper Pipette
Sample Color from Screen Farbe vom Bildschirm aufnehmen
This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm
@@ -1982,6 +1993,10 @@ file-filter Templates Vorlagen
Stylistic Stilistisch
Mojikumi Set Mojikumi-Satz
Kinsoku Set Kinsoku-Satz
Kinsoku Shori Type Kinsoku-Shori-Typ
Push In First Zuerst einziehen
Push Out First Zuerst hinausschieben
Push Out Only Nur hinausschieben
Hard Hart
Soft Weich
Monitor RGB Monitor-RGB
@@ -6161,6 +6176,24 @@ breakText Glyphs Glyphen
3D Box 3D-Quader
3D Box Tool 3D-Quader-Werkzeug
# Importer plug-ins (File › Open With, document.open)
Open With Öffnen mit
No importer plug-ins installed Keine Import-Plug-ins installiert
Read files you open in its format and make documents from them Dateien lesen, die Sie in seinem Format öffnen, und daraus Dokumente erstellen
@msg {_1} is an importer (File › Open With) {_1} ist ein Import-Plug-in (Datei › Öffnen mit)
@msg missing `importer` (see document.formats) `importer` fehlt (siehe document.formats)
@msg `importer` must be a plug-in format id (see document.formats) `importer` muss die Format-ID eines Plug-ins sein (siehe document.formats)
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}`
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` ist ein Plug-in der Art `{_2}`, kein Import-Plug-in
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` ist ein Plug-in der Art `{_2}`, kein Export-Plug-in
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}}
@msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben
@msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}`
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads die Datei ist größer als die {MAX_INPUT_BYTES} Byte, die ein Import-Plug-in liest
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ein Import-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an
@msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an
@msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg
# Safe Mode
Safe Mode… Abgesicherter Modus…
Safe Mode Abgesicherter Modus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+33
View File
@@ -1080,6 +1080,17 @@ font-technology Variable Μεταβλητές
Exporting {} Εξαγωγή {}
Export… Εξαγωγή…
Extrude & Bevel… Εξώθηση και λοξότμηση…
3D (Classic) 3D (κλασικό)
Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)…
Surface Επιφάνεια
Wireframe Συρματόπλεγμα
No Shading Χωρίς σκίαση
Plastic Πλαστικό
Highlight Intensity Ένταση ανταύγειας
Highlight Size Μέγεθος ανταύγειας
Blend Steps Βήματα ανάμειξης
Shading Color Χρώμα σκίασης
Draw Hidden Faces Σχεδίαση κρυφών εδρών
Eyedropper Σταγονόμετρο (Eyedropper Tool)
Eyedropper Applies: Το σταγονόμετρο εφαρμόζει:
Eyedropper Options Επιλογές σταγονόμετρου
@@ -2778,6 +2789,10 @@ theme Appearance Εμφάνιση
Stylistic Στιλιστικό
Mojikumi Set Σύνολο Mojikumi
Kinsoku Set Σύνολο Kinsoku
Kinsoku Shori Type Τύπος Kinsoku Shori
Push In First Πρώτα προς τα μέσα
Push Out First Πρώτα προς τα έξω
Push Out Only Μόνο προς τα έξω
Mono Μονοφωνικό
Monochrome images Μονόχρωμες εικόνες
Monospaced Σταθερού πλάτους
@@ -6527,6 +6542,24 @@ breakText Glyphs Γλύφοι
3D Box Κουτί 3D
3D Box Tool Εργαλείο κουτιού 3D
# Importer plug-ins (File › Open With, document.open)
Open With Άνοιγμα με
No importer plug-ins installed Δεν έχουν εγκατασταθεί πρόσθετα εισαγωγής
Read files you open in its format and make documents from them Ανάγνωση των αρχείων που ανοίγετε στη μορφή του και δημιουργία εγγράφων από αυτά
@msg {_1} is an importer (File › Open With) το {_1} είναι πρόσθετο εισαγωγής (Αρχείο › Άνοιγμα με)
@msg missing `importer` (see document.formats) λείπει το `importer` (δείτε document.formats)
@msg `importer` must be a plug-in format id (see document.formats) το `importer` πρέπει να είναι αναγνωριστικό μορφής πρόσθετου (δείτε document.formats)
@msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}`
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εισαγωγής
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εξαγωγής
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}}
@msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο
@msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}`
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads το αρχείο είναι μεγαλύτερο από τα {MAX_INPUT_BYTES} byte που διαβάζει ένα πρόσθετο εισαγωγής
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εισαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions`
@msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions`
@msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget`
# Safe Mode
Safe Mode… Ασφαλής λειτουργία…
Safe Mode Ασφαλής λειτουργία
Can't render this file because it is too large.

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