Merge main into feat/outline-object-image-alpha

This commit is contained in:
dotCooCoo
2026-11-12 06:33:36 -08:00
128 changed files with 4396 additions and 711 deletions
+1
View File
@@ -651,6 +651,7 @@ mod portable {
impl Answer {
/// The answer to `request` when it couldn't be read: `why`, or no files.
#[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn failed(request: Request, why: String) -> Self {
match request {
Request::Text => Self::Text(Err(why)),
+2 -2
View File
@@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: false,
options: Default::default(),
@@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() {
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: true,
options: Default::default(),
+1 -1
View File
@@ -267,7 +267,7 @@ pub struct Document {
/// they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
/// File → File Info (the title is [`Document::title`]).
/// File → File Info (its title is [`Document::info_title`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub metadata: DocMetadata,
/// Effect → Document Raster Effects Settings besides the resolution
+197 -3
View File
@@ -60,12 +60,12 @@ pub use envelope::{
pub(crate) use expand::max_outset;
pub use expand::{
Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live,
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version};
#[cfg(test)]
mod tests {
@@ -132,6 +132,200 @@ mod tests {
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
/// Each warp style's Bézier net, at points worked out by a separate implementation of the same
/// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) →
/// the point in the box (origin at its top left, y down).
#[test]
fn warp_nets_bend_each_style_as_its_own_patch() {
#[rustfmt::skip]
let samples = [
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045),
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443),
("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666),
("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371),
("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712),
("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703),
("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585),
("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631),
("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44),
("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792),
("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352),
("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48),
("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472),
("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68),
("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68),
("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224),
("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769),
];
for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples {
let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") };
let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v);
assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})");
}
}
/// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half
/// circle through the bottom corners about the bottom's middle, and the top edge the one 100
/// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle.
#[test]
fn warp_net_arc_edges_are_concentric_circles() {
let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false);
let center = Point::new(100.0, 100.0);
for i in 0..=20 {
let u = f64::from(i) / 20.0;
for (v, r) in [(0.0, 200.0), (1.0, 100.0)] {
let d = net.at(u, v).distance(center);
assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}");
// Its ends and middle lie on the circle.
if i % 10 == 0 {
assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}");
}
}
}
// Bend 0 leaves the box as it is.
for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] {
let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false);
assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}");
}
}
/// The horizontal distortion scales each column of the net about its middle (narrowing the left
/// side for a positive value), then the vertical one each row (narrowing the top).
#[test]
fn warp_net_distortion_scales_the_columns_then_the_rows() {
let corners = |dh: f64, dv: f64| {
let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false);
[net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)]
};
let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9);
assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0));
assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5));
// Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and
// it then scales by 1.4 about its middle, (100, 0).
assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4));
}
/// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number
/// counts as 0, and out of range as its limit; a size that isn't a number as 0.
#[test]
fn warp_net_takes_any_numbers() {
for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] {
for (b, dh, w, h) in
[(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)]
{
for vertical in [false, true] {
let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6);
assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}");
}
}
}
let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false);
assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false));
}
/// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only
/// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's
/// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number,
/// gives finite points inside the box and past it, and the unit square's points scaled up where
/// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite
/// width and a height of 1e-300 or 100 every y is the unit square's times the height, also
/// where x goes past the largest finite number.
#[test]
fn warp_net_bends_a_huge_box_without_overflowing() {
let w = 1e308;
let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false);
let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false);
for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] {
let (p, q) = (flag.at(u, v), small.at(u, v));
assert!(p.is_finite(), "({u}, {v}): {p:?}");
assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}");
assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}");
}
let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false);
assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300));
let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false);
for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] {
let p = fisheye.at(u, v);
assert!(p.is_finite(), "({u}, {v}): {p:?}");
}
let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false);
let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE);
let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1));
assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}");
// The bottom of a box with a subnormal height stays exactly at that height: the net is
// built on the box halved, and the height's lowest bit but one is set.
let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2);
let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0);
assert_eq!(sub.y, low, "{sub:?}");
for h in [1e-300, 100.0] {
let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false);
for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] {
let (p, q) = (tall.at(u, v), unit.at(u, v));
assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}");
}
}
}
/// Content with no width or no height takes the middle of a warp envelope's patch along that
/// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight.
#[test]
fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() {
let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let arc = warp("arc");
let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0));
for y in [0.0, 30.0, 100.0] {
let p = map(Point::new(100.0, y));
assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}");
}
let bulge = warp("bulge");
let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0));
for x in [0.0, 70.0, 200.0] {
let p = map(Point::new(x, 50.0));
assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}");
}
}
/// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written
/// as none, so files keep their bytes, and the nets' version is written.
#[test]
fn warp_envelopes_write_their_map_version_only_when_it_has_one() {
let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap();
assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}");
assert!(!serde_json::to_string(&old).unwrap().contains("version"));
let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let text = serde_json::to_string(&new).unwrap();
assert!(text.contains(r#""version":1"#), "{text}");
assert_eq!(serde_json::from_str::<EnvelopeKind>(&text).unwrap(), new);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
@@ -537,7 +731,7 @@ mod tests {
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
+8
View File
@@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -19,6 +23,10 @@ pub enum EnvelopeKind {
v: f64,
#[serde(default = "yes")]
horizontal: bool,
/// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net,
/// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point).
#[serde(default, skip_serializing_if = "is_zero")]
version: u32,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
+34 -13
View File
@@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
a.is_finite().then_some(a)
}
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`]
/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's
/// options say).
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
// pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
// is left out.
if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
// An image an inner envelope already cut into pieces is cut again here, so the pieces count
// against [`MAX_GENERATED`]. Past it, the image is left out.
if !Generating::charge(mesh_warp_cost(k)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency)
}
/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two
/// triangles a cell, three objects each.
pub fn mesh_warp_cost(cells: usize) -> usize {
let k = cells.max(1);
k.saturating_mul(k).saturating_mul(6)
}
/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into
/// `cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name,
/// transparency, opacity mask and appearance.
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
let k = cells.max(1);
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
for j in 0..k {
for i in 0..k {
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
let d = s.map(|p| (w.f)(im.xf * p));
let d = s.map(|p| f(im.xf * p));
for t in [[0, 1, 2], [0, 2, 3]] {
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
let Some(xf) = affine_between(st, dt) else { continue };
@@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
.collect();
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
image.extras_mut().pixelated = pixelated;
image.extras_mut().colorize = colorize.clone();
if masked {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
@@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
}
let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
if let Some(e) = g.extras_if_any() {
e.colorize = None;
}
g
}
+11 -2
View File
@@ -1,5 +1,5 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point};
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
@@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64)
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
if uses_net(*version) {
let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal);
// Content with no width or no height takes the middle of the patch along that side.
let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0);
return Box::new(move |u: f64, v: f64| {
let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v });
frame * (src.origin() + net.at(u, v).to_vec2())
});
}
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
+331
View File
@@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6
(x2, y2)
}
// =====================================================================================
// Warp nets: each style as one Bézier patch over its box
// =====================================================================================
/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With
/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep
/// their look; new and imported warps are given this one.
pub const WARP_NET: u32 = 1;
/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws
/// with the newest map it has.
pub fn uses_net(version: u32) -> bool {
version >= WARP_NET
}
/// The map version `v` names when it is one this build draws: a whole number from 0 to
/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`).
pub fn warp_version(v: &serde_json::Value) -> Option<u32> {
let n = v.as_f64()?;
(n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32)
}
/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row
/// from the top, in the box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug)]
struct Net {
rows: usize,
cols: usize,
p: [[Point; 4]; 4],
}
impl Net {
/// The undistorted box `w` × `h` as a `rows` × `cols` net.
fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self {
let mut p = [[Point::ZERO; 4]; 4];
for (r, row) in p.iter_mut().enumerate().take(rows) {
for (c, q) in row.iter_mut().enumerate().take(cols) {
*q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64));
}
}
Self { rows, cols, p }
}
/// Upside down within a box `h` high: the rows in reverse order, each point mirrored.
fn mirrored(self, h: f64) -> Self {
let mut out = self;
for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) {
for (q, f) in to.iter_mut().zip(from).take(self.cols) {
*q = Point::new(f.x, h - f.y);
}
}
out
}
/// Rows and columns exchanged, and x with y: a net made on the box turned on its side.
fn transposed(self) -> Self {
let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] };
for (r, row) in self.p[..self.rows].iter().enumerate() {
for (c, q) in row[..self.cols].iter().enumerate() {
out.p[c][r] = Point::new(q.y, q.x);
}
}
out
}
/// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from
/// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1).
fn distort_columns(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for c in 0..cols {
let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0);
let mid = half_way(self.p[0][c], self.p[rows - 1][c]);
for row in &mut self.p[..rows] {
row[c] = mid + (row[c] - mid) * s;
}
}
}
/// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0
/// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1).
fn distort_rows(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for (r, row) in self.p[..rows].iter_mut().enumerate() {
let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0);
let mid = half_way(row[0], row[cols - 1]);
for q in &mut row[..cols] {
*q = mid + (*q - mid) * s;
}
}
}
/// The same patch as a bicubic net (degree elevation, which leaves the surface as it is).
fn bicubic(self) -> [[Point; 4]; 4] {
let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect();
let mut out = [[Point::ZERO; 4]; 4];
for c in 0..4 {
let col: Vec<Point> = rows.iter().map(|row| row[c]).collect();
for (to, q) in out.iter_mut().zip(cubic_line(&col)) {
to[c] = q;
}
}
out
}
}
/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one.
fn half_way(a: Point, b: Point) -> Point {
Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y)
}
/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form.
fn cubic_line(p: &[Point]) -> [Point; 4] {
match *p {
[a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b],
[a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c],
[a, b, c, d] => [a, b, c, d],
_ => [Point::ZERO; 4],
}
}
/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord`
/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along
/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is
/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord.
fn arc_handles(chord: f64, b: f64) -> (f64, f64) {
let th = b.abs().min(1.0) * PI;
let len = chord / (3.0 * (th / 4.0).cos().powi(2));
(len * (th / 2.0).cos(), len * (th / 2.0).sin())
}
/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive
/// `b` when `up`, down when not, the other way for a negative one.
fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] {
let (ax, ay) = arc_handles(w, b);
let dy = if (b > 0.0) == up { -ay } else { ay };
[Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)]
}
/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top
/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly
/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean
/// out by θ/2.
fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] {
let th = b.abs().min(1.0) * PI;
let (s, c) = (th / 2.0).sin_cos();
let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c)));
let (ax, ay) = arc_handles(tr.x - tl.x, b);
let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr];
let bottom = arc_row(w, h, b.abs(), true);
let mut out = [[Point::ZERO; 4]; 4];
for (r, row) in out.iter_mut().enumerate() {
for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) {
*q = t.lerp(u, r as f64 / 3.0);
}
}
out
}
/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion.
/// `vertical`: the net of the box turned on its side, where Rise moves the other half.
fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net {
use WarpStyle::*;
let two = |top: [Point; 4], bottom: [Point; 4]| {
let mut p = [[Point::ZERO; 4]; 4];
(p[0], p[1]) = (top, bottom);
Net { rows: 2, cols: 4, p }
};
let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y));
match style {
Arc => {
let rows = arc_rows(w, h, b);
let net = two(rows[0], rows[3]);
if b < 0.0 { net.mirrored(h) } else { net }
}
ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)),
ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)),
Arch => {
let top = arc_row(w, 0.0, b, true);
two(top, top.map(|q| Point::new(q.x, q.y + h)))
}
Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)),
ShellLower | ShellUpper => {
let a = arc_rows(w, h, b);
let mut net = Net::flat(w, h, 4, 4);
if b >= 0.0 {
// The outer arc and the ends of the row inside it, upside down for Shell Lower.
net.p[0] = a[0];
(net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]);
if style == ShellLower { net.mirrored(h) } else { net }
} else {
// The inner arc and the ends of the row outside it, upside down for Shell Upper.
net.p[3] = a[3];
(net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]);
if style == ShellUpper { net.mirrored(h) } else { net }
}
}
Flag | Fish | Rise => {
let mut net = Net::flat(w, h, 2, 4);
let d = 2.0 * b * h;
match style {
Flag => {
for row in &mut net.p[..2] {
(row[1].y, row[2].y) = (row[1].y + d, row[2].y - d);
}
}
Fish => {
(net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d);
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
}
_ => {
for row in &mut net.p[..2] {
if vertical {
(row[2].y, row[3].y) = (row[2].y - d, row[3].y - d);
} else {
(row[0].y, row[1].y) = (row[0].y + d, row[1].y + d);
}
}
}
}
net
}
Wave => {
let mut net = Net::flat(w, h, 3, 4);
let d = 2.0 * b * h;
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
net
}
Fisheye => {
let mut net = Net::flat(w, h, 4, 4);
let c = Point::new(w / 2.0, h / 2.0);
for row in &mut net.p[1..3] {
for q in &mut row[1..3] {
*q = c + (*q - c) * (1.0 + 4.0 * b);
}
}
net
}
Inflate | Squeeze => {
let mut net = Net::flat(w, h, 3, 3);
let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 };
net.p[0][1].y -= b * h / 2.0;
net.p[2][1].y += b * h / 2.0;
net.p[1][0].x -= side;
net.p[1][2].x += side;
net
}
Twist => {
// The four inner points move round: clockwise for a positive bend.
let mut net = Net::flat(w, h, 4, 4);
let (dx, dy) = (b.abs() * w, b.abs() * h);
if b >= 0.0 {
net.p[1][1].x += dx;
net.p[1][2].y += dy;
net.p[2][2].x -= dx;
net.p[2][1].y -= dy;
} else {
net.p[1][1].y += dy;
net.p[1][2].x -= dx;
net.p[2][2].y -= dy;
net.p[2][1].x += dx;
}
net
}
}
}
/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns
/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the
/// box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WarpNet {
/// The control points over the box divided by `scale`.
net: [[Point; 4]; 4],
/// 1, or a power of two when the net over the box itself does not fit in finite numbers.
scale: f64,
w: f64,
h: f64,
}
impl WarpNet {
/// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each
/// -1..1, clamped); `vertical` bends along the box's height.
pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self {
let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 };
let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 };
let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h));
let build = |w: f64, h: f64| {
let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) };
net.distort_columns(dh);
net.distort_rows(dv);
net.bicubic()
};
// A box near the largest finite size can overflow the net. The net is then built on the box
// divided by the smallest power of two that fits (2 to 4096), an exact division for every
// side above the subnormal range, and points are multiplied back as they are read.
let mut out = Self { net: build(w, h), scale: 1.0, w, h };
while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) {
let scale = out.scale * 2.0;
out = Self { net: build(w / scale, h / scale), scale, w, h };
}
out
}
/// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A
/// coordinate the patch would send past the largest finite number is the box's own, held to
/// the finite range.
pub fn at(&self, u: f64, v: f64) -> Point {
let p = self.at_scaled(u, v);
let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) };
let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) };
Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h))
}
/// [`Self::at`] divided by the net's scale.
fn at_scaled(&self, u: f64, v: f64) -> Point {
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t]
};
let (bu, bv) = (bern(u), bern(v));
let (mut x, mut y) = (0.0, 0.0);
for (row, wv) in self.net.iter().zip(bv) {
for (q, wu) in row.iter().zip(bu) {
x += wv * wu * q.x;
y += wv * wu * q.y;
}
}
Point::new(x, y)
}
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
+41 -2
View File
@@ -1,11 +1,15 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged.
//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it
//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and
//! [`Document::info_title`] is the title File Info shows and exports write. Every field is
//! defaulted, so documents saved before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use crate::Document;
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
/// The title the file this document was opened from gives itself (an SVG's root `<title>`, a
/// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of
/// [`Document::title`], the document's display name. `None`: the file gave none, so the
/// display name is the title. Left out of a saved document when unset, so other documents
/// save as they did.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub author: String,
/// The author's position or job title.
pub author_title: String,
@@ -83,6 +94,16 @@ impl DocMetadata {
}
}
impl Document {
/// The title File Info shows and the files an export writes carry (PDF `Title` and XMP
/// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from
/// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the
/// default file name) is always [`Document::title`].
pub fn info_title(&self) -> &str {
self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title)
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
@@ -191,6 +212,24 @@ mod tests {
assert!(DocMetadata::default().png_text("").is_empty());
}
#[test]
fn the_info_title_is_the_files_own_title_else_the_display_name() {
let mut d = Document::new(10.0, 10.0);
d.title = "box.svg".into();
assert_eq!(d.info_title(), "box.svg");
d.metadata.title = Some("Access paths".into());
assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths"));
d.metadata.title = Some(" ".into());
assert_eq!(d.info_title(), "box.svg");
// Saved only when set: other documents' metadata is as before, and the field comes back.
d.metadata.title = None;
assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title"));
d.metadata.title = Some("Access paths".into());
let json = serde_json::to_string(&d.metadata).unwrap();
assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths"));
assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None);
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
+26 -2
View File
@@ -194,8 +194,14 @@ fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
/// Live effect `id` with `params` (the others keep the effect's defaults).
fn fx(id: &str, params: Value) -> Effect {
/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its
/// style's Bézier net, as a new Warp does.
fn fx(id: &str, mut params: Value) -> Effect {
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::live::WARP_NET));
}
Effect { id: id.into(), params, visible: true }
}
@@ -657,6 +663,24 @@ mod tests {
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
}
/// The built-in styles' Warps draw with the map a new Warp takes.
#[test]
fn builtin_warp_styles_take_the_nets_map() {
let mut warps = 0;
for b in STYLE_LIBRARIES {
let lib = builtin_style_library(b.id).unwrap();
for g in &lib.styles {
let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect();
effects.extend(g.appearance.items.iter().flat_map(|i| i.effects()));
for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) {
assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id);
warps += 1;
}
}
}
assert!(warps >= 3, "{warps}");
}
#[test]
fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
+1 -1
View File
@@ -116,7 +116,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
+1 -1
View File
@@ -96,7 +96,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
pub use trap::TRAP;
pub use vectorcraft_three_d::Mesh;
pub use warp::{WarpStyle, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version};
mod catalog;
mod geometry;
+25 -2
View File
@@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's
/// map `version` given as a whole float is stored as an integer.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
@@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value {
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
upgrade_warp_version(id, &mut m);
Value::Object(m)
}
other => other.clone(),
@@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
}
}
/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer.
fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) {
if !id.starts_with("warp.") {
return;
}
if let Some(v) = m.get_mut("version")
&& !v.is_u64()
&& let Some(n) = crate::warp::warp_version(v)
{
*v = json!(n);
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
@@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
let mut params = merged_params(id, params);
// A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry
// no version, so warps saved without one keep the earlier map.
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::warp::WARP_NET));
}
Some(Effect { id: id.to_string(), params, visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
+279 -15
View File
@@ -4,8 +4,9 @@
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp
//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur,
//! feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
@@ -13,11 +14,13 @@
//! ([`opaque_area`]).
use std::collections::{BTreeMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -648,7 +651,14 @@ fn as_art_opts(
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let image = Node::new(n.id, n.kind.clone());
let mut image = Node::new(n.id, n.kind.clone());
// Its pixels draw as the image's do: colorized by a grayscale image's fill color, and
// smoothed or not.
{
let mut e = image.extras_mut();
e.colorize = n.extras().colorize.clone();
e.pixelated = n.extras().pixelated;
}
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -750,9 +760,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images
/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them
/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
@@ -823,8 +833,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
let copy_whole = copies_whole(art);
let mut warped = WarpedImages::new();
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
@@ -857,10 +868,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
}
let mut groups = vec![];
if follows {
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups);
// The images earlier Warps bend are measured by their pieces only when a group's
// gradient needs the bounds.
if !groups.is_empty()
&& let Some(images) = warped.when_cut(art)
{
groups.clear();
group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups);
}
}
// The ids of the paths that kept their shape (past the budget).
// The ids of the paths that kept their shape (past the budget), and their places in the
// order [`for_each_leaf`] visits the paths.
let mut kept_ids = HashSet::new();
let mut kept_leaves = HashSet::new();
let mut leaf_at = 0usize;
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
@@ -875,6 +897,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
continue;
}
for_each_leaf(art, &mut |leaf| {
let at = leaf_at;
leaf_at += 1;
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
@@ -907,6 +931,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
kept_leaves.insert(at);
return;
};
used = used.minus(before).plus(Budget::of(&out));
@@ -922,6 +947,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if boxes {
rebase_leaves(art, &mut leaves.into_iter());
}
// Warp bends images too: each one's pixels follow the warp's map over the same box. The
// images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image
// whose clipping frame kept its shape (past the budget) keeps its pixels as they are.
if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id)
&& crate::warp::bends(&e.id, &params)
{
let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, &params));
warped.reserve(art);
mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped);
}
if let Some((m, copies, options, _)) = transform
&& options.objects
{
@@ -934,14 +969,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
let images = if groups.is_empty() { None } else { warped.when_cut(art) };
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1;
if !groups.is_empty() {
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, &params, bounds, from, to, seed));
}
if follows && !held {
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), art.geometric_bounds(), seed));
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), warped.bounds(art), seed));
}
}
cut_warped_images(art, &warped);
whole
}
@@ -1563,17 +1600,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool);
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images`
/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]).
fn group_marks(
n: &Node,
at: &mut Vec<usize>,
kept: &HashSet<NodeId>,
images: Option<&WarpedImages>,
out: &mut Vec<GroupMark>,
) -> (Option<Rect>, bool) {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)),
NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false),
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let skip = children.len().saturating_sub(n.editing_children().len());
let (mut bounds, mut held) = (None, false);
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
let (b, h) = group_marks(c, at, kept, images, out);
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
@@ -1708,6 +1753,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma
copy_placed(n, m, copies, &mut room);
}
/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number).
pub(crate) const WARP_IMAGE_CELLS: usize = 10;
/// A Warp effect's point map, shared by the images it bends.
pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>;
/// One image a Warp effect bent: the image's own key, and each warp it took with the placement
/// the image had when it took it, in order.
type WarpedImage = (String, Vec<(WarpMap, Affine)>);
/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's
/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform
/// effect makes keeps. The images are cut into pieces once, after the last effect
/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece,
/// and a later Warp or Transform moves the one set of pieces.
pub(crate) struct WarpedImages {
/// What this table's keys start with: no image key in the art starts with it
/// ([`WarpedImages::reserve`]).
prefix: String,
/// The prefix was checked against the art's image keys.
reserved: bool,
images: Vec<WarpedImage>,
}
impl WarpedImages {
pub(crate) fn new() -> Self {
static TABLES: AtomicUsize = AtomicUsize::new(0);
Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] }
}
/// Before the first image of `art` is marked: lengthen the prefix until no image key in `art`
/// starts with it, so no key from the document is read as one of this table's.
pub(crate) fn reserve(&mut self, art: &Node) {
if self.reserved {
return;
}
self.reserved = true;
let mut keys = vec![];
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
keys.push(im.key.clone());
}
});
while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) {
self.prefix.push(':');
}
}
/// What this table's keys start with.
#[cfg(test)]
pub(crate) fn prefix(&self) -> &str {
&self.prefix
}
/// The image that `key`, a key of this table, stands for.
fn get(&self, key: &str) -> Option<&WarpedImage> {
self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?)
}
/// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's
/// new key.
fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String {
let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![]));
warps.push((f.clone(), xf));
self.images.push((own, warps));
format!("{}{}", self.prefix, self.images.len() - 1)
}
/// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that
/// this table lists?
fn fits(&self, n: &Node, max: usize) -> bool {
let mut count = 0usize;
n.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind
&& self.get(&im.key).is_some()
{
count += 1;
}
});
vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count())
}
/// The geometric bounds of `art` as it draws after the effects so far: each image this table
/// lists measured by the pieces the warps and moves it took would cut it into, when the art
/// has room for them ([`cut_warped_images`]).
pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> {
if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) {
return art.geometric_bounds();
}
// The pieces the images would be cut into now: each group of pieces is shown or hidden as
// its image is, and hidden objects are left out of a layer's bounds.
let mut probe = art.clone();
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// The geometric bounds of image `n`: when this table lists it, measured by the pieces the
/// warps and moves it took would cut it into.
fn image_bounds(&self, n: &Node) -> Option<Rect> {
let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() };
if self.get(&im.key).is_none() {
return n.geometric_bounds();
}
let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]);
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// This table when the art `art` has room for the pieces of the images it lists, so that a
/// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise.
fn when_cut(&self, art: &Node) -> Option<&Self> {
(!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self)
}
}
/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers
/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of
/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the
/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take
/// the warp.
pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) {
let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return };
let mut held = held;
// A clip group's first member is its clipping art: when a path in it kept its shape, it and
// the members it clips keep theirs.
if *clip && let Some(first) = children.first() {
let start = *at;
held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p));
}
for c in children.iter_mut() {
match &c.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1,
NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped),
NodeKind::Image(_) if !held => {
if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind {
im.key = warped.add(&im.key, im.xf, f);
}
}
_ => {}
}
}
}
/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of
/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image
/// is, smoothed or not) through the warps it took and the moves between and after them
/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces
/// would pass it, no image is cut. An image whose placement has no inverse is not cut either.
fn cut_warped_images(n: &mut Node, warped: &WarpedImages) {
cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS);
}
/// [`cut_warped_images`] with the art keeping to `max` nodes.
pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) {
if warped.images.is_empty() {
return;
}
let cut = warped.fits(n, max);
restore_warped_images(n, warped, cut);
}
/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut
/// into pieces.
fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
let NodeKind::Image(im) = &c.kind else {
if c.children().is_some() {
restore_warped_images(Arc::make_mut(c), warped, cut);
}
continue;
};
let Some((key, warps)) = warped.get(&im.key) else { continue };
let mut image = (**c).clone();
let NodeKind::Image(own) = &mut image.kind else { continue };
own.key = key.clone();
// The pieces start from the image as it was placed when it took its first warp.
let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() });
let map = warped_map(warps, own.xf);
*c = Arc::new(match (first, map) {
(Some(first), Some(map)) if cut => {
let pixelated = image.extras().pixelated;
vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false)
}
_ => image,
});
}
}
/// The map that takes a point of an image placed by the first of `warps`' placements to where the
/// warps and the moves between them take it, for an image now placed by `placed`: each warp in
/// turn, then the move from the placement the image had when it took that warp to the one it had
/// at the next, or to `placed` after the last. `None` when a placement has no inverse.
fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> {
let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len());
for (i, (f, at)) in warps.iter().enumerate() {
let det = at.determinant();
if !det.is_finite() || det == 0.0 {
return None;
}
let next = warps.get(i + 1).map_or(placed, |(_, at)| *at);
let m = next * at.inverse();
if !m.is_finite() {
return None;
}
steps.push((f.clone(), m));
}
Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q))))
}
/// How many paths [`for_each_leaf`] visits in `n`.
fn leaf_count(n: &Node) -> usize {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1,
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(),
_ => 0,
}
}
/// An object that a Transform effect places through its transform rather than reshapes: an image
/// (pixel space to document) or a symbol instance (the symbol's art to document).
fn is_placed(n: &Node) -> bool {
+362
View File
@@ -210,6 +210,57 @@ fn warp_arc_bends_the_top_edge() {
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as
/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the
/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map
/// moves it 0.6 × 25 = 15.
#[test]
fn a_warp_draws_with_the_map_its_version_names() {
let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0));
// u(1 - u)(1 - 2u) peaks at √3 / 18.
let peak = 360.0 * 3f64.sqrt() / 18.0;
for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] {
let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap();
assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}");
assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}");
}
}
/// A straight line has a box with no width or no height, and the net bends it as the middle of
/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye,
/// Inflate and Squeeze a horizontal line stays straight.
#[test]
fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() {
let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>();
let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0));
let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical);
assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}");
let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0));
for style in ["bulge", "fisheye", "inflate", "squeeze"] {
let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal);
assert!(
anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)),
"{style}: {out:?}"
);
}
}
/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none,
/// so a saved warp's params don't gain one.
#[test]
fn new_warps_take_the_nets_version() {
for (s, _) in WARP_STYLES {
let id = format!("warp.{s}");
let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}");
let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(0), "{id}");
assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}");
}
let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") };
assert!(e.params.get("version").is_none());
}
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
@@ -319,6 +370,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001);
}
/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`.
fn warp_test_image(id: u64) -> std::sync::Arc<Node> {
use vectorcraft_doc::{ImageObject, NodeId};
let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im))))
}
/// How many images `art` holds, and the keys they have.
fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) {
let (mut n, mut keys) = (0, std::collections::BTreeSet::new());
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
n += 1;
keys.insert(im.key.clone());
}
});
(n, keys)
}
#[test]
fn warped_images_stay_within_the_object_budget() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS);
let none = std::collections::HashSet::new();
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let warp = |art: &mut Node, warps: usize| {
let mut warped = crate::reshape::WarpedImages::new();
for _ in 0..warps {
crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped);
}
warped
};
// The art has room for one image's pieces only: neither image is cut, and each gets its own
// key back.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 1);
let max = art.count() + cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (2, k()));
// With room for both, each is cut into 200 pieces once, however many warps it took.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 3);
let max = art.count() + 2 * cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (400, k()));
// Images clipped by their frames: the second frame (the second path) kept its shape, so its
// image stays whole, and the first is cut. The third's frame is a group of two paths, the
// second of them kept, with an image after them, and its image sits in a group of its own:
// both stay whole too.
let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default()));
let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true }));
let nested = clip_group(
4,
vec![
Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])),
Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])),
],
);
let mut art =
Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]);
let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new());
crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped);
assert_eq!(at, 4);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (203, k()));
}
/// An image key from the document that looks like a key of the table is not read as one, and a
/// hidden image stays out of the bounds the later effects take, as it does before it is warped.
#[test]
fn warped_images_keep_document_keys_and_hidden_images_apart() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let none = std::collections::HashSet::new();
let mut warped = crate::reshape::WarpedImages::new();
let lookalike = format!("{}0", warped.prefix());
let mut b = (*warp_test_image(3)).clone();
if let NodeKind::Image(im) = &mut b.kind {
im.key = lookalike.clone();
}
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]);
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike])));
// A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured.
let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()));
let mut hidden = (*warp_test_image(5)).clone();
hidden.visible = false;
let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {
*children = vec![square, Arc::new(hidden)];
}
let before = layer.geometric_bounds();
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&layer);
crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped);
assert_eq!(warped.bounds(&layer), before);
// A shown image is measured by the pieces it is cut into.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]);
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
let measured = warped.bounds(&art);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(measured, art.geometric_bounds());
assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}");
// An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed
// with no area is not.
for (scale, pieces) in [(1e-7, 200), (0.0, 1)] {
let mut image = (*warp_test_image(2)).clone();
if let NodeKind::Image(im) = &mut image.kind {
im.xf = Affine::scale(scale);
}
let mut art = Node::group(NodeId(1), vec![Arc::new(image)]);
let mut warped = crate::reshape::WarpedImages::new();
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art).0, pieces, "{scale}");
}
}
/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its
/// pixels as they are; within the budget, both bend.
#[test]
fn an_image_whose_frame_passes_the_budget_stays_whole() {
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let clipped = || {
let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
*clipping = true;
}
Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true })
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None);
assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4));
assert_eq!(warp_test_images(&art), (1, k()));
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None);
assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4));
assert_eq!(warp_test_images(&art), (200, k()));
}
/// The first image under `n` (the first piece of a cut image).
fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> {
let mut first = None;
n.walk(&mut |c| {
if first.is_none()
&& let NodeKind::Image(im) = &c.kind
{
first = Some((**im).clone());
}
});
first
}
#[test]
fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() {
let image = |effects: Vec<Effect>| {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = effects;
n
};
// Each piece's clipping path, and the image's frame (the outer clip group's clipping path).
let clips = |art: &Node| {
let (mut pieces, mut frame) = (vec![], None);
art.walk(&mut |c| {
if let NodeKind::Group { children, clip: true } = &c.kind
&& let Some(path) = children.first().and_then(|p| p.path_data())
{
if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 {
pieces.push(path.anchor_count());
} else if frame.is_none() {
frame = Some(path.anchor_count());
}
}
});
(pieces, frame)
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
for after in [
fx("distort.roughen", json!({"size": 5, "detail": 10})),
fx("stylize.roundCorners", json!({"radius": 5})),
fx("path.offsetPath", json!({"offset": -2})),
] {
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap();
let (pieces, frame) = clips(&art);
assert_eq!(pieces.len(), 200, "{}", after.id);
assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id);
assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id);
}
// Two warps cut the image once, through both maps; a Transform after them moves the pieces.
let flag = fx("warp.flag", json!({"bend": 30, "version": 1}));
let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0}));
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap();
assert_eq!(warp_test_images(&moved).0, 200);
let box1 = Rect::new(0.0, 0.0, 40.0, 40.0);
let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params));
// The flag bends the box the arc leaves, which the arc's frame measures.
let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap();
let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params));
// The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4).
for (art, by) in [(&art, 0.0), (&moved, 100.0)] {
let piece = first_image(art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0);
assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p);
}
}
}
/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it
/// bends the frame.
#[test]
fn an_images_pixels_take_the_map_their_warps_version_names() {
for version in [json!(null), json!(0), json!(1)] {
let mut params = json!({"bend": 50});
if !version.is_null() {
params["version"] = version.clone();
}
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("warp.arc", params.clone())];
let art = crate::reshape::reshape(None, &n, None).unwrap();
let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &params));
let piece = first_image(&art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}");
}
// The frame has an anchor where the map takes each corner.
let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap();
for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] {
let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6);
assert!(near, "{version}: {corner:?}");
}
}
}
/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's
/// bounds to where its image's pieces draw them.
#[test]
fn a_nested_groups_gradient_follows_its_warped_image() {
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId};
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 geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed)));
let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default());
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]);
g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1})));
let art = crate::group::evaluate_container(None, &g).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None);
art.walk(&mut |c| {
if c.id == NodeId(5) && to.is_none() {
to = c.geometric_bounds();
}
});
let to = to.unwrap();
assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}");
let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height());
let mut checked = 0;
art.walk(&mut |c| {
if c.id != NodeId(5) {
return;
}
for item in &c.appearance.items {
let Paint::Gradient(gp) = item.paint() else { continue };
let got = gp.geom.unwrap();
assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}");
checked += 1;
}
});
assert!(checked > 0);
}
/// An image under a geometry effect other than a Warp draws as the image does: colorized by
/// a grayscale image's fill color, and smoothed or not.
#[test]
fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))];
n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
n.extras_mut().pixelated = true;
let art = crate::reshape::reshape(None, &n, None).unwrap();
let mut seen = vec![];
art.walk(&mut |c| {
if matches!(c.kind, NodeKind::Image(_)) {
seen.push((c.extras().colorize.clone(), c.extras().pixelated));
}
});
assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]);
}
/// What `effects` make of `path` within `budget`.
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
+34 -11
View File
@@ -1,25 +1,49 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]);
//! one without (saved before the version existed) is a map on normalized box coordinates
//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control
//! points are mapped (see `map_nonlinear`).
use serde_json::Value;
use vectorcraft_geom::{PathData, Point, Rect};
use crate::util::{map_nonlinear_within, num, text};
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version};
/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent.
pub fn map_version(p: &Value) -> u32 {
let v = num(p, "version", 0.0);
if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 }
}
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, warp_map(b, style, p))
}
/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each
/// point of a path to.
pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> {
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
if uses_net(map_version(p)) {
let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
let (w, h) = (b.width(), b.height());
// A box with no width or no height takes the middle of the patch along that side.
return Box::new(move |q: Point| {
let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
b.origin() + net.at(u, v).to_vec2()
});
}
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
let f = |q: Point| {
Box::new(move |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
@@ -28,18 +52,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
})
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
/// tests both sides of it).
/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as
/// none: the threshold must match the one in [`warp_point`]'s Arc case
/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any
/// bend moves the outline.
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
+30 -8
View File
@@ -1,7 +1,8 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
//! it gives ([`read_pdf`]).
//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]),
//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or
//! `.ai` opens with the File Info it gives ([`read_pdf`]).
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
@@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
"title": d.title,
"title": d.info_title(),
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
@@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone());
for (k, v) in o {
apply(&mut title, &mut m, k, v)?;
}
let d = &s.doc()?.doc;
if title != d.title || m != d.metadata {
// A title File Info changes becomes the document's own, as it always has (the tab and Save As
// offer it where the document has no file), and replaces the one its file gave.
let retitled = title != d.info_title();
if retitled {
m.title = None;
}
if retitled || m != d.metadata {
s.edit("File Info", |d, _| {
d.title = title;
if retitled {
d.title = title;
}
d.metadata = m;
Ok(())
})?;
@@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
/// field that `file.info` would take (a value it refuses is left out), and the created and
/// modified dates.
/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`])
/// as the File Info title, and the created and modified dates.
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
let fields = [
@@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let _ = apply(&mut title, m, k, &v);
}
}
m.title = f.title.as_deref().and_then(imported_title);
m.created = f.created.or(m.created);
m.modified = f.modified.or(m.modified);
}
@@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
Ok(s.to_string())
}
/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too)
/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option<String> {
let words: Vec<String> =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
let title = title.trim_end();
super::fileio::is_own_title(title).then(|| title.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result<Vec<String>> {
+1
View File
@@ -143,6 +143,7 @@ pub(crate) use export_names::{
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
};
use export_names::{distinct_names_by, write_encoded};
pub(crate) use formats::is_own_title;
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, builtin_open_filters,
+1 -1
View File
@@ -592,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title()))
} else {
bytes
};
+1 -1
View File
@@ -370,7 +370,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
let o = EpsOptions {
region,
origin,
title: doc.title.clone(),
title: doc.info_title().to_owned(),
created,
native: Some(native(&editing, if whole { None } else { board }, set.include_linked)),
..set.eps.clone()
+8 -1
View File
@@ -930,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
is_own_title(doc.info_title())
}
/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
pub(crate) fn is_own_title(title: &str) -> bool {
!title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
// A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it.
d.metadata.title = None;
Some(d)
}
+1 -1
View File
@@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
}
}
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
+12 -1
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name};
@@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
// or Info `Title`, which the readers put in File Info) is separate from the document's name.
// Restored File Info is preserved exactly; newly imported SVG candidates and a restored
// document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626).
if !restored && matches!(format.id, "svg" | "svgz") {
doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title);
}
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
@@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
if template {
doc.template = false;
doc.title = s.next_untitled();
doc.metadata.title = None;
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
@@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
st.import_losses = losses;
st.links_from = read_from;
let opened = &s.documents()[index];
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string());
Ok(super::merge(
json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }),
links.to_json(),
+1 -1
View File
@@ -731,7 +731,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
// With the artboards' background colors, as the pages draw them.
let art = super::export::with_artboard_fills(art, &params, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?;
let o = vectorcraft_eps::AiEditingOptions {
title: doc.title.clone(),
title: doc.info_title().to_owned(),
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
};
let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?;
+11 -1
View File
@@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() {
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
let p = search(&root, "missing", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
// Each failure has the same reason without the folder's path.
let briefs = [
("missing", "the folder can't be read"),
("file.ttf", "the path isn't a folder"),
("refused", "VectorCraft doesn't search other apps' or the system's folders"),
("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"),
];
for (picked, why) in briefs {
assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}");
}
}
#[test]
@@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() {
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
if enforced {
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}");
} else {
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
}
+51 -15
View File
@@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024;
/// How a search ends when every walker thread stopped on a bug.
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
/// Why a search couldn't start, without the folder's path ([`Progress::failure`]).
const UNREADABLE: &str = "the folder can't be read";
const NOT_A_FOLDER: &str = "the path isn't a folder";
const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users";
const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders";
const UNLISTED: &str = "the folder can't be listed";
/// What a search looks for.
pub trait Visitor: Send + Sync {
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
@@ -77,8 +84,9 @@ pub enum End {
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
/// keeps at most [`Limits::per_item`] files, without ending the search.
Limit,
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
Failed(String),
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why,
/// and the same without the folder's path.
Failed(String, &'static str),
}
/// Where a search stands ([`Search::progress`]).
@@ -114,7 +122,7 @@ impl Progress {
None => "searching",
Some(End::Done | End::Found) => "done",
Some(End::Stopped | End::Time | End::Limit) => "stopped",
Some(End::Failed(_)) => "failed",
Some(End::Failed(..)) => "failed",
}
}
@@ -131,10 +139,38 @@ impl Progress {
/// Why a failed search failed.
pub fn error(&self) -> Option<&str> {
match &self.end {
Some(End::Failed(e)) => Some(e),
Some(End::Failed(e, _)) => Some(e),
_ => None,
}
}
/// Why a failed search failed, without the folder's path.
pub fn failure(&self) -> Option<&'static str> {
match &self.end {
Some(End::Failed(_, why)) => Some(why),
_ => None,
}
}
/// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]),
/// for a reply to it: the files found inside them. Returns the folder searched (`given` until a
/// walker checked it) and why the search failed, when that folder lies inside them too;
/// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in
/// force, everything is kept.
pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) {
let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() };
if crate::file_access::current().is_none() {
return (Some(folder), self.error().map(str::to_string));
}
// A path that isn't valid text is never shown: its lossy form names another file.
let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
self.hits.retain(|(path, _)| readable(path));
if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) {
(Some(folder), self.error().map(str::to_string))
} else {
(None, self.failure().map(str::to_string))
}
}
}
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
@@ -306,7 +342,7 @@ pub(crate) fn start_in(
return Err("the search couldn't start: try again in a moment".into());
}
if w.alive == 0 && w.end.is_none() {
shared.finish(&mut w, End::Failed(INTERNAL.into()));
shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
}
Ok(Search { shared })
@@ -401,13 +437,13 @@ impl Shared {
fn prepare(&self, root: &Path) {
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
let path = root.display();
let checked = match std::fs::canonicalize(root) {
Err(e) => Err(format!("{path}: {e}")),
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
let (checked, brief) = match std::fs::canonicalize(root) {
Err(e) => (Err(format!("{path}: {e}")), UNREADABLE),
Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER),
Ok(folder) => match rules.refusal(&folder) {
None => Ok(folder),
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
None => (Ok(folder), INTERNAL),
Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED),
Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS),
},
};
let mut w = self.lock();
@@ -416,7 +452,7 @@ impl Shared {
w.folder = folder.clone();
w.stack.push((folder, 0));
}
Err(e) => self.finish(&mut w, End::Failed(e)),
Err(e) => self.finish(&mut w, End::Failed(e, brief)),
}
}
@@ -430,7 +466,7 @@ impl Shared {
let mut w = self.lock();
// The picked folder itself: the search can't run, and says why.
if depth == 0 {
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED));
} else {
w.unreadable += 1;
w.folders += 1;
@@ -610,7 +646,7 @@ fn walker(s: &Shared) {
if done.is_err() {
let mut w = s.lock();
match job {
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)),
Job::List(..) => w.unreadable += 1,
}
}
@@ -640,7 +676,7 @@ impl Drop for Alive {
let mut w = self.0.lock();
w.alive = w.alive.saturating_sub(1);
if w.alive == 0 && w.end.is_none() {
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
drop(w);
self.0.pool.release(1);
+18 -10
View File
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Font Files",
[],
None,
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Font Files",
[],
None,
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
@@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
let p = f.search.progress();
let mut p = f.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&f.folder);
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
for (path, items) in &p.hits {
for i in items {
@@ -293,16 +295,18 @@ fn state(s: &Session) -> Value {
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
"id": f.id, "state": p.state(), "fonts": fonts,
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
@@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// VectorCraft's own Fonts folder: no automation root applies to it.
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
let report = copy_into(&files, dir);
let pairs = |v: &[(PathBuf, PathBuf)]| {
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
// The caller sees the Fonts folder and the copies in it only where its read roots reach.
let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
let shown = |p: &Path| -> String {
let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() };
shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
};
let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>();
let mut out = json!({
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
"folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(),
});
if !report.copied.is_empty() || !report.kept.is_empty() {
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
+16 -7
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Linked Files",
[],
None,
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
)]
@@ -52,6 +52,8 @@ pub(crate) struct LinkSearch {
pub(crate) id: u64,
/// The folder as given.
pub(crate) folder: String,
/// The path of the document the links were taken from, which a relative link path starts from.
pub(crate) doc: Option<String>,
pub(crate) links: Vec<Sought>,
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
if links.is_empty() {
return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" }));
}
let doc = super::links::LinkBase::of(st).doc.map(str::to_string);
// A new search stops the last one.
s.link_search = None;
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
@@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let visitor = Arc::new(LinkFiles::new(&links, sizes.clone()));
let search =
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?;
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search });
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search });
Ok(state(s))
}
@@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize {
/// Where the session's last search stands (`links.findFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) };
let p = l.search.progress();
let mut p = l.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&l.folder);
let n = l.links.len();
let sizes = lock(&l.sizes).clone();
// Per linked file: the files found, each with whether it is the same file.
@@ -267,19 +272,23 @@ fn state(s: &Session) -> Value {
f
})
.collect();
json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files })
let mut row = json!({ "name": link.name, "ids": link.ids, "files": files });
super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref());
row
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": l.id, "state": p.state(), "folder": folder, "links": links,
"id": l.id, "state": p.state(), "links": links,
"searched": { "folders": p.folders, "files": p.files, "read": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
+2 -2
View File
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
+4 -4
View File
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
has_selection,
env_make_warp
),
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
+11 -6
View File
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
// A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
// names one.
let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
(style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
}
if let Some(n) = p.get("version") {
version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
}
if let Some(s) = text_param(p, "style", cmd)? {
let s = s.strip_prefix("warp.").unwrap_or(s);
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
Some(o) => o == "horizontal",
None => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
+8
View File
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
let newest = vectorcraft_render::effects::WARP_NET;
if effect.starts_with("warp.")
&& let Some(v) = given.get("version")
&& vectorcraft_render::effects::warp_version(v).is_none()
{
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
+4 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Info",
[],
None,
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
"{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
has_doc,
image_info
),
@@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
let [a, b, c, d, ..] = im.xf.as_coeffs();
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
let size = n.geometric_bounds().unwrap_or_default();
// A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info.
let doc_path = super::links::LinkBase::of(st).doc;
Ok(json!({
"id": id.0,
"name": n.display_name(),
"linked": im.link.is_some(),
"link": im.link.as_ref().map(|l| &l.path),
"link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)),
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
"pixelWidth": im.width,
"pixelHeight": im.height,
@@ -645,6 +645,8 @@ pub(super) const ALLOWED: &[&str] = &[
"type.opticalMarginAlignment",
"type.orientation.horizontal",
"type.orientation.vertical",
"type.pathCenter.bottom",
"type.pathCenter.top",
"type.pathOptions",
"type.resetHorizontalScale",
"type.resetTracking",
+148
View File
@@ -60,6 +60,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
path_options
),
cmd!(
"type.pathCenter.top",
"Center on Top",
["Type", "Type on a Path"],
None,
"{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)",
has_closed_path_type,
path_center_top
),
cmd!(
"type.pathCenter.bottom",
"Center on Bottom",
["Type", "Type on a Path"],
None,
"{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)",
has_closed_path_type,
path_center_bottom
),
cmd!(
"type.dynamicText",
"Dynamic Text",
@@ -430,6 +448,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// Whether type on a path `t` runs along one closed path (no open or compound path).
fn on_one_closed_path(t: &TextObject) -> bool {
matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed())
}
/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or
/// compound path, then those in a thread; None when they take every one.
fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>();
if !open.is_empty() {
let open = open.join(", ");
return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
}
let frames = threaded_ids(d, ids);
(!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
}
/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of
/// it on one closed path and none of it in a thread.
fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
let d = &st.doc;
let selected: Vec<NodeId> = st.selection.objects.to_vec();
let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
if paths.is_empty() {
return Err("select type on a closed path".into());
}
path_center_refusal(d, &paths).map_or(Ok(()), Err)
}
/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its
/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or
/// no height.
fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> {
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
let (from, to) = t.kind.path_span()?;
let arc = vectorcraft_geom::ArcPath::new(path);
if arc.is_empty() {
return None;
}
let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY };
let middle = arc.topmost(flip * t.xf)? / arc.len();
let run = to - from;
let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0);
// Without an end bracket the type runs once round; a full turn's end stays on its start.
let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) });
Some((start, end))
}
/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within
/// rounding (0 and 1 are one place).
fn same_bracket(a: f64, b: f64) -> bool {
let d = (a - b).rem_euclid(1.0);
d.min(1.0 - d) <= 1e-9
}
fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> {
path_center(s, p, false)
}
fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> {
path_center(s, p, true)
}
/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed
/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as
/// drawn, and center the first paragraph, in one undo step; none when nothing changes.
fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> {
let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" };
let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter);
// Each object the command changes, with its new brackets when they move and whether its first
// paragraph is set to Center.
let (ids, changes) = {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
if ids.is_empty() {
return Err(bad(c, "select type on a closed path"));
}
if let Some(why) = path_center_refusal(d, &ids) {
return Err(bad(c, why));
}
let (mut changes, mut flat) = (vec![], vec![]);
for id in &ids {
let Some(t) = path_text(d.node(*id)) else { continue };
let TextKind::OnPath { start, end, .. } = &t.kind else { continue };
let Some((to_start, to_end)) = centered_brackets(t, bottom) else {
flat.push(id.0.to_string());
continue;
};
let stays = same_bracket(*start, to_start)
&& match (end, to_end) {
(None, None) => true,
(Some(a), Some(b)) => same_bracket(*a, b),
_ => false,
};
let align = !centered(t.para_at(0).justify);
if !stays || align {
changes.push((*id, (!stays).then_some((to_start, to_end)), align));
}
}
if !flat.is_empty() {
let flat = flat.join(", ");
return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}")));
}
(ids, changes)
};
if !changes.is_empty() {
s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| {
for (id, brackets, align) in &changes {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) {
(*start, *end) = *b;
}
if *align {
t.edit_paras(Some(0..1), |para| para.justify = Justify::Center);
}
refresh_bounds(t);
}
Ok(())
})?;
}
let d = &s.doc()?.doc;
let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind {
TextKind::OnPath { start, end, .. } => (*start, *end),
_ => (0.0, None),
});
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end}))
}
/// The options `type.pathOptions` sets; with none of them given it queries.
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
+1 -1
View File
@@ -645,7 +645,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ {
/// The File Info text `s.metadata` lets into the files: `(keyword, text)`.
fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> {
doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
}
/// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata.
+2 -2
View File
@@ -285,8 +285,8 @@ fn document_base(s: &Session) -> Value {
json!({
"uid": st.uid,
"title": st.title(),
// The File Info title (`file.info`), where `title` comes from the file name once the document has one.
"fileInfoTitle": d.title,
// The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
"fileInfoTitle": d.info_title(),
"path": st.path,
"dirty": st.is_dirty(),
"revision": st.revision,
+1
View File
@@ -206,6 +206,7 @@ mod typescale;
mod typeundo;
mod units;
mod variables;
mod warp_images;
mod webexport;
mod widthpoints;
mod widthprofiles;
+34
View File
@@ -147,3 +147,37 @@ fn show_all_hidden_attributes() {
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.has_hidden());
}
/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit
/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and
/// a map this build doesn't draw is refused.
#[test]
fn warps_keep_the_map_their_version_names() {
let (mut s, id) = session_with_rect();
let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned();
run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}}));
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"}));
assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
p.remove("version");
}
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}}));
assert_eq!(version(&s), None);
run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}}));
assert_eq!(version(&s), Some(json!(1)));
run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
// A whole number given as a float is that version.
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1));
run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}}));
assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
for bad in [json!(2), json!(-1), json!(0.5), json!("1")] {
assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}");
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}");
}
assert_eq!(node(&s, id).appearance.effects.len(), 2);
}
+47 -2
View File
@@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
}
/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps
/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its
/// warp or its options change.
#[test]
fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() {
// The surface point a quarter of the way along the top edge.
let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p;
let at = |x, y| vectorcraft_geom::Point::new(x, y);
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET));
// Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4.
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e));
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind)
&& let EnvelopeKind::Warp { version, .. } = &mut **kind
{
*version = 0;
}
s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap();
s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
// The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights.
assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e));
// `version` moves a warp to a map, and a new warp can take the earlier one.
s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap();
assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0}));
assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old));
for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] {
for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] {
let r = s.execute(cmd, &json!({"version": bad}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}");
}
}
}
#[test]
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let mut s = session();
@@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let content = node(&s, a);
assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
// The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
// The mesh follows the arc: its top corners swing out past the content's sides and up.
let b = m.outline().bounds().unwrap();
assert!(b.y1 > 201.0, "{b:?}");
assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
+33 -3
View File
@@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() {
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
}
#[test]
fn a_colorized_image_keeps_its_color_in_an_envelope() {
let mut s = session();
let img = image::GrayImage::from_pixel(4, 4, image::Luma([0]));
let mut png = vec![];
image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let im = s
.edit("Place", |d, sel| {
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0);
let im =
vectorcraft_doc::ImageObject { key: "gray".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [im.0]})).unwrap();
sel(&mut s, &[im]);
arch(&mut s, 80.0, json!({}));
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
// The black pixels draw in the fill color, in the bent shape.
let mid = img.pixel(120, 97);
assert!(mid[0] > 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}");
let inside = img.pixel(120, 120);
assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}");
}
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
fn two_color_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
@@ -286,17 +315,18 @@ fn symbols_bend_with_the_envelope() {
let e = arch(&mut s, 60.0, json!({}));
let pts = anchors(&evaluated(&s, e));
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The
// arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it.
let mut r = vectorcraft_render::Renderer::new();
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60);
s.edit("edit symbol", |d, _| {
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
d.symbols[0].art = Arc::new(art);
Ok(())
})
.unwrap();
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200);
}
fn strokes(nodes: &[Node]) -> usize {
+188 -1
View File
@@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["description"], "A poster\nfor the fair");
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries");
assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -277,6 +278,192 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() {
assert_eq!(m.copyright_url, "");
}
/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose
/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`.
fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
let first = first_extra(1);
let mut objects = vec![];
let mut catalog = String::new();
if let Some(t) = xmp_title {
let xmp = format!(
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\
<dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\
</rdf:Description></rdf:RDF></x:xmpmeta>"
);
objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()));
catalog = format!("/Metadata {first} 0 R ");
}
let info_at = first + objects.len();
if let Some(i) = info {
objects.push(format!("<< {i} >>"));
}
let refs: Vec<&str> = objects.iter().map(String::as_str).collect();
let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None);
if info.is_none() {
return bytes;
}
// The trailer follows the cross-reference table, so a longer trailer moves no object.
String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
}
/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab
/// shows the document's name), `s` holding it.
fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
}
/// The document's name (its tab) and its File Info title.
fn titles(s: &mut Session) -> [String; 2] {
let info = s.execute("file.info", &json!({})).unwrap();
[s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()]
}
/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title.
fn opened(name: &str, bytes: &[u8]) -> [String; 2] {
let mut s = Session::new();
let r = open_named(&mut s, name, bytes);
let t = titles(&mut s);
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply");
assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title");
t
}
fn svg_titled(inner: &str) -> String {
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip</title></rect></svg>"
)
}
#[test]
fn import_puts_the_files_own_title_in_file_info_only() {
// SVG: the root's `<title>`, its white space made single spaces (not a shape's tooltip), is the
// File Info title; the document keeps the file's name, with its extension.
assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths </title>").as_bytes()), ["box.svg", "Access paths"]);
assert_eq!(
opened("/work/box.svg", svg_titled("<title>Zugangswege Rechenzentrum – Prüfdiagramm</title>").as_bytes()),
["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"]
);
// PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone.
assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]);
assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]);
assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]);
assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]);
// Compressed SVG too.
let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner));
assert_eq!(opened("box.svgz", &svgz("<title>Access paths</title>")), ["box.svgz", "Access paths"]);
// A file that gives none (or a blank one) gives File Info the document's name.
assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]);
assert_eq!(opened("box.svg", svg_titled("<title> </title>").as_bytes()), ["box.svg", "box.svg"]);
assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]);
assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]);
// The Untitled-N name New gives is no title (a document that never had one writes it).
assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]);
// A template opens as a new untitled document, whatever title its file gave.
let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None));
assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}");
}
#[test]
fn imported_svg_titles_are_filtered_and_bounded() {
for (raw, expected) in [
("Untitled-2".to_string(), None),
("\u{7f}".to_string(), None),
("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())),
("λ".repeat(65_537), Some("λ".repeat(65_536))),
] {
let svg = svg_titled(&format!("<title>{raw}</title>"));
for format in ["svg", "svgz"] {
let name = format!("box.{format}");
let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() };
assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}");
}
}
}
#[test]
fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() {
let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("box.svg");
std::fs::write(&path, svg_titled("<title>Access paths</title>")).unwrap();
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
// The default names of what the document exports are made from the file's name, not its title.
assert_eq!(s.doc().unwrap().doc.title, "box.svg");
assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn file_info_edits_a_files_own_title_and_the_document_follows() {
let mut s = Session::new();
open_named(&mut s, "box.svg", svg_titled("<title>Access paths</title>").as_bytes());
// The dialog sends back the title it was given: nothing changes (no undo step).
let before = s.doc().unwrap().revision;
let shown = s.execute("file.info", &json!({})).unwrap();
s.execute("file.info", &shown).unwrap();
assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit");
assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
// Another title is the document's own, as ever, and the file's is gone.
let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap();
assert_eq!(r["title"], "Plan B");
assert_eq!(titles(&mut s), ["Plan B", "Plan B"]);
assert_eq!(s.doc().unwrap().doc.metadata.title, None);
// Undo brings the file's title back, and the name.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
}
#[test]
fn restored_file_info_title_keeps_authored_spaces() {
let mut s = session();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into());
for format in ["vectorcraft", "svg", "pdf"] {
let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap());
let mut reopened = Session::new();
let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes);
assert_eq!(reply["restored"], format != "vectorcraft", "{format}");
assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}");
assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}");
}
}
#[test]
fn the_files_own_title_is_kept_by_saves_and_written_by_exports() {
let mut s = Session::new();
open_named(&mut s, "box.svg", svg_titled("<title>Access paths</title>").as_bytes());
// The native file: its own title is the document's name, the File Info title survives.
let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap());
let mut t = Session::new();
open_named(&mut t, "other.vectorcraft", &native);
assert_eq!(titles(&mut t), ["box.svg", "Access paths"]);
// An SVG with the editing data it carries, reopened under another name.
let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"]
.as_str()
.unwrap()
.to_string();
assert!(svg.contains("<title>Access paths</title>") && svg.contains("<dc:title>Access paths</dc:title>"), "{svg}");
let mut u = Session::new();
let r = open_named(&mut u, "again.svg", svg.as_bytes());
assert_eq!(r["restored"], true);
assert_eq!(titles(&mut u), ["again.svg", "Access paths"]);
// PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer.
for pdf in pdf_texts(&mut s) {
assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf));
assert!(pdf.contains("/ViewerPreferences<</DisplayDocTitle true>>"), "{pdf}");
}
// PNG text.
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths"));
// The default file names are made from the document's name.
assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf");
}
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
+37
View File
@@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() {
assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}");
}
/// A caller confined by read roots sees, in the replies to a search another caller started, only
/// the files found inside its read roots, and the folder searched and a failed search's path only
/// when they are inside them.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let root = folder("read-roots");
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
for dir in [&home, &wide] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Findme.otf"), &font).unwrap();
}
let mut s = session_for(&root);
let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap();
let (narrow, broad) = (roots(&[&home]), roots(&[&root]));
let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| {
crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap())
};
// A font no file has keeps the search going through every folder.
let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]);
ask(&mut s, &broad, json!({"folder": root, "fonts": fonts}));
finished(&mut s);
let all = ask(&mut s, &broad, json!({}));
assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}");
for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}");
assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}");
assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
}
// A search that failed: the reason shows without the path to a caller outside its folder.
ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts}));
assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide")));
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
let root = folder("junk");
+111 -1
View File
@@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() {
let (tx, rx) = mpsc::channel();
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }];
s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search });
s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search });
let until = Instant::now() + WAIT;
while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until {
std::thread::sleep(Duration::from_millis(2));
@@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() {
drop(tx);
}
/// A caller confined by read roots sees, in the replies to a search another caller started and to
/// its own, only the paths inside its read roots: the files found outside them, a link's saved path
/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without
/// roots, the replies are as before.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let root = folder("read-roots");
let mut s = session_for(&root);
let photo = png(40, 30, RED);
write(&root.join("lrf-away/photo.png"), &photo);
let id = placed(&mut s, &root.join("lrf-away/photo.png"));
std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap();
// Files of the name with other contents: the search reads every folder.
let other = png(40, 30, BLUE);
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
write(&home.join("photo.png"), &other);
write(&wide.join("photo.png"), &other);
let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap();
let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[]));
let ask = |s: &mut Session, r: &Arc<crate::file_access::AutomationRoots>, p: Value| {
crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap())
};
let hidden = |r: &Value| {
let text = r.to_string();
assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}");
let row = &r["links"][0];
assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}");
};
// Started with the broad roots, over the whole folder.
let r = ask(&mut s, &broad, json!({"folder": root}));
assert_eq!(r["folder"], json!(root), "{r}");
finished(&mut s);
let all = ask(&mut s, &broad, json!({}));
assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}");
for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
hidden(&r);
assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}");
assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
}
let r = ask(&mut s, &write_only, json!({}));
hidden(&r);
assert_eq!(r["links"][0]["files"], json!([]), "{r}");
// Without roots, every path shows.
let r = s.execute("links.findFiles", &json!({})).unwrap();
assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}");
assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}");
// The narrow caller's own search shows the folder it gave.
let r = ask(&mut s, &narrow, json!({"folder": home}));
assert_eq!(r["folder"], json!(home), "{r}");
finished(&mut s);
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}");
// A search that failed: the reason shows without the path to a caller outside its folder.
ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")}));
let r = finished(&mut s);
assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}");
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}");
hidden(&r);
let _ = std::fs::remove_dir_all(&root);
}
/// A link's saved path relative to the document is shown to a confined caller when it leads to a
/// file inside its read roots, and left out when `..` leads out of them.
#[test]
fn a_relative_link_path_is_judged_from_the_documents_folder() {
let root = folder("read-roots-relative");
let mut s = session_for(&root);
let home = root.join("home");
write(&root.join("lrf-away/photo.png"), &png(40, 30, RED));
let id = placed(&mut s, &root.join("lrf-away/photo.png"));
std::fs::create_dir_all(&home).unwrap();
s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap();
let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap();
for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] {
s.edit("relative path", |d, _| {
if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind {
im.link.as_mut().unwrap().path = saved.into();
}
Ok(())
})
.unwrap();
let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap());
assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}");
finished(&mut s);
}
let _ = std::fs::remove_dir_all(&root);
}
/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link`
/// null), as `links.info` leaves it out, and keeps the other fields.
#[test]
fn image_info_leaves_out_a_link_outside_the_read_roots() {
let root = folder("read-roots-info");
let mut s = session_for(&root);
let pic = root.join("lrf-away/photo.png");
write(&pic, &png(40, 30, RED));
let id = placed(&mut s, &pic);
std::fs::create_dir_all(root.join("home")).unwrap();
let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap();
let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap());
let r = ask(&mut s, "image.info");
assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}");
assert!(!r.to_string().contains("lrf-away"), "{r}");
let info = ask(&mut s, "links.info");
assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}");
assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
let root = folder("copy");
+2 -2
View File
@@ -570,8 +570,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() {
let pts = all_anchors(&out);
assert!(pts.len() > 4, "curves are subdivided");
assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}");
// It really is bent: the sides moved down.
assert!(pts.iter().any(|p| p.y > 201.0));
// It really is bent: the top corners swung out past the sides.
assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0));
}
#[test]
+178
View File
@@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
assert!(origins(&s, id)[0].distance(first) < 8.0);
}
/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with
/// type on it from its start.
fn on_circle(s: &mut Session, text: &str) -> NodeId {
path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text)
}
#[test]
fn center_on_top_and_bottom_of_a_circle() {
let mut s = session();
let id = on_circle(&mut s, "OOOO");
assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id));
let steps = undo_len(&s);
let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
// Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered.
let (start, end) = brackets(&s, id);
assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}");
assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null));
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
let top = ink(&s, id);
assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}");
assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string()));
// Again: nothing moves, and no undo step is recorded.
s.execute("type.pathCenter.top", &json!({})).unwrap();
assert_eq!(undo_len(&s), steps + 1);
// The bottom: 6 o'clock, outside the circle (the type is not flipped).
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
let (start, end) = brackets(&s, id);
assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}");
let bottom = ink(&s, id);
assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}");
assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string()));
// Justify Center stays as it is.
s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap();
s.execute("type.pathCenter.top", &json!({})).unwrap();
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter);
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(brackets(&s, id).0, 0.25));
// Brackets already in place with another alignment: only the alignment changes, in one step.
s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
let (steps, before) = (undo_len(&s), brackets(&s, id));
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before));
assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
}
#[test]
fn center_on_top_twice_records_one_step_for_any_span() {
let mut s = session();
let id = on_circle(&mut s, "OOOO");
for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] {
s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap();
for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
s.execute(c, &json!({})).unwrap();
let (steps, placed) = (undo_len(&s), brackets(&s, id));
// Again: the brackets stay exactly where they are, and no undo step is recorded.
s.execute(c, &json!({})).unwrap();
assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}");
}
}
}
#[test]
fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() {
let mut s = session();
let id = on_circle(&mut s, "OOOO");
// A span of a fifth of the path keeps its length.
s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap();
s.execute("type.pathCenter.top", &json!({})).unwrap();
let (start, end) = brackets(&s, id);
assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}");
// Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top.
s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
s.execute("type.pathCenter.top", &json!({})).unwrap();
let (start, _) = brackets(&s, id);
assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}");
let bounds = text(&s, id).bounds().unwrap();
let top = ink(&s, id);
assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}");
// A rectangle: the middle of its top edge.
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
s.execute("type.pathCenter.top", &json!({})).unwrap();
let (start, end) = brackets(&s, id);
assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}");
assert!((ink(&s, id).center().x - 200.0).abs() < 0.5);
}
#[test]
fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() {
let mut s = session();
let a = on_circle(&mut s, "OOOO");
let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap();
let steps = undo_len(&s);
let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
assert_eq!(r["ids"], json!([a.0, b.0]));
assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625));
assert_eq!(undo_len(&s), steps + 1);
// Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of
// the circle, at its bottom.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
s.execute("type.pathCenter.bottom", &json!({})).unwrap();
assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a));
assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale);
let bottom = ink(&s, a);
assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}");
// Vertical type on a path moves the same way.
s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
s.execute("type.orientation.vertical", &json!({})).unwrap();
s.execute("type.pathCenter.top", &json!({})).unwrap();
assert!(near(brackets(&s, a).0, 0.75));
}
#[test]
fn center_on_top_refuses_open_compound_and_threaded_type() {
let mut s = session();
let circle = on_circle(&mut s, "OOOO");
let line = on_line(&mut s);
s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap();
let steps = undo_len(&s);
let before = brackets(&s, circle);
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}");
// Nothing changes, the type on the circle included, and the same holds for an explicit list.
assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before));
let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string();
assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}");
s.execute("select.set", &json!({"ids": [circle.0]})).unwrap();
for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] {
assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}");
}
assert_eq!(undo_len(&s), steps);
// Two circles side by side in one path: a compound path.
let pair = path_type(
&mut s,
"M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z",
[90.0, 450.0],
"OOOO",
);
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}");
// A closed path that is level all round has no height.
let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO");
let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}");
// A frame of a thread is refused, alone or with other type on a closed path.
let threaded = on_circle(&mut s, "Threaded story");
let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap();
let before = brackets(&s, threaded);
for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] {
let steps = undo_len(&s);
let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string();
assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}");
assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before));
}
// Without type on one closed path selected, the commands are not available.
let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
for (ids, why) in [
(json!([p]), "select type on a closed path"),
(json!([line.0]), "open or compound path"),
(json!([circle.0, line.0]), "open or compound path"),
(json!([threaded.0]), "in a thread"),
] {
s.execute("select.set", &json!({ "ids": ids })).unwrap();
for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
let e = s.execute(c, &json!({})).unwrap_err().to_string();
assert!(e.contains(why), "{c} {ids}: {e}");
}
}
}
#[test]
fn align_to_path_and_spacing() {
let mut s = session();
+157
View File
@@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() {
// From above B, over B and the shadow: B only.
assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]);
}
/// The selection after a drag with `tool` from `from` to `to`.
fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection {
press(s, tool, from, to, Mods::default());
s.doc().unwrap().selection.clone()
}
/// The point of anchor `(si, ai)` of path `id`.
fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point {
let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap();
path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap()
}
/// The anchors a marquee or a click picked on `id`, as points.
fn picked_points(s: &Session, id: NodeId) -> Vec<Point> {
let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect();
refs.into_iter().map(|r| anchor_point(s, id, r)).collect()
}
/// The anchors the tool marks as hovered.
fn anchor_marks(s: &mut Session) -> Vec<Point> {
s.overlays(ViewInfo::default())
.into_iter()
.filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None })
.collect()
}
fn hover(s: &mut Session, x: f64, y: f64) -> Cursor {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap();
s.cursor(Point::new(x, y), Mods::default(), v)
}
#[test]
fn a_direct_selection_marquee_leaves_out_a_drop_shadow() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
let (a_before, b_before) = (bounds(&s, a), bounds(&s, b));
assert_ne!(pixel(&s, 90, 50), WHITE);
run(&mut s, "select.none", json!({}));
// Over the shadow alone: no object, no anchor, no segment.
let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0));
assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}");
// Over the shadow and B's upper left corner: that anchor of B, and nothing of A.
let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0));
assert_eq!(sel.objects, [b]);
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
// Across B's top edge under the shadow, with neither of its anchors inside: that segment.
run(&mut s, "select.none", json!({}));
assert_ne!(pixel(&s, 112, 25), BLUE);
let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0));
assert_eq!(sel.objects, [b]);
assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}");
assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before));
}
#[test]
fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() {
let mut s = session();
let (_, b) = shadow_over_b(&mut s);
// B's upper left corner lies under the shadow.
assert_ne!(pixel(&s, 101, 21), BLUE);
run(&mut s, "select.none", json!({}));
s.select_tool("directSelection", ViewInfo::default()).unwrap();
for (x, y) in [(90.0, 50.0), (110.0, 50.0)] {
assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})");
assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
}
assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor);
assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]);
// A press there picks that anchor of B.
assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]);
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
}
#[test]
fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
run(&mut s, "select.none", json!({}));
assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty());
assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]);
}
#[test]
fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() {
let mut s = session();
// B and C in one group, A and D in another above it, and A's shadow over B.
let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff");
let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff");
run(&mut s, "select.set", json!({"ids": [b.0, c.0]}));
let gb = id_of(&run(&mut s, "object.group", json!({})));
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000");
run(&mut s, "select.set", json!({"ids": [a.0, d.0]}));
let ga = id_of(&run(&mut s, "object.group", json!({})));
shadow(&mut s, a, 40.0);
assert_ne!(pixel(&s, 110, 50), BLUE);
assert_ne!(pixel(&s, 90, 50), WHITE);
run(&mut s, "select.none", json!({}));
let click = |s: &mut Session, x, y| {
let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default());
sel.sort_by_key(|n| n.0);
sel
};
assert_eq!(click(&mut s, 110.0, 50.0), [b]);
let mut both = vec![b, gb];
both.sort_by_key(|n| n.0);
assert_eq!(click(&mut s, 110.0, 50.0), both);
// With A selected, a click on its shadow over the canvas does not add A's group.
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert!(click(&mut s, 90.0, 50.0).is_empty());
// On A itself the second click adds it.
assert_eq!(click(&mut s, 50.0, 50.0), [a]);
let mut both = vec![a, ga];
both.sort_by_key(|n| n.0);
assert_eq!(click(&mut s, 50.0, 50.0), both);
}
#[test]
fn a_group_selection_marquee_leaves_out_a_drop_shadow() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
run(&mut s, "select.none", json!({}));
let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0));
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
// Over the shadow and B's upper left corner: that anchor of B.
let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0));
assert_eq!(sel.objects, [b]);
assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
// With E, a marquee around B that crosses A's shadow selects B whole, and not A.
run(&mut s, "select.none", json!({}));
let v = ViewInfo::default();
s.select_tool("groupSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap();
s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert_eq!(sel.objects, [b]);
assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}");
}
#[test]
fn group_selection_hover_over_a_drop_shadow_marks_nothing() {
let mut s = session();
let (_, b) = shadow_over_b(&mut s);
run(&mut s, "select.none", json!({}));
s.select_tool("groupSelection", ViewInfo::default()).unwrap();
for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] {
assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})");
assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
}
assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
}
+293
View File
@@ -0,0 +1,293 @@
//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports.
use serde_json::{Value, json};
use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
/// The box of the image [`quarters`] places.
const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0);
/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top
/// left, green top right, blue bottom left, yellow bottom right.
fn quarter(u: f64, v: f64) -> [u8; 3] {
match (u < 0.5, v < 0.5) {
(true, true) => [255, 0, 0],
(false, true) => [0, 255, 0],
(true, false) => [0, 0, 255],
(false, false) => [255, 255, 0],
}
}
/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]).
fn quarters(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_fn(40, 40, |x, y| {
let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0);
image::Rgba([r, g, b, 255])
});
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, sel| {
d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
let im = vectorcraft_doc::ImageObject {
key: "quarters".into(),
width: 40,
height: 40,
xf,
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`].
fn arced(bend: f64, u: f64, v: f64) -> Point {
let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false);
FRAME.origin() + net.at(u, v).to_vec2()
}
/// Points of the image's box: near each quarter's middle and on both sides of the line between
/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color.
const SAMPLES: [(f64, f64); 10] =
[(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)];
/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white.
fn drawn(s: &Session) -> vectorcraft_render::Rendered {
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true)
}
/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v).
fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> {
SAMPLES
.iter()
.filter_map(|&(u, v)| {
let p = at(u, v);
let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
let want = quarter(u, v);
let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40);
(!near).then_some((u, v, px))
})
.collect()
}
/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the
/// Bézier patch [`arced`] follows.
fn arc_image(s: &mut Session) -> NodeId {
let id = quarters(s);
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let e = &n.appearance.effects[0];
assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params);
id
}
fn decoded(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn an_images_pixels_bend_with_effect_warp() {
let mut s = session();
let id = arc_image(&mut s);
// The arc lifts the middle of the image: the line between the quarters rises there.
assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6));
let bent = drawn(&s);
assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it");
// Drawn as pieces of the one image, each placed by the warp.
let d = &s.doc().unwrap().doc;
let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap();
let mut pieces = 0;
art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize);
assert!(pieces >= 100, "{pieces} pieces");
// Removing the warp draws the image as it was, in one undo step.
let steps = s.doc().unwrap().history.undo.len();
s.execute("effect.remove", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height());
assert_eq!(wrong(&drawn(&s), &plain), vec![]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back");
}
/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image
/// bent.
fn opens_bent(name: &str, out: &Value) {
let mut back = session();
back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap();
assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}");
}
#[test]
fn a_warped_image_keeps_its_bend_in_svg() {
let mut s = session();
arc_image(&mut s);
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let text = String::from_utf8(decoded(&svg)).unwrap();
assert_eq!(text.matches("<image").count(), 1, "the pieces share the image's pixels");
opens_bent("warped.svg", &svg);
}
#[test]
fn a_warped_image_keeps_its_bend_in_pdf() {
let mut s = session();
arc_image(&mut s);
opens_bent("warped.pdf", &s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap());
}
#[test]
fn a_warped_image_keeps_its_bend_in_ai() {
let mut s = session();
arc_image(&mut s);
opens_bent("warped.ai", &s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap());
}
#[test]
fn a_warped_image_keeps_its_bend_in_eps() {
let mut s = session();
arc_image(&mut s);
opens_bent("warped.eps", &s.execute("document.exportEps", &json!({"previewFormat": "none", "useArtboards": false})).unwrap());
}
#[test]
fn a_warped_image_keeps_its_bend_in_png() {
let mut s = session();
arc_image(&mut s);
let out = s.execute("document.export", &json!({"format": "png"})).unwrap();
let png = image::load_from_memory(&decoded(&out)).unwrap().to_rgba8();
// The artboard starts at (0, 0) and the export draws one pixel a point.
assert_eq!((png.width(), png.height()), (800, 600));
for &(u, v) in &SAMPLES {
let p = arced(50.0, u, v);
let px = png.get_pixel(p.x.floor() as u32, p.y.floor() as u32).0;
let want = quarter(u, v);
assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}");
}
}
/// A group the user made of two clip groups of the same image, each clipped by a rectangle, writes
/// the image once for each: only the pieces of a warp share one.
#[test]
fn svg_writes_an_image_clipped_twice_in_a_users_group_twice() {
let mut s = session();
let im = quarters(&mut s);
s.edit("Clip twice", |d, _| {
let image = d.node(im).cloned().unwrap();
let mut clipped = vec![];
for x in [FRAME.x0, FRAME.x0 + 40.0] {
let mut clip =
Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(x, FRAME.y0, x + 30.0, FRAME.y0 + 30.0)), Default::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node { id: d.alloc_id(), ..image.clone() };
let children = vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)];
clipped.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })));
}
let group = Node::group(d.alloc_id(), clipped);
d.remove(im)?;
d.insert(d.default_layer(), 0, group)?;
Ok(())
})
.unwrap();
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let text = String::from_utf8(decoded(&svg)).unwrap();
assert_eq!(text.matches("<image").count(), 2, "{text}");
}
/// Expand Appearance of a warped image gives objects that draw as the canvas did, with ids of
/// their own, in one undo step.
#[test]
fn expand_appearance_of_a_warped_image_draws_as_the_canvas_did() {
let mut s = session();
let id = arc_image(&mut s);
let steps = s.doc().unwrap().history.undo.len();
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let d = &s.doc().unwrap().doc;
let mut ids = std::collections::HashSet::new();
let mut images = 0;
for layer in &d.layers {
layer.walk(&mut |c| {
assert!(ids.insert(c.id), "{:?} twice", c.id);
images += matches!(c.kind, NodeKind::Image(_)) as usize;
assert!(c.appearance.effects.iter().all(|e| !e.id.starts_with("warp.")));
});
}
assert!(images >= 100, "{images} pieces");
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
s.execute("edit.undo", &json!({})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1);
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
}
/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized
/// red by its fill color: red above, nothing below.
fn red_on_clear(s: &mut Session) -> NodeId {
let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }]));
let mut png = vec![];
image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let id = s
.edit("Place", |d, sel| {
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
let im = vectorcraft_doc::ImageObject {
key: "gray".into(),
width: 40,
height: 40,
xf,
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap();
id
}
#[test]
fn a_colorized_image_keeps_its_color_when_warped() {
let mut s = session();
red_on_clear(&mut s);
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
let img = drawn(&s);
let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]);
for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] {
let p = arced(50.0, u, v);
let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}");
}
}
#[test]
fn a_warp_on_a_group_bends_the_image_in_it() {
let mut s = session();
let im = quarters(&mut s);
// A group of the image and a line below it as wide as the image: the group's box is the image's.
let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap();
let line = NodeId(line["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
}
+7
View File
@@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
assert!(st.revision > revision && !st.is_dirty());
assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0);
// A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the
// reply names the copy by its file name only, without the folder.
let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap();
let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap();
assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}");
assert!(!r.to_string().contains("App Fonts"), "{r}");
// The same file again is kept, not copied a third time.
let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap();
assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}");
+4 -1
View File
@@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
// Local VM restored: dictionary changes undone, saves restored twice.
"/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store",
// Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`).
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit",
// CID-keyed fonts found by name over predefined CMaps, horizontal and vertical.
"/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V",
"[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", "<c7d1a4a4467c>"
])
.prop_map(str::to_string),
]
+18
View File
@@ -6668,6 +6668,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
for id in &ids {
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1})));
}
for (id, params) in cases {
let effect = fx(id, params.clone());
@@ -6684,6 +6685,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
let got = &back.graphic_styles[0].appearance.effects;
assert_eq!(got.len(), 1, "{id} {params}");
assert_eq!(got[0].id, effect.id);
// A warp read from a file bends as its style's Bézier net.
if id.starts_with("warp.") {
assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}");
}
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
for (key, value) in w.as_object().unwrap() {
// Random isn't written, and a style with it isn't read; its seed is the default.
@@ -6704,6 +6709,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
}
}
}
// A warp saved before the map version existed is written with its settings too, and reads back on
// the nets' map.
let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true };
let mut doc = Document::new(200.0, 100.0);
let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) });
g.id = 3;
*doc.graphic_styles = vec![g];
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
let back = read_back(&doc);
let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) };
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0)));
assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
let mut doc = Document::new(200.0, 100.0);
let effects = vec![
+8 -1
View File
@@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) ->
}
/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as
/// `#rrggbb`.
/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader
/// gives every warp it reads the newest map, so a warp saved before the version existed is written
/// with its settings as before.
fn normalized_params(e: &Effect) -> Value {
let mut p = vectorcraft_effects::merged_params(&e.id, &e.params);
if e.id.starts_with("warp.")
&& let Value::Object(m) = &mut p
{
m.remove("version");
}
if let Some(m) = word(&p, "mode") {
p["mode"] = Value::String(m);
}
+1
View File
@@ -42,6 +42,7 @@ mod interp;
mod lex;
mod native;
mod obj;
mod predefined_cmap;
mod preview;
mod shading;
mod text;
+1
View File
@@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option<EnvelopeKind> {
h: percent("DeformHoriz")?,
v: percent("DeformVert")?,
horizontal: !vertical,
version: vectorcraft_doc::live::WARP_NET,
})
}
+2 -276
View File
@@ -14,12 +14,12 @@
use std::sync::Arc;
use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded};
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun};
use vectorcraft_geom::AnchorKind;
use super::super::super::predefined_cmap::{self, CMapCodes, composite_font};
use super::super::obj::V;
use super::art::path_data;
use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader};
@@ -284,259 +284,15 @@ impl TypeState {
/// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read.
fn cmap_codes(&mut self, cmap: &str) -> Option<Arc<CMapCodes>> {
if let Some(form) = unicode_cmap(cmap) {
return Some(Arc::new(CMapCodes::Unicode(form)));
}
if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) {
return codes.clone();
}
let codes = cid_codes(cmap).map(Arc::new);
let codes = predefined_cmap::codes(cmap).map(Arc::new);
self.cmaps.push((cmap.to_string(), codes.clone()));
codes
}
}
/// How a composite font's strings decode.
#[derive(Debug)]
enum CMapCodes {
/// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap;
/// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's
/// code space ([`code_space`]).
Cids { codes: Box<CMap>, unicode: Box<CMap>, high_bit: bool, space: Option<CodeSpace> },
/// A predefined Unicode CMap: the codes are the characters.
Unicode(Unicode),
}
/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the
/// code's length in bytes.
type CodeSpace = &'static [(&'static [u8], &'static [u8])];
/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab.
fn control(b: u8) -> Option<char> {
match b {
b'\r' => Some('\n'),
b'\t' => Some('\t'),
_ => None,
}
}
impl CMapCodes {
/// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a
/// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a
/// UTF-16 or UTF-32 string).
fn decode(&self, bytes: &[u8]) -> String {
let (codes, unicode, high_bit, space) = match self {
Self::Unicode(form) => {
let unit = match form {
Unicode::Utf16 => 2,
Unicode::Utf8 => 1,
Unicode::Utf32 => 4,
};
let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[]));
let mut text = unicode_text(whole, *form).replace('\r', "\n");
text.extend(rest.last().copied().and_then(control));
return text;
}
Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space),
};
let euc: Vec<u8>;
let bytes = if high_bit {
euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect();
euc.as_slice()
} else {
bytes
};
let mut out = String::new();
let mut at = 0;
while let Some(&first) = bytes.get(at) {
// VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every
// CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space
// has no one-byte codes.
if let Some(control) = control(first) {
out.push(control);
at += 1;
continue;
}
let rest = bytes.get(at..).unwrap_or_default();
let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)));
let found = match space {
// The code is as long as the code space range it falls in; a byte that starts no
// code in the code space is skipped.
Some(space) => {
let Some(len) = code_len(space, rest) else {
at += 1;
continue;
};
code(len).map(|c| (len, codes.lookup_cid_code(c, len)))
}
None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))),
};
let Some((len, cid)) = found else {
at += 1;
continue;
};
at += usize::from(len);
// A code the CMap leaves out is dropped.
let Some(cid) = cid else { continue };
match unicode.lookup_bf_string(cid) {
// A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in
// the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first.
Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)),
Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c),
Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)),
_ => {}
}
}
out
}
}
/// How a predefined Unicode CMap's codes encode their characters.
#[derive(Clone, Copy, PartialEq, Debug)]
enum Unicode {
/// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
Utf16,
Utf8,
/// Four bytes, the high byte first.
Utf32,
}
/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`,
/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V`
/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: <https://github.com/adobe-type-tools/cmap-resources>).
fn unicode_cmap(name: &str) -> Option<Unicode> {
let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?;
let rest = rest.strip_suffix("-HW").unwrap_or(rest);
let (collection, form) = rest.rsplit_once('-')?;
let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"];
if !collections.contains(&collection) {
return None;
}
match form {
"UCS2" | "UTF16" => Some(Unicode::Utf16),
"UTF8" => Some(Unicode::Utf8),
"UTF32" => Some(Unicode::Utf32),
_ => None,
}
}
/// The characters string `s` encodes in Unicode form `form` (invalid codes left out).
fn unicode_text(s: &[u8], form: Unicode) -> String {
match form {
Unicode::Utf16 => {
let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b));
char::decode_utf16(units).filter_map(Result::ok).collect()
}
Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(),
Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(),
}
}
/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose
/// codes are CIDs of no known character collection.
fn predefined(name: &str) -> Option<CMapName<'_>> {
match CMapName::from_bytes(name.as_bytes()) {
CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None,
name => Some(name),
}
}
/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V`
/// (<https://github.com/adobe-type-tools/cmap-resources>): the nearest carried CMap with the same
/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC
/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space
/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads.
fn stand_in(stem: &str) -> Option<Option<(&'static str, bool)>> {
Some(match stem {
"RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)),
"78-EUC" => Some(("EUC-H", false)),
"78" | "Add" | "Ext" | "NWP" => Some(("H", false)),
"GB" => Some(("GB-EUC-H", true)),
"CNS1" => Some(("CNS-EUC-H", true)),
"KSC" => Some(("KSC-EUC-H", true)),
"B5" => Some(("ETen-B5-H", false)),
"ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)),
"GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None,
_ => return None,
})
}
/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A
/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two
/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with
/// its [`stand_in`].
fn cid_codes(cmap: &str) -> Option<CMapCodes> {
let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded);
let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H"));
let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"));
let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| {
let (name, high_bit) = stand_in(stem?).flatten()?;
Some((read(name)?, high_bit))
})?;
let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?;
let unicode = CMap::parse(load_embedded(table)?, load_embedded)?;
// A row-and-cell code read in its EUC form falls in the EUC CMap's code space.
let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) };
Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space })
}
/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it
/// (<https://github.com/adobe-type-tools/cmap-resources>); a CMap's vertical form has its
/// horizontal form's. `None` for a CMap not listed here.
fn code_space(cmap: &str) -> Option<CodeSpace> {
const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")];
const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")];
const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")];
const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")];
const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")];
const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")];
const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")];
const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")];
const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")];
const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")];
const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")];
const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")];
const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")];
const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")];
let stem = match cmap {
"H" | "V" => "",
_ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?,
};
let stem = stem.strip_suffix("-HW").unwrap_or(stem);
Some(match stem {
"83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC,
_ if stem.ends_with("RKSJ") => RKSJ,
"EUC" | "78-EUC" => EUC_JP,
"" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL,
"GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC,
"GBpc-EUC" | "GBTpc-EUC" => GB_PC,
"GBK-EUC" | "GBKp-EUC" => GBK,
"GBK2K" => GBK2K,
"B5pc" => B5_PC,
"B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5,
"HKscs-B5" | "ETHK-B5" => HKSCS,
"HKdlb-B5" => HKDLB,
"HKgccs-B5" => HKGCCS,
"CNS-EUC" => CNS_EUC,
"KSCpc-EUC" => KSC_PC,
"KSCms-UHC" => UHC,
_ => return None,
})
}
/// The length of the code string `bytes` starts with: the length of the code space range whose
/// lowest and highest codes hold each of its bytes between them. `None` when no range does.
fn code_len(space: CodeSpace, bytes: &[u8]) -> Option<u8> {
space
.iter()
.filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b))))
.map(|(lo, _)| lo.len())
.min()
.and_then(|len| u8::try_from(len).ok())
}
/// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`.
fn encoding_pairs(table: &mut [Option<char>], vals: &[V]) {
let mut code: Option<usize> = None;
@@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option<char>], vals: &[V]) {
}
}
/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a
/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries
/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical
/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an
/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`.
fn composite_font(name: &str) -> Option<(&str, &str)> {
if !(name.ends_with("-H") || name.ends_with("-V")) {
return None;
}
// The longest CMap name first: `83pv-RKSJ-H` before `H`.
// No predefined CMap name is longer than 32 bytes.
for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) {
let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue };
if matches!(cmap, "Identity-H" | "Identity-V") {
return None;
}
let encoding_part =
base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part));
if matches!(cmap, "H" | "V") && encoding_part {
return None;
}
let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some());
let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some());
if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() {
return Some((base, cmap));
}
}
None
}
/// An operator's `N` operands, the last `N` values before it, when each is a finite number.
fn nums<const N: usize>(vals: &[V]) -> Option<[f64; N]> {
let operands = vals.get(vals.len().checked_sub(N)?..)?;
+1 -1
View File
@@ -1259,7 +1259,7 @@ mod tests {
fn envelopes_in_symbol_definitions_spend_the_imports_checks() {
use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys};
use vectorcraft_doc::live::EnvelopeKind;
let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET };
let envelope =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want));
let mut reader = Reader::new(b"");
+1
View File
@@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
"horizontal": pct("DeformHoriz"),
"vertical": pct("DeformVert"),
"orientation": if flag("Rotate") { "vertical" } else { "horizontal" },
"version": vectorcraft_doc::live::WARP_NET,
}),
)
}
+2
View File
@@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() {
// The warp is the whole object's; the characters paint below every item.
let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal")));
// A file's warp bends as its style's Bézier net.
assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
assert_eq!(st.contents_index, None);
}
+8 -6
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use kurbo::{Affine, Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::live::WARP_NET;
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
@@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() {
for (style, bend, h, v, rotate, id) in
[(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")]
{
let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 };
let want =
EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET };
let body =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, fidelity, editing, options) = live_envelope(&a[0]);
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal), (id, rotate == 0));
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET));
assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}");
assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW));
assert_eq!(content.len(), 1, "{content:#?}");
@@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() {
"twist",
];
for (n, id) in ids.iter().enumerate() {
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want))));
let a = art(&r.doc);
@@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() {
#[test]
fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
@@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
const WHITE: &str = "0 0 0 0 1 1 1 Xa\n";
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let content = envelope_content().replace(RED, WHITE);
let drawn = drawn_content(&other).replace(RED, WHITE);
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn);
+8 -24
View File
@@ -18,6 +18,7 @@ use vectorcraft_geom::BezPath;
use super::interp::{Interp, matrix_obj};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
use super::predefined_cmap::Unicode;
/// Most codespace ranges, character and notdef mappings a CMap may hold.
const MAX_RANGES: usize = 1 << 16;
@@ -210,16 +211,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option<u64> {
(n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b)))
}
/// How a predefined Unicode CMap's codes encode their characters.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Unicode {
/// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
Utf16,
Utf8,
/// Four bytes, the high byte first.
Utf32,
}
/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's
/// public CMap resources whose name says its codes are Unicode: `Uni` and a character
/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms),
@@ -246,18 +237,6 @@ pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> {
Some((form, vertical))
}
/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out).
pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String {
match form {
Unicode::Utf16 => {
let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b));
char::decode_utf16(units).filter_map(Result::ok).collect()
}
Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(),
Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(),
}
}
/// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for
/// `Identity-V`.
pub(super) fn identity(name: &str, vertical: bool) -> Obj {
@@ -417,8 +396,13 @@ impl Interp<'_> {
return Ok(d.clone());
}
let text = name.text().map(|t| t.to_string()).unwrap_or_default();
match unicode_cmap(&text) {
Some((_, vertical)) => {
// Another predefined CMap the file doesn't define, without its mappings: its codes are
// read by its name when type is set (see `text`).
let predefined = unicode_cmap(&text)
.map(|(_, vertical)| vertical)
.or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text)));
match predefined {
Some(vertical) => {
let mut d = Dict::new();
d.insert(Key::name("CMapName"), Obj::name(&text));
d.insert(Key::name("CMapType"), Obj::Int(1));
+4
View File
@@ -92,6 +92,9 @@ pub(crate) struct Interp<'a> {
pub cid_subrs: super::cid::CidSubrs,
/// The font number `usefont` gave the CMap mappings that follow.
pub cmap_font: i64,
/// The predefined CMaps type was set in so far, by name, with how their strings decode
/// (`None` when one can't be read; see `text`).
pub predefined_cmaps: Vec<(Rc<str>, Option<Rc<super::predefined_cmap::CMapCodes>>)>,
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
@@ -232,6 +235,7 @@ impl<'a> Interp<'a> {
cff: vec![],
cid_subrs: vec![],
cmap_font: 0,
predefined_cmaps: vec![],
type42_counted: HashSet::new(),
g,
saved: vec![],
+358
View File
@@ -0,0 +1,358 @@
//! Strings in fonts over Adobe's predefined CMaps (<https://github.com/adobe-type-tools/cmap-resources>)
//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the
//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters.
//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name.
use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded};
/// How a composite font's strings decode.
#[derive(Debug)]
pub(super) enum CMapCodes {
/// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap;
/// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's
/// code space ([`code_space`]).
Cids { codes: Box<CMap>, unicode: Box<CMap>, high_bit: bool, space: Option<CodeSpace> },
/// A predefined Unicode CMap: the codes are the characters.
Unicode(Unicode),
}
/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the
/// code's length in bytes.
type CodeSpace = &'static [(&'static [u8], &'static [u8])];
/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab.
fn control(b: u8) -> Option<char> {
match b {
b'\r' => Some('\n'),
b'\t' => Some('\t'),
_ => None,
}
}
impl CMapCodes {
/// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a
/// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a
/// UTF-16 or UTF-32 string).
pub(super) fn decode(&self, bytes: &[u8]) -> String {
let mut text = String::new();
self.each_code(bytes, |_, chars| text.extend(chars));
text
}
/// The codes of string `bytes`, each with its value and the characters it stands for (as
/// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts
/// no code, which counts as a code of its own).
pub(super) fn codes(&self, bytes: &[u8]) -> Decoded {
let mut out = Decoded::default();
self.each_code(bytes, |code, chars| out.push(code, chars));
out
}
/// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the
/// characters it stands for.
fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator<Item = char>)) {
let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b));
let (codes, unicode, high_bit, space) = match self {
Self::Unicode(form) => {
let unit = match form {
Unicode::Utf16 => 2,
Unicode::Utf8 => 1,
Unicode::Utf32 => 4,
};
let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[]));
unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter()));
if let Some(&last) = rest.last() {
emit(value(rest), &mut control(last).into_iter());
}
return;
}
Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space),
};
// A code's value is that of the string's bytes, also where they're read in their EUC form.
let original = bytes;
let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value);
let euc: Vec<u8>;
let bytes = if high_bit {
euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect();
euc.as_slice()
} else {
bytes
};
let mut at = 0;
while let Some(&first) = bytes.get(at) {
// VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every
// CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space
// has no one-byte codes.
if let Some(control) = control(first) {
emit(value_at(at, 1), &mut Some(control).into_iter());
at += 1;
continue;
}
let rest = bytes.get(at..).unwrap_or_default();
let code = |len: u8| rest.get(..usize::from(len)).map(value);
let found = match space {
// The code is as long as the code space range it falls in; a byte that starts no
// code in the code space is skipped.
Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))),
None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))),
};
let Some((len, cid)) = found else {
emit(value_at(at, 1), &mut None.into_iter());
at += 1;
continue;
};
let value = value_at(at, usize::from(len));
at += usize::from(len);
// A code the CMap leaves out stands for no character.
match cid.and_then(|cid| unicode.lookup_bf_string(cid)) {
// A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in
// the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first.
Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => {
emit(value, &mut Some(char::from(first)).into_iter())
}
Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()),
Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)),
_ => emit(value, &mut None.into_iter()),
}
}
}
}
/// A string's characters, and its codes: each code's value and how many of the characters it
/// stands for, in order.
#[derive(Debug, Default)]
pub(super) struct Decoded {
pub text: String,
pub codes: Vec<(u32, usize)>,
}
impl Decoded {
/// Code `code`, standing for characters `chars`.
pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator<Item = char>) {
let before = self.text.len();
self.text.extend(chars);
let n = self.text.get(before..).map_or(0, |added| added.chars().count());
self.codes.push((code, n));
}
}
/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read.
pub(super) fn codes(cmap: &str) -> Option<CMapCodes> {
match unicode_cmap(cmap) {
Some(form) => Some(CMapCodes::Unicode(form)),
None => cid_codes(cmap),
}
}
/// How a predefined Unicode CMap's codes encode their characters.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Unicode {
/// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
Utf16,
Utf8,
/// Four bytes, the high byte first.
Utf32,
}
/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`,
/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V`
/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: <https://github.com/adobe-type-tools/cmap-resources>).
fn unicode_cmap(name: &str) -> Option<Unicode> {
let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?;
let rest = rest.strip_suffix("-HW").unwrap_or(rest);
let (collection, form) = rest.rsplit_once('-')?;
let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"];
if !collections.contains(&collection) {
return None;
}
match form {
"UCS2" | "UTF16" => Some(Unicode::Utf16),
"UTF8" => Some(Unicode::Utf8),
"UTF32" => Some(Unicode::Utf32),
_ => None,
}
}
/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it
/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a
/// value past U+10FFFF).
fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option<char>)) {
match form {
Unicode::Utf16 => {
let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes);
let mut at = 0;
while let Some(u) = unit(at) {
let next = unit(at + 2);
let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n));
let len = if pair.is_some() { 4 } else { 2 };
let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok);
out(s.get(at..at + len).unwrap_or_default(), c);
at += len;
}
}
Unicode::Utf8 => {
let mut at = 0;
for chunk in s.utf8_chunks() {
for c in chunk.valid().chars() {
out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c));
at += c.len_utf8();
}
for _ in chunk.invalid() {
out(s.get(at..at + 1).unwrap_or_default(), None);
at += 1;
}
}
}
Unicode::Utf32 => {
for b in s.as_chunks::<4>().0 {
out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b)));
}
}
}
}
/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose
/// codes are CIDs of no known character collection.
fn predefined(name: &str) -> Option<CMapName<'_>> {
match CMapName::from_bytes(name.as_bytes()) {
CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None,
name => Some(name),
}
}
/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V`
/// (<https://github.com/adobe-type-tools/cmap-resources>): the nearest carried CMap with the same
/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC
/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space
/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads.
fn stand_in(stem: &str) -> Option<Option<(&'static str, bool)>> {
Some(match stem {
"RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)),
"78-EUC" => Some(("EUC-H", false)),
"78" | "Add" | "Ext" | "NWP" => Some(("H", false)),
"GB" => Some(("GB-EUC-H", true)),
"CNS1" => Some(("CNS-EUC-H", true)),
"KSC" => Some(("KSC-EUC-H", true)),
"B5" => Some(("ETen-B5-H", false)),
"ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)),
"GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None,
_ => return None,
})
}
/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A
/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two
/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with
/// its [`stand_in`].
fn cid_codes(cmap: &str) -> Option<CMapCodes> {
let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded);
let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H"));
let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"));
let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| {
let (name, high_bit) = stand_in(stem?).flatten()?;
Some((read(name)?, high_bit))
})?;
let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?;
let unicode = CMap::parse(load_embedded(table)?, load_embedded)?;
// A row-and-cell code read in its EUC form falls in the EUC CMap's code space.
let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) };
Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space })
}
/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it
/// (<https://github.com/adobe-type-tools/cmap-resources>); a CMap's vertical form has its
/// horizontal form's. `None` for a CMap not listed here.
fn code_space(cmap: &str) -> Option<CodeSpace> {
const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")];
const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")];
const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")];
const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")];
const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")];
const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")];
const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")];
const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")];
const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")];
const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")];
const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")];
const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")];
const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")];
const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")];
let stem = match cmap {
"H" | "V" => "",
_ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?,
};
let stem = stem.strip_suffix("-HW").unwrap_or(stem);
Some(match stem {
"83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC,
_ if stem.ends_with("RKSJ") => RKSJ,
"EUC" | "78-EUC" => EUC_JP,
"" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL,
"GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC,
"GBpc-EUC" | "GBTpc-EUC" => GB_PC,
"GBK-EUC" | "GBKp-EUC" => GBK,
"GBK2K" => GBK2K,
"B5pc" => B5_PC,
"B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5,
"HKscs-B5" | "ETHK-B5" => HKSCS,
"HKdlb-B5" => HKDLB,
"HKgccs-B5" => HKGCCS,
"CNS-EUC" => CNS_EUC,
"KSCpc-EUC" => KSC_PC,
"KSCms-UHC" => UHC,
_ => return None,
})
}
/// The length of the code string `bytes` starts with: the length of the code space range whose
/// lowest and highest codes hold each of its bytes between them. `None` when no range does.
fn code_len(space: CodeSpace, bytes: &[u8]) -> Option<u8> {
space
.iter()
.filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b))))
.map(|(lo, _)| lo.len())
.min()
.and_then(|len| u8::try_from(len).ok())
}
/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the
/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in
/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them.
pub(super) fn known(cmap: &str) -> bool {
let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some());
let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some());
unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other()
}
/// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`).
pub(super) fn vertical(cmap: &str) -> bool {
cmap == "V" || cmap.ends_with("-V")
}
/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a
/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one
/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise
/// end in `H`.
pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> {
if !(name.ends_with("-H") || name.ends_with("-V")) {
return None;
}
// The longest CMap name first: `83pv-RKSJ-H` before `H`.
// No predefined CMap name is longer than 32 bytes.
for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) {
let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue };
if matches!(cmap, "Identity-H" | "Identity-V") {
return None;
}
let encoding_part =
base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part));
if matches!(cmap, "H" | "V") && encoding_part {
return None;
}
if known(cmap) {
return Some((base, cmap));
}
}
None
}
+165
View File
@@ -403,6 +403,171 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() {
check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and");
}
/// The text objects of an import.
fn texts(r: &crate::Imported) -> Vec<vectorcraft_doc::TextObject> {
all(&r.document)
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some((**t).clone()),
_ => None,
})
.collect()
}
/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC,
/// JIS row and cell: <https://github.com/adobe-type-tools/cmap-resources>), the file defining
/// neither, its CIDFont only named: the codes are read by that CMap and its character
/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether
/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out.
#[test]
fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() {
let cases: &[(&str, &str, &str)] = &[
// 日 is 0x93FA and 本 0x967B in Shift-JIS.
("83pv-RKSJ-H", "93fa967b41", "日本A"),
("90ms-RKSJ-H", "93fa", "日"),
("EUC-H", "c6fc", "日"),
// JIS row and cell: 日 is 0x467C.
("H", "467c", "日"),
("GBK-EUC-H", "d6d0", "中"),
("B5pc-H", "a4a4", "中"),
("ETen-B5-H", "a4a4", "中"),
("KSCms-UHC-H", "c7d1", "한"),
// A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650.
("GB-H", "5650", "中"),
// 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code.
("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"),
];
for (cmap, hex, want) in cases {
let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show"));
assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings);
let t = texts(&r);
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), [*want], "{cmap}");
let style = &t[0].runs[0].style;
assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}");
assert!(!t[0].vertical, "{cmap}");
}
let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto <d6d041> show");
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "中A");
// The point moves past the type, along the line.
check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and");
}
/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`,
/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the
/// point moves that far, and the characters' tracking makes up the difference, as for type in a
/// font found by name. Over a vertical CMap the extra space runs down the column.
#[test]
fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() {
// Measured without painting.
let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore"));
let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont";
// `ashow` adds 5 to each of the two codes.
measured(&format!(
"{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt"
));
let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow"));
let t = texts(&r);
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日本"]);
assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs);
// `widthshow` adds 7 to the space, code 0x20.
measured(&format!(
"{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt"
));
// A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650.
let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont";
measured(&format!(
"{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt"
));
// `xshow` moves 30 a code.
measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt"));
// Down the column: `ashow`'s 5 down each code.
let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont";
measured(&format!(
"{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt"
));
let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow"));
let t = texts(&r);
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<String>(), "日本");
assert!(t.iter().all(|t| t.vertical), "{t:?}");
}
/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont
/// only named: the type is a vertical column, and the point moves down it, not along a line.
#[test]
fn named_cidfonts_over_vertical_cmaps_set_vertical_type() {
for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] {
let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show"));
let t = texts(&r);
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日本"], "{cmap}: {:?}", r.warnings);
assert!(t[0].vertical, "{cmap}");
check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and"));
}
}
/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one
/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that
/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as
/// showing the codes one at a time.
#[test]
fn codes_down_a_column_each_move_the_point_one_em() {
// Measured without painting.
let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont";
check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and"));
check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and"));
check(&format!(
"{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \
3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and"
));
}
/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show
/// operators that space it: characters the font would join in a ligature (`fi` in the bundled
/// Source Serif 4) are set without the ligature, and the point moves as far.
#[test]
fn named_cid_type_is_set_alike_by_every_show_operator() {
let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto";
let features = |show: &str| {
let r = super::tests::read(&format!("{set} <00660069> {show}"));
let t = texts(&r);
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["fi"], "{show}");
t[0].runs.iter().map(|r| r.style.features.clone()).collect::<Vec<_>>()
};
let plain = features("show");
assert_eq!(plain, features("0 0 3 -1 roll ashow"));
assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}");
check(&format!(
"gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \
20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and"
));
}
/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of
/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't
/// available is left out with a warning, and a long string in a CID-keyed font over a predefined
/// CMap shown in a loop runs out of the budget.
#[test]
fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() {
let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show");
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["AB"]);
let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill");
assert!(texts(&r).is_empty(), "{:?}", all(&r.document));
assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings);
super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop");
}
/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the
/// memory budget stops the program with an error before it is decoded.
#[test]
fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() {
// Codes the CMap doesn't map, so nothing is laid out when the string is decoded.
let huge = "\u{1}".repeat(17 << 20);
let r = import(&super::tests::eps(&format!(
"0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth"
)))
.unwrap();
assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
}
/// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the
/// file doesn't define, sets its characters as type at the font's size, not a thousand times it.
#[test]
+8 -1
View File
@@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String {
fn arc_envelope(text: &Node, bend: f64) -> Node {
let mut text = text.clone();
text.visible = true;
let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::live::EnvelopeKind::Warp {
style: "arc".into(),
bend,
h: 0.0,
v: 0.0,
horizontal: true,
version: vectorcraft_doc::live::WARP_NET,
};
Node::new(
vectorcraft_doc::NodeId(1_000_000),
NodeKind::Envelope {
+187 -74
View File
@@ -15,6 +15,7 @@ use super::cid::Base;
use super::graphics::MAX_APART;
use super::interp::{Interp, matrix_obj, matrix_of};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
use super::predefined_cmap::Decoded;
use super::type1::Embedded;
/// The font a program uses before it sets one.
@@ -170,8 +171,8 @@ impl Interp<'_> {
ResourceForAll => self.resource_for_all()?,
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
let (s, spacing) = self.show_operands(op)?;
if let Some((text, font)) = self.unicode_type(&s)? {
self.show_unicode(&text, &font)?;
if let Some((codes, font, vertical)) = self.named_cid_type(&s)? {
self.show_named_cid(&codes, &font, vertical, &spacing)?;
} else if let Some(glyphs) = self.composite_glyphs(&s)? {
self.show_composite(&glyphs, &spacing)?;
} else {
@@ -282,9 +283,9 @@ impl Interp<'_> {
/// How far `s` moves the current point (user space).
fn width_of(&mut self, s: &[u8]) -> Res<Vec2> {
if let Some((text, font)) = self.unicode_type(s)? {
if let Some((codes, font, vertical)) = self.named_cid_type(s)? {
let saved = self.g.font.replace(font);
let r = self.set_text(&text, Point::ZERO).map(|t| t.2);
let r = self.named_cid_width(&codes, vertical);
self.g.font = saved;
return r;
}
@@ -395,18 +396,29 @@ impl Interp<'_> {
}
/// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap
/// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a
/// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of
/// that name.
/// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font,
/// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap
/// resources, <https://github.com/adobe-type-tools/cmap-resources>); else a font of that name.
/// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the
/// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so
/// `Futura-H` stays a font of that name.
fn find_font(&mut self, k: &Obj) -> Res<DictRef> {
let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?;
if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) {
return Ok(d.clone());
}
let text = k.text().map(|t| t.to_string()).unwrap_or_default();
let split =
text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some());
if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) {
let split = text
.match_indices('-')
.map(|(i, _)| i)
.find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some())
.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?)))
.or_else(|| {
let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?;
let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some();
(!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap))
});
if let Some((cid_font, cmap)) = split {
let cmap = self.find_cmap(&Obj::name(cmap))?;
let dep = self.find_cid_font(&Obj::name(cid_font))?;
return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)]));
@@ -476,6 +488,15 @@ impl Interp<'_> {
/// [`Self::set_type`] for the characters `text`.
fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> {
self.set_text_in(text, at, false)
}
/// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top
/// center of its first em box, that moves the current point down the column by the layout's
/// advances. In vertical writing the current point is a glyph's origin 1, by default half its
/// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph
/// Metrics in CIDFonts", `DW2`).
fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> {
// Laying out type costs about as much as 300 operations, and 64 more a character
// (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends
// within the budget like any other loop instead of running for minutes.
@@ -493,11 +514,29 @@ impl Interp<'_> {
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.vertical = vertical;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size);
t.cached_bounds = Some(layout.bounds);
let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum();
Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO))
let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) };
Ok((t, layout, f1 * moved - f1 * Point::ZERO))
}
/// [`Self::set_text_in`] for characters the file places one by one: where the layout joins
/// characters in a ligature, the type is set without ligatures, so each character has its own
/// advance.
fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> {
let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?;
let glyph_starts: std::collections::HashSet<usize> = layout.glyphs.iter().map(|g| g.byte).collect();
if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) {
for run in &mut t.runs {
run.style.features.push("-liga".into());
}
layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(layout.bounds);
}
Ok((t, layout))
}
/// `show`: point type at the current point, which moves past it.
@@ -527,27 +566,30 @@ impl Interp<'_> {
return self.show_type42_outlines(data, glyphs, s, spacing, at, f1);
}
// Each code's character, if it has one that shows (control codes don't).
let chars: Vec<Option<char>> = match &plain {
let chars = match &plain {
Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::<Vec<_>>(),
None => self.named_chars(s).into_iter().map(Some).collect(),
};
let mut codes = Decoded::default();
for (c, &code) in chars.into_iter().zip(s) {
codes.push(u32::from(code), c.filter(|c| !c.is_control()));
}
.into_iter()
.map(|c| c.filter(|c| !c.is_control()))
.collect();
let text: String = chars.iter().flatten().collect();
let (mut t, mut layout, _) = self.set_text(&text, at)?;
let widths: Option<Vec<f64>> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect());
self.show_codes(&codes, widths.as_deref(), spacing, false, s)
}
/// The codes `codes` (each standing for some of its characters, or none) shown in the current
/// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths`
/// wide when given (in glyph space, one unit an em), else as wide as its characters' advances;
/// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the
/// string `kshow` gives its procedure the codes of.
fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res {
let at = self.user_point()?;
let (_, f1) = self.font()?;
let text = &codes.text;
let (mut t, layout) = self.set_placed_text(text, at, vertical)?;
let size = t.runs.first().map_or(1.0, |r| r.style.size);
let starts: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
// The file places each character's glyph: where the layout joins characters in a
// ligature, the type is set without ligatures, so each character has its own advance.
let glyph_starts: std::collections::HashSet<usize> = layout.glyphs.iter().map(|g| g.byte).collect();
if starts.iter().any(|b| !glyph_starts.contains(b)) {
for run in &mut t.runs {
run.style.features.push("-liga".into());
}
layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(layout.bounds);
}
// Each character's advance in the layout (text space).
let mut natural = vec![0.0; starts.len()];
for g in &layout.glyphs {
@@ -555,29 +597,30 @@ impl Interp<'_> {
*n += g.advance;
}
}
// Each code's move (user space) and that move in text space, along the baseline and across.
let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO;
// Each code's move (user space) and that move in text space, along the baseline (down the
// column) and across.
let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO;
let inv = f1.inverse();
let mut moves = Vec::with_capacity(s.len());
let mut moves = Vec::with_capacity(codes.codes.len());
let mut k = 0;
// What `kshow`'s procedure draws goes after the type.
let start = self.out.drawn.len();
let mut pen = at;
for (i, (&code, c)) in s.iter().zip(&chars).enumerate() {
let own = match &plain {
Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width),
None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size,
None => 0.0,
for (i, &(code, n)) in codes.codes.iter().enumerate() {
let own = match widths {
Some(widths) => widths.get(i).copied().unwrap_or(0.0),
None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::<f64>()) / size,
};
k += usize::from(c.is_some());
k += n;
let mut d = spacing.advance(i, i64::from(code), em(own));
d += self.kern(spacing, s, i, pen + d)?;
d += self.kern(spacing, kern, i, pen + d)?;
pen += d;
let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO;
if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) {
let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) };
if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) {
return ps_err("undefinedresult", "a show");
}
moves.push((d, e.x * size, e.y * size));
moves.push((d, along, across));
}
let total: Vec2 = moves.iter().map(|m| m.0).sum();
if !(total.x.is_finite() && total.y.is_finite()) {
@@ -589,12 +632,11 @@ impl Interp<'_> {
if across {
// Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it.
let mut pen = at;
for ((d, ..), c) in moves.iter().zip(&chars) {
if let Some(c) = c
&& painted
&& !c.is_whitespace()
{
let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?;
let mut chars = text.chars();
for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) {
let c: String = chars.by_ref().take(n).collect();
if painted && !c.trim().is_empty() {
let (t, ..) = self.set_text_in(&c, pen, vertical)?;
let clips = self.g.clips.clone();
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
}
@@ -605,11 +647,17 @@ impl Interp<'_> {
// a character, before the next, are that character's.
let mut lead = Vec2::ZERO;
let mut target: Vec<f64> = Vec::with_capacity(natural.len());
for ((d, along, _), c) in moves.iter().zip(&chars) {
match (c, target.last_mut()) {
(Some(_), _) => target.push(*along),
(None, Some(last)) => *last += along,
(None, None) => lead += *d,
for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) {
if n > 0 {
// A code of several characters: those before its last keep their advances.
let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec();
let rest = *along - before.iter().sum::<f64>();
target.extend(before);
target.push(rest);
} else if let Some(last) = target.last_mut() {
*last += along;
} else {
lead += *d;
}
}
let tracking: Vec<f64> = target
@@ -931,23 +979,47 @@ impl Interp<'_> {
.collect())
}
/// The characters of `s` and the font to set them in when the current font is a CID-keyed
/// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose
/// mappings to CIDs aren't known: its codes say the characters, set as type in the font of
/// its CIDFont's name.
fn unicode_type(&mut self, s: &[u8]) -> Res<Option<(String, DictRef)>> {
let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) };
/// The codes of `s` with the characters each stands for, the font to set them in and whether
/// they run down a column, when the current font is a CID-keyed font over a predefined CMap
/// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file
/// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap
/// of that name and its character collection's CID → Unicode CMap (a code it doesn't map
/// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical
/// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't
/// read is left out, with a warning.
fn named_cid_type(&mut self, s: &[u8]) -> Res<Option<(Decoded, DictRef, bool)>> {
let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) };
// Each code is looked up in the CMap's mappings.
self.spend(s.len() as u64)?;
// The decoded codes and characters are held while the string is shown: checked against
// the memory budget first, so a huge string in the file stops the program with an error.
let held = s.len().saturating_mul(DECODED_BYTES);
self.alloc(held)?;
self.unalloc(held);
let decoded = match self.predefined_codes(&cmap) {
Some(codes) => codes.codes(s),
None => {
self.out.warn(UNREAD_CMAP);
Decoded::default()
}
};
let mut codes = Decoded::default();
let mut chars = decoded.text.chars();
for &(code, n) in &decoded.codes {
codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control()));
}
// The composite font is copied under the CIDFont's name for every string: charged by
// its size, so a huge font dictionary shown in a loop runs out of the budget.
let entries = font.borrow().len();
self.spend(entries as u64)?;
let mut named = font.borrow().clone();
named.insert(Key::name("FontName"), name);
Ok(Some((text, DictRef::new(named))))
Ok(Some((codes, DictRef::new(named), vertical)))
}
/// The text, the composite font and the CIDFont's name of [`Self::unicode_type`].
fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> {
/// The CMap's name, the composite font, the CIDFont's name and the writing mode of
/// [`Self::named_cid_type`].
fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> {
let font = self.g.font.clone()?;
let f = font.borrow();
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) {
@@ -959,29 +1031,63 @@ impl Interp<'_> {
if cmap.contains_key(&Key::name("CodeSpaceRange")) {
return None;
}
let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?;
let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string();
if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) {
return None;
}
let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num);
let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None };
let name = {
let d = dep.borrow();
d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()?
};
Some((super::cid::unicode_text(s, form), font.clone(), name))
Some((cmap_name, font.clone(), name, wmode == Some(1.0)))
}
/// `text` set as point type at the current point in font `font` (see
/// [`Self::unicode_type`]), which moves past it.
fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res {
let at = self.user_point()?;
let saved = self.g.font.replace(font.clone());
let r = self.set_text(text, at);
self.g.font = saved;
let (t, _, adv) = r?;
if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() {
let clips = self.g.clips.clone();
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
/// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a
/// file); `None` when VectorCraft can't read it.
fn predefined_codes(&mut self, cmap: &str) -> Option<Rc<super::predefined_cmap::CMapCodes>> {
if let Some((form, _)) = super::cid::unicode_cmap(cmap) {
return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form)));
}
self.g.cur = Some(self.xf() * (at + adv));
Ok(())
if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) {
return codes.clone();
}
let codes = super::predefined_cmap::codes(cmap).map(Rc::new);
// Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the
// list stays short.
self.predefined_cmaps.push((Rc::from(cmap), codes.clone()));
codes
}
/// The codes `codes` (with their characters) set as point type at the current point in font
/// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves
/// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and
/// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed.,
/// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's
/// codes aren't single bytes). Down a column each code moves the point one em, the default
/// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7,
/// §5.6.3, `DW2`).
fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res {
let widths = vertical.then(|| vec![1.0; codes.codes.len()]);
let saved = self.g.font.replace(font.clone());
let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]);
self.g.font = saved;
r
}
/// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`]
/// shows them with no extra spacing.
fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res<Vec2> {
let (_, f1) = self.font()?;
let moved = if vertical {
Point::new(0.0, -(codes.codes.len() as f64))
} else {
let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?;
let size = t.runs.first().map_or(1.0, |r| r.style.size);
Point::new(layout.glyphs.iter().map(|g| g.advance).sum::<f64>() / size, 0.0)
};
Ok(f1 * moved - f1 * Point::ZERO)
}
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
@@ -1282,6 +1388,13 @@ const MAX_TYPE1: usize = 256;
/// The warning for CID-keyed type in a CIDFont the file doesn't carry.
const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out";
/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16
/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code).
const DECODED_BYTES: usize = 64;
/// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have.
pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out";
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
const MAX_TYPE42: usize = 16;
+4 -1
View File
@@ -33,7 +33,10 @@
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the
//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard
//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal
//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is
//! written for type that has neither mark. Only the
//! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their
//! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to
//! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding
+1 -1
View File
@@ -106,7 +106,7 @@ VCLegacy begin
/TE {cleartomark} bd /TZ {cleartomark} bd
/To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd
/Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd
/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd
/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd
% The page trailer (3): annotatepage marks nothing.
/annotatepage {} bd
end
+53
View File
@@ -1193,6 +1193,59 @@ fn variable_font_settings_are_warned_as_left_out() {
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
}
/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a
/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back
/// on the same characters, a block is closed before its text object ends, and type with neither
/// mark has neither operator.
#[test]
fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() {
use vectorcraft_doc::{TextObject, TextRun};
let plain = style(12.0, Paint::solid(Color::BLACK));
let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() };
let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() };
let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() };
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone());
for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] {
marked.runs.push(TextRun::new(text, st.clone()));
}
let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone());
for t in [marked, next] {
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let out = write(&doc);
assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
let text = String::from_utf8(out.bytes.clone()).unwrap();
assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}");
assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}");
// The last block ends before its text object does.
assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}");
let read = texts(&crate::import(&out.bytes).unwrap().document);
let flags = |t: &TextObject| -> Vec<(String, bool, bool)> {
let mut out: Vec<(String, bool, bool)> = vec![];
for r in &t.runs {
let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman);
match out.last_mut() {
Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text),
_ => out.push((r.text.clone(), key.0, key.1)),
}
}
out
};
let s = |x: &str| x.to_string();
assert_eq!(
flags(&read[0].1),
[(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)]
);
assert_eq!(flags(&read[1].1), [(s("D"), false, false)]);
// Type with neither mark has neither operator.
let text = String::from_utf8(write(&typed()).bytes).unwrap();
assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}");
}
/// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path
/// or end bracket: type on a path with either is written as text, with the warning that they are
/// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A
+20 -1
View File
@@ -39,6 +39,10 @@ pub(super) struct Writer<'a> {
in_hidden: bool,
/// The fonts type is set in: PostScript name, and whether it's TrueType.
pub(super) fonts: Vec<(String, bool)>,
/// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects).
roman: bool,
/// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type).
kumi: bool,
}
impl<'a> Writer<'a> {
@@ -59,6 +63,8 @@ impl<'a> Writer<'a> {
hidden_art: vec![],
in_hidden: false,
fonts: vec![],
roman: false,
kumi: false,
}
}
@@ -819,6 +825,10 @@ impl<'a> Writer<'a> {
self.line(" Tx");
}
}
if self.kumi {
self.line("0 TY");
self.kumi = false;
}
self.line("TO");
}
@@ -838,7 +848,8 @@ impl<'a> Writer<'a> {
}
/// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`,
/// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`.
/// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY`
/// where Standard Vertical Roman Alignment or Tate-chu-yoko changes.
fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) {
let fill = !st.fill.is_none();
let stroke = st.has_stroke();
@@ -876,6 +887,14 @@ impl<'a> Writer<'a> {
let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size));
ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]);
self.line("Tl");
if st.standard_vertical_roman != self.roman {
self.roman = st.standard_vertical_roman;
self.line(if self.roman { "1 Tv" } else { "0 Tv" });
}
if st.tate_chu_yoko != self.kumi {
self.kumi = st.tate_chu_yoko;
self.line(if self.kumi { "1 TY" } else { "0 TY" });
}
}
/// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill
+133 -1
View File
@@ -2,7 +2,7 @@
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
//! selection tools drag its brackets by distance along the path).
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2};
use crate::PathData;
@@ -76,6 +76,78 @@ impl ArcPath {
pub fn fraction_at(&self, p: Point) -> Option<f64> {
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
}
/// The distance along the path of the middle of its topmost points (the least y) where `xf`
/// draws it: the topmost point when there is one, the middle of a level stretch (a
/// rectangle's top edge), and when several points or stretches are topmost, the middle of the
/// shortest part of the path that holds them all. None for a path with no length, or a closed
/// path whose every point is topmost.
pub fn topmost(&self, xf: Affine) -> Option<f64> {
let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect();
// The candidates for the least y in each segment: its ends and where it turns.
let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::<Vec<f64>>();
let mut bounds: Option<kurbo::Rect> = None;
let mut top = f64::INFINITY;
for (_, d, _, _) in &drawn {
let b = d.bounding_box();
bounds = Some(bounds.map_or(b, |a| a.union(b)));
top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min);
}
let b = bounds?;
if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) {
return None;
}
// Points this close to the top count as topmost: a part of the drawn size, and no less than
// the rounding error of coordinates this far from the origin.
let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs()));
// Stretches this close along the path meet.
let near = 1e-9 * self.len;
// The topmost points as stretches of distance along the path, in order.
let mut runs: Vec<(f64, f64)> = vec![];
for (s, d, c, l) in &drawn {
let db = d.bounding_box();
if db.y1 - db.y0 <= tol && db.y0 <= top + tol {
runs.push((*c, c + l));
continue;
}
for t in turns(d) {
if d.eval(t).y <= top + tol {
let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l);
runs.push((at, at));
}
}
}
runs.sort_by(|a, b| a.0.total_cmp(&b.0));
// Stretches that meet or overlap are one.
let mut merged: Vec<(f64, f64)> = vec![];
for (a, z) in runs {
match merged.last_mut() {
Some(last) if a <= last.1 + near => last.1 = last.1.max(z),
_ => merged.push((a, z)),
}
}
let (first, last) = (*merged.first()?, *merged.last()?);
if !self.closed {
return Some((first.0 + last.1) * 0.5);
}
let len = self.len;
// Round a closed path, the shortest part holding every stretch leaves out the longest
// gap between two of them (the gap from the last round to the first included).
let wrap = first.0 + len - last.1;
let (mut gap, mut from, mut to) = (wrap, first.0, last.1);
for w in merged.windows(2) {
if let [a, b] = w
&& b.0 - a.1 > gap
{
(gap, from, to) = (b.0 - a.1, b.0, a.1);
}
}
if gap <= near {
return None;
}
let run = (to - from).rem_euclid(len);
Some((from + run * 0.5).rem_euclid(len))
}
}
#[cfg(test)]
@@ -99,4 +171,64 @@ mod tests {
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
assert!(ArcPath::new(&PathData::default()).is_empty());
}
fn polyline(points: &[Point], closed: bool) -> PathData {
PathData::single(crate::SubPath::polyline(points, closed))
}
/// Where `topmost` puts the middle, drawn by `xf`.
fn top_point(a: &ArcPath, xf: Affine) -> Point {
xf * a.at(a.topmost(xf).unwrap()).0
}
#[test]
fn topmost_points_as_drawn() {
// A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its
// bottom three quarters.
let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)));
assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6);
assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6);
// As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top.
let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0));
assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6);
// Turned part way, the top lies inside a segment.
let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0));
assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted));
// An ellipse squashed and skewed by its transform.
let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5);
let p = top_point(&circle, skewed);
let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min);
assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}");
// A level top: the middle of the top edge, for a rectangle and a rounded rectangle.
for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] {
let a = ArcPath::new(&path);
assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY));
assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6);
}
// Far from the origin the top is still the top edge's middle.
let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
let far = Affine::translate((1e12, 1e12));
assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far));
// A level top across the path's start: the start in the middle of the top edge.
let across = ArcPath::new(&polyline(
&[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)],
true,
));
assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6);
// Two peaks: the middle of the shortest part that holds both, the dip between them.
let peaks = ArcPath::new(&polyline(
&[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)],
true,
));
assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY));
// An open path: the middle of its topmost points too.
let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false));
assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6);
// No top: a closed path that is level all round, and a path with no length.
let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0)));
assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none());
assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none());
let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true));
assert!(point.topmost(Affine::IDENTITY).is_none());
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result<Output, String
let doc = baked.as_ref().unwrap_or(doc);
let (ops, mut warnings) = scene::Scene::new(doc, r, kind == Kind::Emf).run();
let bytes = match kind {
Kind::Emf => emf::write(&ops, r, &doc.title),
Kind::Emf => emf::write(&ops, r, doc.info_title()),
Kind::Wmf => wmf::write(&ops, r, &mut warnings),
};
Ok(Output { bytes, warnings })
+4 -2
View File
@@ -53,7 +53,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let raised = for_long_pages(set, &pages)?;
let set = raised.as_ref().unwrap_or(set);
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
// The file's Title is File Info's; the page information names the document, as its tab does.
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned()));
let name = opts.title.as_deref().unwrap_or(&doc.title);
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
@@ -108,7 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
let (m, r) = (boxes.media, boxes.bleed);
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() };
let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
// The page is the media box, y down from its top-left corner (like ours), in units of
// `unit` points.
+4
View File
@@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace
/// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileInfo {
/// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`.
pub title: Option<String>,
/// XMP `dc:creator` (its names joined with ", "), else Info `Author`.
pub author: Option<String>,
/// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`.
@@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default();
let info = pdf.metadata();
let text = |b: &Option<Vec<u8>>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
f.title = f.title.or_else(|| text(&info.title));
f.author = f.author.or_else(|| text(&info.author));
f.description = f.description.or_else(|| text(&info.subject));
f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty()));
@@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option<FileInfo> {
let creators = get(DC, "creator");
let subjects = get(DC, "subject");
Some(FileInfo {
title: first(DC, "title"),
author: (!creators.is_empty()).then(|| creators.join(", ")),
description: first(DC, "description"),
keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
+24 -2
View File
@@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String {
}
const INFO: &str = "/Author (Grace Hopper) /Subject <FEFF00430061007400610020006C006F0067> /Keywords (navy; compilers, , cobol) \
/CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)";
/CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)";
/// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00).
const CREATED: i64 = 1_791_201_780;
@@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() {
assert_eq!(
f,
FileInfo {
title: Some("Compiler manual".into()),
author: Some("Grace Hopper".into()),
description: Some("Cata log".into()),
keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]),
@@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() {
fn the_xmp_packet_gives_file_info() {
let body = description(
"xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"",
"<dc:creator><rdf:Seq><rdf:li>Ada Lovelace</rdf:li><rdf:li>Charles Babbage</rdf:li></rdf:Seq></dc:creator>\
"<dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Plakat</rdf:li><rdf:li xml:lang=\"x-default\">Poster</rdf:li></rdf:Alt></dc:title>\
<dc:creator><rdf:Seq><rdf:li>Ada Lovelace</rdf:li><rdf:li>Charles Babbage</rdf:li></rdf:Seq></dc:creator>\
<dc:description><rdf:Alt><rdf:li xml:lang=\"de\">Ein Plakat</rdf:li><rdf:li xml:lang=\"x-default\">A poster\nfor the fair</rdf:li></rdf:Alt></dc:description>\
<dc:subject><rdf:Bag><rdf:li>fair</rdf:li><rdf:li> print </rdf:li><rdf:li></rdf:li></rdf:Bag></dc:subject>\
<dc:rights><rdf:Alt><rdf:li xml:lang=\"en\">(c) 2026 Ada</rdf:li></rdf:Alt></dc:rights>\
@@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() {
assert_eq!(
f,
FileInfo {
title: Some("Poster".into()),
author: Some("Ada Lovelace, Charles Babbage".into()),
description: Some("A poster\nfor the fair".into()),
keywords: Some(vec!["fair".into(), "print".into()]),
@@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() {
assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60));
assert_eq!(f.description.as_deref(), Some("Cata log"));
assert_eq!(f.modified, Some(MODIFIED));
// The title is the packet's where it gives one (a blank `x-default` item leaves the next one),
// else the dictionary's.
assert_eq!(f.title.as_deref(), Some("Compiler manual"));
let body =
description("", "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\"> </rdf:li><rdf:li xml:lang=\"fr\">Manuel</rdf:li></rdf:Alt></dc:title>");
let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap();
assert_eq!(f.title.as_deref(), Some("Manuel"));
}
#[test]
@@ -197,17 +207,29 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() {
#[test]
fn a_written_pdf_gives_its_file_info_back() {
let mut d = Document::new(100.0, 100.0);
d.title = "Fair poster".into();
d.metadata.author = "Ada Lovelace".into();
d.metadata.description = "A poster\nfor the fair".into();
d.metadata.keywords = vec!["fair".into(), "print".into()];
let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
let f = file_info(&bytes, None).unwrap();
assert_eq!(f.title.as_deref(), Some("Fair poster"));
assert_eq!(f.author.as_deref(), Some("Ada Lovelace"));
assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair"));
assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()]));
assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED)));
}
#[test]
fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() {
let mut d = Document::new(100.0, 100.0);
d.title = "poster.svg".into();
d.metadata.title = Some("Fair poster".into());
let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster"));
assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg"));
}
#[test]
fn a_written_pdf_gives_its_copyright_back() {
use vectorcraft_doc::CopyrightStatus;
+2 -2
View File
@@ -796,7 +796,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) });
}
let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY });
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(first)],
kind: Box::new(kind),
@@ -830,7 +830,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() });
}
let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY })));
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: content.to_vec(),
kind: Box::new(kind),
+3 -3
View File
@@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() {
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) });
let l = d.layers[0].id;
for (i, content) in [typed, shown].into_iter().enumerate() {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
@@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style))));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(typed)],
kind: Box::new(kind),
@@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
const FAMILY: &str = "Mapped Serif 4";
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = Node::new(
vectorcraft_doc::NodeId(1),
NodeKind::Envelope {
+2 -2
View File
@@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() {
d.patterns.push(def);
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0));
let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() };
let envelope = Node::new(
d.alloc_id(),
@@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() {
/// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort
/// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it.
fn arc_envelope(d: &mut Document, content: Vec<Node>, patterns: bool) -> Node {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() };
let content = content.into_iter().map(Arc::new).collect();
Node::new(
+1 -1
View File
@@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] {
/// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no
/// bend), with Distort Appearance on or off.
fn envelope_doc(content: Node, distort_appearance: bool) -> Document {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
+1 -1
View File
@@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() {
let placed = d.remove(NodeId(50)).unwrap();
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") };
let p = (**p).clone();
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![placed],
kind: Box::new(kind),
+1 -1
View File
@@ -188,7 +188,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
// An artboard exported on its own is named after it (#3544), else after the document.
// A name that is blank once the characters XML can't hold are dropped falls back too.
let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name));
let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title));
let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title()));
if !title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{title}</title>{nl}"));
+19
View File
@@ -121,6 +121,21 @@ impl Reuse {
}
}
/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]):
/// more than one, each a clip group of a triangle and one and the same image?
fn image_pieces(children: &[std::sync::Arc<Node>]) -> bool {
let mut first: Option<(&str, u32, u32)> = None;
children.len() > 1
&& children.iter().all(|c| {
let NodeKind::Group { children, clip: true } = &c.kind else { return false };
let [clip, im] = children.as_slice() else { return false };
let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false };
let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3;
let image = (im.key.as_str(), im.width, im.height);
triangle && *first.get_or_insert(image) == image
})
}
/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas
/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement
/// and the art of unlinked opacity masks.
@@ -678,6 +693,10 @@ impl Writer<'_> {
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
// The pieces a Warp effect cuts an image into share its pixels, as an envelope's do.
if image_pieces(children) {
self.share_images(n);
}
let id = self.id_attr(n);
let a = self.attrs(&self.group_props(n));
self.line(&format!("<g{id}{a}>"));
+1 -1
View File
@@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
let info = &doc.metadata;
let mut terms = vec![("format", "image/svg+xml".to_string())];
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
+4 -1
View File
@@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN;
use importer::SymbolMade;
pub(super) use layers::DEFAULT_FONT_SIZE;
pub(crate) use layers::SUBLAYER;
use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name};
use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title};
pub(super) use prepass::Edits;
use prepass::{Extras, labels, prepass};
pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE};
@@ -158,6 +158,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
// The file's own title, when it gives one, is the document's File Info title; the document's
// name stays what `Document::new` gave it, for the caller to name after the file.
doc.metadata.title = root_title(xml.root_element());
let mut im = Importer {
doc,
warnings,
+10
View File
@@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option<String> {
root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)
}
/// The title the file gives root element `root`: the text of its first `<title>` child (the one
/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is
/// that element's tooltip, not the document's), its runs of white space made single spaces. `None`
/// without one, or when it is blank.
pub(super) fn root_title(root: XNode) -> Option<String> {
let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?;
let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect();
(!text.is_empty()).then(|| text.join(" "))
}
/// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or
/// the label beside it) is its name.
pub(super) fn marked_layer(g: XNode) -> bool {
@@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() {
assert!(svg.contains("<dc:subject>kw</dc:subject>"));
assert!(svg.contains("<dc:rights>(c) me</dc:rights>"));
}
/// The root's `<title>` is the File Info title of the imported document ([`Document::info_title`]);
/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip.
#[test]
fn the_roots_title_is_the_file_info_title_not_the_documents_name() {
let svg = |inner: &str| {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip</title></rect></svg>"#)
};
let d = import(&svg("<title>\n Access\n paths </title>")).unwrap();
assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths"));
for none in [svg(""), svg("<title> </title>")] {
let d = import(&none).unwrap();
assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1"));
}
}
/// Exports write the File Info title, where the document has one, in place of its name.
#[test]
fn exports_write_the_file_info_title() {
let mut d = Document::new(200.0, 200.0);
d.title = "box.svg".into();
d.metadata.title = Some("Access paths".into());
let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() });
assert!(svg.contains("<title>Access paths</title>") && svg.contains("<dc:title>Access paths</dc:title>"), "{svg}");
assert!(!svg.contains("box.svg"), "{svg}");
}
+2 -2
View File
@@ -1543,7 +1543,7 @@ mod tests {
let d = doc_with_live_over_green(|d| {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0);
let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a);
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let n = Node::new(
d.alloc_id(),
@@ -1574,7 +1574,7 @@ mod tests {
use vectorcraft_geom::{Affine, Rect};
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let options = Default::default();
Node::new(
+16 -1
View File
@@ -153,7 +153,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
match kind {
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())),
TextKind::OnPath { .. } => {
items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none()));
center_on_path_items(app, &mut items);
}
}
// Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does;
// left out for point type and type in a thread, which is VectorCraft's own rule
@@ -350,6 +353,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
}
/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a
/// closed path.
fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) {
for (label, icon, id) in
[(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")]
{
if crate::menus::enabled(app, id) {
items.push((label, icon, id, json!({})));
}
}
}
/// The Contextual Task Bar's Image Trace button: a trace with the Default preset.
const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace";
+7
View File
@@ -882,6 +882,13 @@ fn command(d: &Dialog) -> (&'static str, Value) {
p["item"] = item.unwrap_or(Value::Null);
p["params"] = params;
let id = d.str("__effect");
// Each target keeps the map its own Warp draws with: the dialog's `version` is the first
// target's.
if super::warp_effect::is_warp(&id)
&& let Some(Value::Object(m)) = p.get_mut("params")
{
m.remove("version");
}
// The displayed style comes from the first target. Preserve other targets' Warp IDs
// unless the user deliberately chooses Style, including after a preview rollback.
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
+1 -1
View File
@@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?;
let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None };
let bar = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => {
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
}
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
+30
View File
@@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
assert_eq!(app.session.doc().unwrap().doc, moved);
}
}
/// Warp Options editing the Warps of two objects, one new and one saved without a map version,
/// changes the Bend of both and keeps each one's own `version`.
#[test]
fn warp_options_edit_keeps_each_targets_map_version() {
let (mut app, first) = app();
app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
// As a file saved before the version existed gives it.
let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc);
if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
p.remove("version");
}
app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap();
app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS);
let ctx = context();
settle(&mut app, &ctx);
click_text(&mut app, &ctx, "Edit");
settle(&mut app, &ctx);
make_warp_edit(&mut app, &ctx, "bend");
click_text(&mut app, &ctx, "OK");
assert!(app.ui.dialog.is_none());
let doc = app.session.doc().unwrap().doc.clone();
let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone();
assert_eq!((&params(first)["bend"], &params(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET)));
assert_eq!(params(second)["bend"], json!(25.0));
assert!(params(second).get("version").is_none(), "{:?}", params(second));
}
+5 -3
View File
@@ -1,7 +1,7 @@
//! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema.
use egui::{Pos2, Rect, Stroke, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point};
use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle};
use super::form;
use crate::state::Dialog;
@@ -38,6 +38,8 @@ fn shown(label: &str) -> String {
fn style_preview(ui: &mut egui::Ui, suffix: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover());
let Some(style) = WarpStyle::from_id(suffix) else { return };
// The map new warps take, on a box 0.55 times as high as it is wide.
let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false);
let mut lines = vec![];
let mut bounds = Rect::NOTHING;
for vertical in [false, true] {
@@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) {
.map(|i| {
let along = -1.0 + f64::from(i) / 8.0;
let (x, y) = if vertical { (fixed, along) } else { (along, fixed) };
let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y);
let p = Pos2::new(x as f32, y as f32);
let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0);
let p = Pos2::new(q.x as f32, q.y as f32);
bounds.extend_with(p);
p
})
+2
View File
@@ -3634,6 +3634,8 @@ digits Malayalam Малаялам
Type on a Path Текст по пътя
Type on a Path Options Опции за текст по пътя
Type on a Path Options… Опции за текст по път…
Center on Top Центриране горе
Center on Bottom Центриране долу
Type on a Path Tool Инструмент „Текст по път“
Type Orientation Ориентация на текста
Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -398,6 +398,8 @@
Stair Step Stepenice
Gravity Gravitacija
Type on a Path Options… Opcije teksta na putanji…
Center on Top Centriraj na vrhu
Center on Bottom Centriraj na dnu
Threaded Text Povezani tekst
Create Kreiraj
Release Selection Oslobodi odabir
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -2933,6 +2933,10 @@ digits Malayalam Malaiàlam
Arch Volta
@msg select point type or type on a path seleccioneu text de punt o text sobre un traçat
@msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open}
@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames}
@msg select type on a closed path seleccioneu text sobre un traçat tancat
Optimize for fast web view Optimitza per a la visualització web ràpida
Optimized Optimitzat
Options Opcions
@@ -4711,6 +4715,8 @@ digits Malayalam Malaiàlam
Type on a Path Text sobre un traçat
Type on a Path Options Opcions de text sobre un traçat
Type on a Path Options… Opcions de text sobre un traçat…
Center on Top Centra a dalt
Center on Bottom Centra a baix
Type on a Path Tool Eina de text sobre un traçat
Type Optimized Optimitzat per al text
Type Orientation Orientació del text
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -405,6 +405,8 @@
Stair Step Schody
Gravity Gravitace
Type on a Path Options… Možnosti textu na cestě…
Center on Top Vystředit nahoře
Center on Bottom Vystředit dole
Threaded Text Zřetězený text
Create Vytvořit
Release Selection Uvolnit výběr
@@ -425,6 +427,10 @@
Arch Klenba
@msg select point type or type on a path vyberte bodový text nebo text na cestě
@msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open}
@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames}
@msg select type on a closed path vyberte text na uzavřené cestě
Fill with Placeholder Text Vyplnit zástupným textem
Insert Inline Symbol Vložit symbol do textu
Show Hidden Characters Zobrazit skryté znaky
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -366,6 +366,8 @@
Stair Step Trappetrin
Gravity Tyngdekraft
Type on a Path Options… Indstillinger for tekst på kurve…
Center on Top Centrer øverst
Center on Bottom Centrer nederst
Threaded Text Sammenkædet tekst
Create Opret
Release Selection Frigør markering
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -2322,6 +2322,10 @@ file-filter Templates Vorlagen
Arch Rundbogen
@msg select point type or type on a path wählen Sie Punkttext oder Pfadtext aus
@msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open}
@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames}
@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus
Optimize for fast web view Für schnelle Webanzeige optimieren
Optimized Optimiert
Options Optionen
@@ -3677,6 +3681,8 @@ effect Points Punkte
Type on a Path Pfadtext
Type on a Path Options Pfadtextoptionen
Type on a Path Options… Pfadtextoptionen…
Center on Top Oben zentrieren
Center on Bottom Unten zentrieren
Type on a Path Tool Pfadtext-Werkzeug
Type Orientation Textrichtung
Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -3095,6 +3095,10 @@ theme Appearance Εμφάνιση
Arch Αψίδα
@msg select point type or type on a path επιλέξτε κείμενο σημείου ή κείμενο σε διαδρομή
@msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open}
@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames}
@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή
Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό
Options for Selection… Επιλογές για την επιλογή…
or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη
@@ -4067,6 +4071,8 @@ digits Malayalam Μαλαγιάλαμ
Type on a Path Κείμενο σε διαδρομή
Type on a Path Options Επιλογές κειμένου σε διαδρομή
Type on a Path Options… Επιλογές κειμένου σε διαδρομή…
Center on Top Κεντράρισμα επάνω
Center on Bottom Κεντράρισμα κάτω
Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool)
Type Orientation Προσανατολισμός κειμένου
Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του.
Can't render this file because it is too large.
+6
View File
@@ -2360,6 +2360,10 @@ font-technology Variable Variables
Arch Bóveda
@msg select point type or type on a path seleccione texto de punto o texto en trazado
@msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open}
@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames}
@msg select type on a closed path seleccione texto en un trazado cerrado
Optimize for fast web view Optimizar para vista web rápida
Optimized Optimizado
Options Opciones
@@ -3723,6 +3727,8 @@ digits Malayalam Malayálam
Type on a Path Texto en trazado
Type on a Path Options Opciones de texto en trazado
Type on a Path Options… Opciones de texto en trazado…
Center on Top Centrar arriba
Center on Bottom Centrar abajo
Type on a Path Tool Herramienta Texto en trazado
Type Orientation Orientación del texto
Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -402,6 +402,8 @@
Stair Step Porras
Gravity Painovoima
Type on a Path Options… Teksti polulla -asetukset…
Center on Top Keskitä ylös
Center on Bottom Keskitä alas
Threaded Text Ketjutettu teksti
Create Luo
Release Selection Vapauta valinta
@@ -422,6 +424,10 @@
Arch Holvi
@msg select point type or type on a path valitse pisteteksti tai polkuteksti
@msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames}
@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open}
@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat}
@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames}
@msg select type on a closed path valitse suljetulla polulla oleva teksti
Fill with Placeholder Text Täytä paikkamerkkitekstillä
Insert Inline Symbol Lisää tekstinsisäinen symboli
Show Hidden Characters Näytä piilotetut merkit
Can't render this file because it contains an unexpected character in line 2 and column 73.

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