Merge remote-tracking branch 'upstream/main' into fix/ai-type-pattern-paint

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
jmverges
2026-11-12 14:14:56 +00:00
190 changed files with 6248 additions and 879 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)),
+1 -1
View File
@@ -378,7 +378,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
// Load Actions… refuses a file over its size limit without reading all of it.
read_head: Some(Box::new(read_file_head)),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
+116 -30
View File
@@ -18,15 +18,19 @@
//! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older
//! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range.
//! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with
//! `sort`, each next path the one that starts nearest where the pen stopped.
//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`,
//! a pen draws a path only after every path of its own that lies inside it (a letter's counter
//! before the letter, a hole before the part).
//!
//! HP-GL writes `IN;`, then for each pen `PU;SP<n>;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`.
//! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths
//! as one encoded polyline (`PE`, base 64).
mod clip;
mod nest;
use std::collections::HashSet;
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashSet};
use std::sync::Arc;
use kurbo::PathEl;
@@ -51,7 +55,8 @@ pub const TOLERANCE_RANGE: std::ops::RangeInclusive<f64> = 0.01..=10.0;
pub const MAX_PENS: usize = 255;
/// The most points the plots of one export hold.
const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 };
/// The most point comparisons sorting may make, over the whole plot.
/// The most work sorting paths and finding which lie inside others may take, over the whole plot:
/// one for each point or box compared.
const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 };
/// The most edge tests clipping masks may take, over the whole plot.
const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 };
@@ -74,6 +79,8 @@ const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from
const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range";
const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color";
const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order";
const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it";
const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it";
const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out";
const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out";
const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out";
@@ -151,6 +158,8 @@ pub struct HpglOptions {
pub origin: Origin,
/// Draw each pen's paths nearest first, instead of in paint order.
pub sort: bool,
/// Draw a path only after the pen's paths that lie inside it.
pub inside_first: bool,
/// The area plotted (document space): its bottom-left corner or center is the origin, and the
/// art is cut at its edges.
pub region: Rect,
@@ -158,7 +167,15 @@ pub struct HpglOptions {
impl Default for HpglOptions {
fn default() -> Self {
Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO }
Self {
version: Version::default(),
tolerance: DEFAULT_TOLERANCE,
pens: vec![],
origin: Origin::default(),
sort: false,
inside_first: false,
region: Rect::ZERO,
}
}
}
@@ -263,14 +280,10 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R
if !by_pen.iter().all(Vec::is_empty) {
pen_warnings.iter().for_each(|w| warn(&mut warnings, w));
}
if opts.sort {
if opts.sort || opts.inside_first {
let (mut at, mut work) = ((0, 0), MAX_SORT_WORK);
for paths in &mut by_pen {
let (stop, sorted) = nearest_first(paths, at, &mut work);
at = stop;
if !sorted {
warn(&mut warnings, UNSORTED);
}
at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings);
}
}
if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) {
@@ -348,25 +361,50 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 {
dx * dx + dy * dy
}
/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its
/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its
/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep
/// their order. Returns where the pen stops, and `false` when the work ran out.
fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) {
// Each path with its box.
let mut left: Vec<_> = std::mem::take(paths)
.into_iter()
.map(|p| {
let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]);
(p, b)
})
.collect();
/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest
/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or
/// for a closed path any of its points (where it then starts); without, the first in paint order.
/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is.
/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with
/// `inside_first`, with a warning. Returns where the pen stops.
fn reorder(
paths: &mut Vec<Vec<(i64, i64)>>,
mut at: (i64, i64),
work: &mut u64,
nearest: bool,
inside_first: bool,
warnings: &mut Vec<String>,
) -> (i64, i64) {
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect();
// The paths each path lies inside, and how many paths inside each are still to draw.
let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten();
if inside_first && outer.is_none() {
warn(warnings, UNNESTED);
}
let mut waiting = vec![0usize; paths.len()];
for o in outer.iter().flatten().flatten() {
if let Some(w) = waiting.get_mut(*o) {
*w += 1;
}
}
if !nearest {
if let Some(outer) = &outer {
let all = std::mem::take(paths).into_iter().enumerate().collect();
inner_first(all, outer, &mut waiting, paths);
}
return at;
}
// Each path with its index and box.
let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect();
while !left.is_empty() {
// (index, reversed, the closed path's point to start at, distance)
let mut best: (usize, bool, Option<usize>, i128) = (0, false, None, i128::MAX);
for (i, (p, b)) in left.iter().enumerate() {
let mut best: (usize, bool, Option<usize>, i128) = (usize::MAX, false, None, i128::MAX);
for (i, (j, p, b)) in left.iter().enumerate() {
let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue };
*work = work.saturating_sub(1);
if waiting.get(*j).is_some_and(|w| *w > 0) {
continue;
}
if s == e && p.len() > 2 {
if box_dist2(at, *b) >= best.3 {
continue;
@@ -387,11 +425,27 @@ fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mu
best = (i, true, None, de);
}
}
if *work == 0 {
paths.extend(left.into_iter().map(|(p, _)| p));
return (at, false);
// Spent, or no path free to draw (which only a fault could cause): the rest in paint
// order, each after the paths inside it.
if *work == 0 || best.0 >= left.len() {
match &outer {
Some(outer) => {
warn(warnings, UNSORTED_INSIDE);
inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths);
}
None => {
warn(warnings, UNSORTED);
paths.extend(left.into_iter().map(|(_, p, _)| p));
}
}
return at;
}
let (j, mut p, _) = left.remove(best.0);
for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() {
if let Some(w) = waiting.get_mut(*o) {
*w = w.saturating_sub(1);
}
}
let (mut p, _) = left.remove(best.0);
if best.1 {
p.reverse();
} else if let Some(k) = best.2 {
@@ -408,7 +462,39 @@ fn nearest_first(paths: &mut Vec<Vec<(i64, i64)>>, mut at: (i64, i64), work: &mu
}
paths.push(p);
}
(at, true)
at
}
/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order,
/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting`
/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside
/// larger ones, so every path comes free; a path left waiting by a fault is drawn last.
fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec<usize>], waiting: &mut [usize], paths: &mut Vec<Vec<(i64, i64)>>) {
let mut held: Vec<Option<Vec<(i64, i64)>>> = (0..outer.len()).map(|_| None).collect();
let mut free = BinaryHeap::new();
for (j, p) in left {
if let Some(h) = held.get_mut(j) {
if waiting.get(j).is_none_or(|w| *w == 0) {
free.push(Reverse(j));
}
*h = Some(p);
} else {
paths.push(p);
}
}
while let Some(Reverse(j)) = free.pop() {
let Some(p) = held.get_mut(j).and_then(Option::take) else { continue };
paths.push(p);
for &o in outer.get(j).into_iter().flatten() {
if let Some(w) = waiting.get_mut(o) {
*w = w.saturating_sub(1);
if *w == 0 {
free.push(Reverse(o));
}
}
}
}
paths.extend(held.into_iter().flatten());
}
/// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`.
+357
View File
@@ -0,0 +1,357 @@
//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the
//! inner paths first.
/// The box of `p`: `[x0, y0, x1, y1]`.
pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] {
p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)])
}
/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of
/// its segments crosses the closed path's line, each part of it between the points where it meets
/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one
/// part lies inside. A path touching the line of the one around it is inside it, a path crossing
/// it is not, and copies of one outline are not inside each other. A path is tested only against
/// closed paths larger than it (by the area of their box, then the area they enclose, then paint
/// order), so no two paths are each inside the other, and only against the closed paths in the
/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a
/// box compared cost one each, and each segment tested costs the closed path's points for its
/// crossings and again for each part of it.
pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option<Vec<Vec<usize>>> {
let size: Vec<(i128, i128)> = paths
.iter()
.zip(boxes)
.map(|(p, b)| {
let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1]));
(box_area, if closed(p) { twice_area(p) } else { 0 })
})
.collect();
let rings: Vec<usize> = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect();
let mut out = vec![vec![]; paths.len()];
if rings.is_empty() {
return Some(out);
}
let grid = Grid::new(&rings, boxes, work)?;
for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() {
for &r in grid.at(ab[0], ab[1]) {
spend(work, 1)?;
let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue };
let smaller = (size.get(a), a) < (size.get(r), r);
if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] {
continue;
}
if inside(p, ring, work)?
&& let Some(o) = out.get_mut(a)
{
o.push(r);
}
}
}
Some(out)
}
/// The most cells along each side of a [`Grid`].
const MAX_GRID_SIDE: usize = 512;
/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed
/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more
/// entries than the pen has points.
const MAX_CELLS_PER_PATH: usize = 4;
/// A grid over the boxes of a pen's closed paths, about one cell for each, up to
/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers
/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed
/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell.
struct Grid {
/// The first corner of the closed paths' boxes together.
x0: i128,
y0: i128,
/// Their width and height, plus one.
w: i128,
h: i128,
/// Cells along each side.
side: usize,
/// The cells row by row.
cells: Vec<Vec<usize>>,
/// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells.
wide: Vec<usize>,
}
impl Grid {
/// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent.
fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option<Self> {
let all = rings
.iter()
.filter_map(|&r| boxes.get(r))
.fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]);
let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE);
let (x0, y0) = (i128::from(all[0]), i128::from(all[1]));
let mut grid = Self {
x0,
y0,
w: (i128::from(all[2]) - x0 + 1).max(1),
h: (i128::from(all[3]) - y0 + 1).max(1),
side,
cells: vec![vec![]; side * side],
wide: vec![],
};
for &r in rings {
let Some(b) = boxes.get(r) else { continue };
let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue };
let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1);
if covered > MAX_CELLS_PER_PATH {
spend(work, 1)?;
grid.wide.push(r);
continue;
}
spend(work, covered as u64)?;
for cy in cy0..=cy1 {
for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() {
cell.push(r);
}
}
}
Some(grid)
}
/// The column and row of the cell holding `(x, y)`, `None` outside the grid.
fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> {
let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0);
if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h {
return None;
}
let side = self.side as i128;
Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize))
}
/// The wide closed paths, then those listed in the cell holding `(x, y)`.
fn at(&self, x: i64, y: i64) -> impl Iterator<Item = &usize> {
let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice);
self.wide.iter().chain(cell)
}
}
/// Takes `n` from `work`; `None`, with `work` at 0, when it has less.
fn spend(work: &mut u64, n: u64) -> Option<()> {
let left = work.checked_sub(n);
*work = left.unwrap_or(0);
left.map(|_| ())
}
/// Does `p` start and end at one point, with three other points or more?
fn closed(p: &[(i64, i64)]) -> bool {
p.len() > 3 && p.first() == p.last()
}
/// Twice the area the closed path `p` encloses.
fn twice_area(p: &[(i64, i64)]) -> i128 {
let sum: i128 = p
.windows(2)
.map(|w| match w {
[a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1),
_ => 0,
})
.sum();
sum.abs()
}
/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent.
fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option<bool> {
let mut any = false;
for w in p.windows(2) {
let [s, e] = w else { continue };
spend(work, ring.len() as u64)?;
// Where the segment meets the line without crossing it: its ends and the ring's points on
// it. Between two of these the segment is all inside or all outside.
let mut stops = vec![*s, *e];
for edge in ring.windows(2) {
let [a, b] = edge else { continue };
if crosses((*s, *e), (*a, *b)) {
return Some(false);
}
if a != s && a != e && on((*s, *e), *a) {
stops.push(*a);
}
}
let along = |q: &(i64, i64)| {
(i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0))
+ (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1))
};
stops.sort_by_key(along);
stops.dedup();
for part in stops.windows(2) {
let [m, n] = part else { continue };
spend(work, ring.len() as u64)?;
// The part's middle, doubled so it is whole.
match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) {
Some(false) => return Some(false),
Some(true) => any = true,
None => {}
}
}
}
Some(any)
}
/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it.
fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 {
let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1))
- (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0));
d.signum()
}
/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other
/// to the other side?
fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool {
side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0
}
/// Does `q` lie on segment `u`?
fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool {
let ((x0, y0), (x1, y1)) = u;
side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1)
}
/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the
/// even-odd rule, `Some(false)` outside, `None` on its line.
fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option<bool> {
let mut inside = false;
for w in ring.windows(2) {
let [a, b] = w else { continue };
let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1)));
// Which side of the segment's line `q` is on, 0 on it.
let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0);
if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) {
return None;
}
// A segment crossing the level of `q` to its right.
if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) {
inside = !inside;
}
}
Some(inside)
}
#[cfg(test)]
mod tests {
use super::*;
fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> {
vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
}
fn nest(paths: &[Vec<(i64, i64)>]) -> Option<Vec<Vec<usize>>> {
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = u64::MAX;
outer(paths, &boxes, &mut work)
}
#[test]
fn points_inside_outside_and_on_the_line() {
let ring = square(0, 0, 10, 10);
assert_eq!(locate((10, 10), &ring), Some(true));
assert_eq!(locate((30, 10), &ring), Some(false));
assert_eq!(locate((10, -2), &ring), Some(false));
assert_eq!(locate((20, 10), &ring), None);
assert_eq!(locate((0, 0), &ring), None);
}
#[test]
fn a_path_inside_another_lies_inside_it_and_not_the_other_way() {
let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]];
assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]]));
}
#[test]
fn touching_counts_as_inside_and_crossing_does_not() {
// A square with a corner on the outline's left edge, a triangle whose corners lie on the
// outline's edges, and a square that crosses the outline.
let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)];
assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]]));
}
/// Every point of a copy lies on the other's line, and none inside it.
#[test]
fn copies_of_one_outline_do_not_lie_inside_each_other() {
let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]];
assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]]));
}
/// A line whose ends and middle lie inside an outline but which crosses a slot cut into it.
#[test]
fn a_line_across_a_slot_is_not_inside() {
let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
}
/// A line that leaves an outline and comes back through two of its corners, crossing none of
/// its sides, and one that runs along the bottom of a slot.
#[test]
fn a_line_through_corners_is_inside_only_where_it_stays_inside() {
let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]]));
}
/// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its
/// lobes lies inside it.
#[test]
fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() {
let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)];
assert_eq!(twice_area(&bow), 0);
assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]]));
}
#[test]
fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() {
let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)];
assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]]));
}
/// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths
/// near it, not against every part.
#[test]
fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() {
let mut paths = vec![];
for row in 0..100 {
for col in 0..100 {
let (x, y) = (col * 30, row * 30);
paths.push(square(x, y, x + 20, y + 20));
paths.push(square(x + 5, y + 5, x + 15, y + 15));
}
}
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = 5_000_000;
let found = outer(&paths, &boxes, &mut work);
let want: Vec<Vec<usize>> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect();
assert_eq!(found, Some(want));
}
/// 400 closed paths that each cover most of the grid are listed once each, not in every cell
/// they cover.
#[test]
fn closed_paths_covering_many_cells_are_listed_once() {
let mut paths: Vec<Vec<(i64, i64)>> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect();
paths.extend((0..400).map(|i| {
let (x, y) = ((i % 20) * 50, (i / 20) * 50);
square(x, y, x + 10, y + 10)
}));
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let rings: Vec<usize> = (0..paths.len()).collect();
let mut work = u64::MAX;
let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") };
assert_eq!(grid.wide, (0..400).collect::<Vec<_>>());
assert!(grid.cells.iter().map(Vec::len).sum::<usize>() <= 400 * MAX_CELLS_PER_PATH);
}
#[test]
fn finding_what_lies_inside_stops_at_its_budget() {
let paths: Vec<Vec<(i64, i64)>> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect();
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = 1_000;
assert_eq!(outer(&paths, &boxes, &mut work), None);
assert_eq!(work, 0);
let mut work = u64::MAX;
let found = outer(&paths, &boxes, &mut work);
assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::<usize>()), Some(50 * 49 / 2));
}
}
+136
View File
@@ -856,6 +856,142 @@ fn sorting_stops_at_its_budget_and_keeps_every_path() {
assert_eq!(text(&out).matches("PD").count(), 600);
}
/// `x` points in plotter units.
fn plu(x: f64) -> i64 {
(x * 1016.0 / 72.0).round() as i64
}
/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units).
fn lefts(out: &HpglOutput) -> Vec<i64> {
let mut lefts: Vec<i64> = vec![];
for i in text(out).split_terminator(';') {
let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::<i64>().ok());
match i.get(..2) {
Some("PU") => lefts.extend(xs.min()),
Some("PD") => {
if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) {
*l = (*l).min(x);
}
}
_ => {}
}
}
lefts
}
/// A letter O made as one path: its outline, then its counter.
#[test]
fn inside_first_cuts_a_letters_counter_before_its_outline() {
let d = doc_with(|d| {
let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath();
bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath());
vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]);
assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]);
}
/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`.
#[test]
fn a_part_with_two_holes_is_cut_holes_first() {
let d = doc_with(|d| {
vec![
stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK),
stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK),
stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK),
]
});
let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0));
assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]);
assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]);
// Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest
// hole comes first, then the other, then the part.
assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]);
let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
assert_eq!(lefts(&both), [left, right, part]);
assert!(both.warnings.is_empty(), "{:?}", both.warnings);
}
/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place
/// in paint order.
#[test]
fn nested_rings_are_cut_innermost_first() {
let d = doc_with(|d| {
vec![
stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK),
stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK),
stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK),
stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK),
]
});
let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]);
}
/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with
/// the red pen, after the black part.
#[test]
fn inside_first_orders_each_pens_paths() {
let d = doc_with(|d| {
vec![
stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK),
stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)),
stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK),
]
});
let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]);
}
/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in
/// paint order, with a warning.
#[test]
fn inside_first_past_its_budget_draws_in_paint_order() {
let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect());
let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
assert_eq!(out.warnings, [UNNESTED]);
let l = lefts(&out);
assert_eq!(l.len(), 100);
assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}");
// With `sort` too, the budget is spent before sorting starts: paint order, with both warnings.
let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
assert_eq!(out.warnings, [UNNESTED, UNSORTED]);
assert_eq!(lefts(&out), l);
}
/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and
/// then the outer one last.
#[test]
fn inside_first_past_its_sorting_budget_keeps_the_rings_last() {
let d = doc_with(|d| {
let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)];
v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)));
v
});
let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
assert_eq!(out.warnings, [UNSORTED_INSIDE]);
let l = lefts(&out);
assert_eq!(l.len(), 602);
assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..]));
}
/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them,
/// within the budget.
#[test]
fn inside_first_alone_keeps_paint_order_for_many_paths() {
let d = doc_with(|d| {
let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)];
v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK)));
v
});
let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let l = lefts(&out);
assert_eq!(l.len(), 1001);
assert_eq!(l.last(), Some(&plu(0.5)));
assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}");
}
#[test]
fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() {
let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]);
+67 -8
View File
@@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> {
/// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and
/// its handles) moved onto the key's centre, so moving a key moves its end of the spine;
/// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread
/// evenly along it by arc length).
/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as
/// above at the anchors where they were spread.
///
/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]).
pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
@@ -1077,7 +1078,24 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
return Some((PathData::single(SubPath::polyline(&centers, false)), Some((0..keys.len()).collect())));
};
let mut first = stored.subpaths.first()?.clone();
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) };
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else {
let mut path = PathData::single(first);
// A key moved off where Replace Spine spread it takes its point of the spine along, as
// on a pinned spine; otherwise the steps would put it back on the spine (#6809).
if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) {
return Some((path, None));
}
let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) };
let mut first = path.subpaths.into_iter().next()?;
open_at_start(&mut first, &mut ka);
for (k, &ai) in keys.iter().zip(&ka) {
if let Some(a) = first.anchors.get_mut(ai) {
let d = center_of(k) - a.p;
a.translate(d);
}
}
return Some((PathData::single(first), Some(ka)));
};
for (k, &ai) in keys.iter().zip(&ka) {
let c = center_of(k);
if let Some(a) = first.anchors.get_mut(ai) {
@@ -1091,21 +1109,62 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
/// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its
/// keys sit (by arc length), so editing it keeps them in place.
pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<usize>)> {
let (mut path, anchors) = blend_spine(keys, spec)?;
if let Some(a) = anchors {
return Some((path, a));
// A spine from Replace Spine is pinned as stored, even with a key moved off it (where
// [`blend_spine`] moves that key's point along): the layout is the same.
if keys.len() >= 2
&& let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first())
&& pinned(spec, keys.len(), first.anchors.len()).is_none()
{
let mut path = PathData::single(first.clone());
let mut out = spread_anchors(&mut path, keys.len())?;
// A closed spine's end keys get points of their own, so that once pinned each still
// moves only its own end.
let mut sp = path.subpaths.into_iter().next()?;
open_at_start(&mut sp, &mut out);
return Some((PathData::single(sp), out));
}
let (path, anchors) = blend_spine(keys, spec)?;
Some((path, anchors?))
}
/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length
/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)?
fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool {
let k = keys.len();
let total = Spine::new(&path)?.length();
keys.iter().enumerate().all(|(i, key)| {
let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 };
center_of(key).distance(spine.at(f).0) < 0.01
})
}
/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as
/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and
/// the last key, so the two end keys move their ends of the spine apart.
fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) {
let Some(&start) = sp.anchors.first() else { return };
if !sp.closed || ka.len() < 2 {
return;
}
if let Some(last) = ka.last_mut().filter(|a| **a == 0) {
*last = sp.anchors.len();
sp.anchors.push(start);
sp.closed = false;
}
}
/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit
/// on, splitting segments where none is.
fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
let total = Spine::new(path)?.length();
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let mut out = vec![0usize];
for i in 1..k.saturating_sub(1) {
let s = total * i as f64 / (k - 1) as f64;
out.push(anchor_at_length(&mut path, s)?);
out.push(anchor_at_length(path, s)?);
}
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
out.push(last);
Some((path, out))
Some(out)
}
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
+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
@@ -52,7 +52,7 @@ pub struct TextObject {
/// the object, so the runs (and their styles) stay the text itself.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ruby: Vec<RubySpan>,
#[serde(default)]
#[serde(default = "ParaStyle::omitted")]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+20
View File
@@ -244,9 +244,21 @@ pub struct ParaStyle {
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
/// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul
/// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul)
/// breaks only at its spaces and punctuation, or where it is wider than the line. New type and
/// imported text have it off; paragraphs saved before it existed open with it on, the line
/// breaks they were laid out with.
pub korean_line_break_anywhere: bool,
}
impl ParaStyle {
/// The attributes a text object saved without `para` reads with: the defaults, with Korean
/// Line Break Anywhere on, as a saved paragraph without that field reads.
pub(crate) fn omitted() -> Self {
Self { korean_line_break_anywhere: true, ..Self::default() }
}
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
@@ -330,6 +342,10 @@ struct ParaStyleFile {
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
/// Written only when off: a paragraph saved without it was laid out breaking between Hangul
/// syllables.
#[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")]
korean_line_break_anywhere: bool,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -361,6 +377,7 @@ impl From<ParaStyle> for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
..
} = p;
Self {
@@ -386,6 +403,7 @@ impl From<ParaStyle> for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
}
}
}
@@ -415,6 +433,7 @@ impl From<ParaStyleFile> for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
..
} = f;
Self {
@@ -439,6 +458,7 @@ impl From<ParaStyleFile> for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
}
}
}
+23
View File
@@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() {
assert_eq!(same, d);
}
/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph
/// saved before it existed, or a text object saved without paragraph attributes, reads with it on,
/// and both values read back as saved.
#[test]
fn korean_line_break_anywhere_is_saved_only_when_off() {
let off = ParaStyle::default();
assert!(!off.korean_line_break_anywhere);
let saved = serde_json::to_value(&off).unwrap();
assert_eq!(saved["korean_line_break_anywhere"], false);
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off);
let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() };
let saved = serde_json::to_value(&on).unwrap();
assert!(saved.get("korean_line_break_anywhere").is_none());
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on);
let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap();
assert!(old.korean_line_break_anywhere);
let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap();
assert_eq!(saved["para"]["korean_line_break_anywhere"], false);
saved.as_object_mut().unwrap().remove("para");
let old: TextObject = serde_json::from_value(saved).unwrap();
assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() });
}
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
+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
+280 -16
View File
@@ -4,19 +4,22 @@
//! 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
//! them ([`image_rows`]).
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -548,7 +551,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 {
@@ -632,9 +642,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`]).
@@ -705,8 +715,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)));
@@ -739,10 +750,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);
@@ -757,6 +779,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);
@@ -789,6 +813,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));
@@ -804,6 +829,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
{
@@ -816,14 +851,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
}
@@ -1445,17 +1482,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));
}
@@ -1590,6 +1635,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
@@ -209,6 +209,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());
@@ -318,6 +369,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
}
+5 -5
View File
@@ -32,8 +32,8 @@ use super::opacitymask::percent;
use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets,
ok, paint_targets, str_param, switch_or, targets,
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
object_param, ok, paint_targets, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -726,8 +726,8 @@ fn clear(n: &mut Node) {
fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.transfer";
let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?;
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
@@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
/// Sample enabled attributes with transform-aware character dimensions and atomic validation.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
if defaults_only && (reverse || append || p.get("ids").is_some()) {
+2 -2
View File
@@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids,
number_or, point_param, str_param, switch_or,
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or,
object_param, point_param, str_param, switch_or,
};
mod live_paint;
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json,
is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -303,7 +303,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
};
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let group = object_param(p, "group", C)?;
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
let gid = match group {
@@ -367,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?;
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
@@ -390,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?;
let g = match object_param(p, "group", "livePaint.info")? {
Some(g) => g,
None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?,
};
let d = &s.doc()?.doc;
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?;
let faces: Vec<Value> = b::faces(n)
+3 -1
View File
@@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole
/// (Korean Line Break Anywhere off). Imported text keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf,
burasagari: vectorcraft_doc::Burasagari::Standard,
korean_line_break_anywhere: false,
..Default::default()
}
}
+5 -5
View File
@@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param,
named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.set";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?);
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
@@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.copy";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
// its widths already), the document and its undo history stay as they are.
@@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
+2 -2
View File
@@ -18,8 +18,8 @@ use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::{
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param,
switch_or,
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok,
str_param, switch_or,
};
use crate::EngineError;
+5 -5
View File
@@ -1,7 +1,7 @@
use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound};
use super::{
Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2,
add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or,
add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or,
};
// ---------- Curvature ----------
@@ -41,7 +41,7 @@ enum CurvatureOp {
pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let curvature = switch_or(p, "curvature", false, C)?;
@@ -220,7 +220,7 @@ fn remove_anchors_of(
pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
@@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
+2 -2
View File
@@ -4,7 +4,7 @@ use super::common::{
};
use super::{
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po,
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
};
/// Split a filled path into connected pieces (each outer contour with its holes).
@@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(
pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
+5 -4
View File
@@ -4,7 +4,8 @@ use super::common::{
};
use super::{
Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point,
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or,
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param,
switch_or,
};
// ---------- Pencil / Paintbrush ----------
@@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
@@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
// open subpath, that subpath starting at the end the stroke ends on, it ends at its end).
let connect = match p.get("connect").filter(|v| !v.is_null()) {
Some(cn) => {
let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?;
let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?;
if other == id {
return Err(bad(C, "connect needs a path other than the one extended"));
}
@@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?;
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
+3 -3
View File
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index,
number_or, ok, point_param, str_param, switch_or,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
object_param, ok, point_param, str_param, switch_or,
};
use crate::{Clipboard, EngineError, inspect};
@@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
}
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?;
let mut opts = slice_opts(p, "document.node")?;
let summary = switch_or(p, "summary", false, "document.node")?;
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
+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,
File diff suppressed because one or more lines are too long
+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)
}
+7
View File
@@ -48,6 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "false",
description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order",
},
FormatOption {
name: "insideFirst",
ty: "boolean",
default: "false",
description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next",
},
];
/// The HP-GL options in `p` (the region is set per plot).
@@ -75,6 +81,7 @@ fn parse(p: &Value) -> Result<HpglOptions> {
o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?;
}
o.sort = switch_or(p, "sortPaths", false, C)?;
o.inside_first = switch_or(p, "insideFirst", false, C)?;
Ok(o)
}
+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(),
+11 -2
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))?;
@@ -885,7 +885,13 @@ impl SaveJob {
/// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several),
/// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?:
/// [path…], linked?: [path…]}`.
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
self.write_unless(|_| None, write)
}
/// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in
/// order: when `stop` returns a value, nothing is written and that value is returned.
pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let (plan, doc) = (&self.plan, &*self.doc);
let mut enc = self.encode()?;
let warnings = std::mem::take(&mut enc.warnings);
@@ -899,6 +905,9 @@ impl SaveJob {
return Ok(out);
};
let files = enc.named(doc, path);
if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) {
return Ok(stopped);
}
for (p, bytes) in &files {
write(p, bytes)?;
}
+21 -1
View File
@@ -102,7 +102,7 @@ fn the_dialogs_and_the_format_list_name_hpgl() {
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false)));
for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] {
for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] {
assert!(f["options"].get(o).is_some(), "{o}: {f}");
}
assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl");
@@ -184,6 +184,7 @@ fn export_options_are_checked() {
(json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"),
(json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"),
(json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"),
];
for (p, want) in cases {
let e = refused(&mut s, p.clone());
@@ -198,6 +199,25 @@ fn export_options_are_checked() {
assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;");
}
/// `insideFirst` draws the square inside the other one first, though it was made last.
#[test]
fn inside_first_draws_the_inner_square_first() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap();
let starts = |s: &mut Session, p: Value| {
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
(plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap())
};
let (outer, inner) = starts(&mut s, json!({"format": "hpgl"}));
assert!(outer < inner);
let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true}));
assert!(inner < outer);
}
/// Export Selection plots the selected square alone (the red one, made last), from the
/// bottom-left corner of its bounds.
#[test]
+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.
+11 -8
View File
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect};
use super::{
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
has_selection, id_param, ids_param, index, ok, str_param, switch_or,
has_selection, ids_param, index, object_param, ok, str_param, switch_or,
};
use crate::EngineError;
@@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
/// The Layers panel's target circle.
fn target(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.target";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
if !st.doc.is_editable(id) {
@@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.setCurrent";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc_mut()?;
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
st.active_layer = Some(layer);
@@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.selectAll";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let add = switch_or(p, "add", false, C)?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
@@ -747,9 +747,12 @@ enum Place {
/// Drag rows in the Layers panel.
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.move";
let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
let mut ids = match ids_param(p, "ids", C)? {
Some(ids) => ids,
None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?],
};
ids.truncate(MAX_ROWS);
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?;
let place = match str_param(p, "place").unwrap_or("inside") {
"above" => Place::Above,
"below" => Place::Below,
@@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "node.move";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let parent = id_param(p, "parent");
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let parent = object_param(p, "parent", C)?;
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
let d = &s.doc()?.doc;
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
+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() {
+1 -1
View File
@@ -21,7 +21,7 @@ use super::expand::gradient_sampler;
use super::pathops::Beside;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi,
has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param,
has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param,
};
mod blend;
+5 -1
View File
@@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
if anchors.contains(&index) {
return Err(bad(C, "a key object sits on that point"));
}
if sp.anchors.len() <= 2 {
// A closed spine pinned open keeps a copy of its first point at its end (#6809), so the
// points left must be two apart, not only two in number.
let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p);
let first = rest.next();
if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) {
return Err(bad(C, "a spine keeps at least two points"));
}
sp.anchors.remove(index);
+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
),
+13 -8
View File
@@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr
use super::mesh::{handle_param, index_param, whole};
use super::{
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
};
// ---------- Envelope ----------
@@ -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"}),
@@ -419,7 +424,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.setMeshPoint";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
+3 -3
View File
@@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec
use super::envelope::{outline_of, rows_cols};
use super::{
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad,
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param,
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param,
};
// ---------- Gradient mesh ----------
@@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols:
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (see [`edit_grid`]).
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
}
@@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20;
/// points}…]}`. Each mesh comes once, with its points in order and without repeats.
fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> {
let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
let points = match q.get("points") {
None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]),
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v
File diff suppressed because one or more lines are too long
+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)
}
+8 -8
View File
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
use super::pathops::Beside;
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets,
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
}
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
s.edit("Pen", |d, sel| {
@@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only
/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape.
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
None => Ok(None),
@@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
}
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
@@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
@@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
let data = PathData::new(
subs.iter()
@@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
+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",
+3 -3
View File
@@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin
use vectorcraft_geom::{Rect, shapes};
use super::{
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or,
targets,
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param,
switch_or, targets,
};
/// The most slices one Divide Slices makes.
@@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.slice.setRect";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let r = rect_param(p, C)?;
let d = &s.doc()?.doc;
if d.slice(id).is_none() {
+10 -10
View File
@@ -11,8 +11,8 @@ use super::menucmds::whole_or;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
index, number_or, point_param, str_param, switch_or, targets, text_param,
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index,
number_or, object_param, point_param, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.editRange";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len, C)?;
@@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.startList";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let at = offset_or(p, "at", len, C)?.min(len);
@@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
}
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?;
if switch_or(p, "story", false, "text.getRange")? {
return get_story(s, p, id);
}
@@ -1261,7 +1261,7 @@ fn drawn_glyph(
fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setGlyphAlternate";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let gid = match p.get("gid") {
None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")),
Some(Value::Null) => None,
@@ -1351,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.glyphAlternates";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
@@ -1366,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.touchType";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["start"], C)?;
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
@@ -1592,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.createInPath";
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let on_path = match str_param(p, "mode").unwrap_or("area") {
"area" => false,
"onPath" | "path" => true,
@@ -1996,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.discardEmpty";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
+1
View File
@@ -245,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("kinsoku_type", json!(v.kinsoku_type)),
("roman_hanging", json!(v.roman_hanging)),
("kashida", json!(v.kashida)),
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
] {
m.insert(k.into(), x);
}
+5 -5
View File
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{length_given, number_given, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
str_param, switch_or, targets, text_param,
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
ok, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut items = Vec::with_capacity(raw.len());
for it in raw {
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
@@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.reshapePath";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
@@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setPathHandle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
@@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
assert!(text(&s).para_at(1).roman_hanging);
}
/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that;
/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a
/// value that is not true or false, a paragraph saved before the option opens with it on, and
/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it.
#[test]
fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off");
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let steps = s.doc().unwrap().history.undo.len();
// Only the first paragraph (the caret's range).
s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap();
assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none());
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
assert!(back.korean_line_break_anywhere);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!text(&s).para_at(0).korean_line_break_anywhere);
s.execute("edit.redo", &json!({})).unwrap();
assert!(text(&s).para_at(0).korean_line_break_anywhere);
// Saved before the option: on, the line breaks it was laid out with.
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
assert!(old.korean_line_break_anywhere);
assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err());
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err());
assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing");
// A paragraph style carries it.
s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap();
assert!(text(&s).para_at(1).korean_line_break_anywhere);
s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap();
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
}
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
/// unknown value changes nothing, and paragraph styles carry it.
+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"];
+2 -2
View File
@@ -8,7 +8,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::select::selectable_ids;
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param};
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
/// points, opacity in percent.
@@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> {
}
fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?;
let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?;
let st = s.doc()?;
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let w = s.menu.wand.clone();
+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");
+89 -2
View File
@@ -424,6 +424,93 @@ fn blend_replace_spine_follows_path() {
assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}");
}
/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine,
/// not only the key's selection outline.
#[test]
fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() {
let mut s = session();
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap());
sel(&mut s, &[g, arc]);
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
let center = |n: &Node| n.geometric_bounds().unwrap().center();
let before = steps_of(&s, g);
assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
// Direct Selection picks the key inside the blend; the drag moves it.
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap();
let key = center(&node(&s, a));
assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}");
let after = steps_of(&s, g);
assert_eq!(after.len(), before.len());
let drawn = center(&after[0]);
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key");
let last = center(&after[after.len() - 1]);
assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}");
// Undo puts the key back on the spine as Replace Spine left it.
s.execute("edit.undo", &json!({})).unwrap();
let back = steps_of(&s, g);
assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
assert!(center(&back[1]).distance(center(&before[1])) < 1e-6);
}
/// #6809 on a closed spine, where both end keys start on its first point: moving the first key
/// moves its end of the spine without dragging the last key's end along.
#[test]
fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() {
let mut s = session();
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap());
sel(&mut s, &[g, square]);
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
let center = |n: &Node| n.geometric_bounds().unwrap().center();
let before = steps_of(&s, g);
assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6);
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap();
let key = center(&node(&s, a));
assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}");
let after = steps_of(&s, g);
assert_eq!(after.len(), before.len());
let drawn = center(&after[0]);
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key");
let last = center(&after[after.len() - 1]);
assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}");
s.execute("edit.undo", &json!({})).unwrap();
let back = steps_of(&s, g);
assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6);
assert!(center(&back[1]).distance(center(&before[1])) < 1e-6);
}
/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so
/// moving the first key afterwards still moves only its end.
#[test]
fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() {
let mut s = session();
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap());
sel(&mut s, &[g, square]);
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
// Dragging a corner away from the keys pins the spine to them.
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap();
let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() };
assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors);
let center = |n: &Node| n.geometric_bounds().unwrap().center();
let before = steps_of(&s, g);
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap();
let key = center(&node(&s, a));
assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}");
let after = steps_of(&s, g);
assert_eq!(after.len(), before.len());
let drawn = center(&after[0]);
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
let last = center(&after[after.len() - 1]);
assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}");
}
#[test]
fn blend_align_to_path_rotates_steps() {
let mut s = session();
@@ -483,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]
+84
View File
@@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() {
}
}
/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such,
/// naming its key, not as a missing id; an id left out is still a missing one.
#[test]
fn a_malformed_object_id_is_not_reported_as_missing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = undo(&s);
let points = json!([[0, 0], [10, 10], [20, 0]]);
for bad in [json!("5"), json!(-1)] {
let id = || json!({ "id": bad });
let with = |extra: serde_json::Value| {
let mut p = id();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
for (cmd, p, key) in [
("stroke.widthPoint.set", id(), "id"),
("stroke.widthPoint.copy", id(), "id"),
("stroke.widthPoint.remove", id(), "id"),
("path.curvatureEdit", id(), "id"),
("path.removeAnchor", id(), "id"),
("path.convertAnchor", id(), "id"),
("path.reshapeSegment", id(), "id"),
("path.split", id(), "id"),
("path.appendAnchor", id(), "id"),
("path.close", id(), "id"),
("path.setHandle", id(), "id"),
("path.moveAnchor", id(), "id"),
("path.setAnchors", id(), "id"),
("path.reshape", id(), "id"),
("path.insertAnchor", id(), "id"),
("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"),
("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"),
("document.node", id(), "id"),
("layer.target", id(), "id"),
("layer.setCurrent", id(), "id"),
("layer.selectAll", id(), "id"),
("layer.move", with(json!({"target": 1})), "id"),
("layer.move", json!({"ids": [1], "target": bad}), "target"),
("node.move", id(), "id"),
("node.move", json!({"id": 1, "parent": bad}), "parent"),
("text.reshapeArea", with(json!({"anchors": []})), "id"),
("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"),
("text.reshapePath", id(), "id"),
("text.setPathHandle", id(), "id"),
("text.editRange", id(), "id"),
("text.startList", id(), "id"),
("text.getRange", id(), "id"),
("text.setGlyphAlternate", id(), "id"),
("text.glyphAlternates", id(), "id"),
("text.touchType", id(), "id"),
("text.discardEmpty", id(), "id"),
("text.createInPath", json!({"path": bad}), "path"),
("object.envelope.setMeshPoint", id(), "id"),
("object.mesh.setPointColor", with(json!({"index": 0})), "id"),
("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"),
("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"),
("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"),
("object.mesh.deletePoint", with(json!({"index": 0})), "id"),
("object.slice.setRect", id(), "id"),
("appearance.transfer", json!({"source": bad, "target": 1}), "source"),
("appearance.transfer", json!({"source": 1, "target": bad}), "target"),
("appearance.copyFrom", json!({"source": bad}), "source"),
("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"),
("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"),
("livePaint.info", json!({"group": bad}), "group"),
("livePaint.info", id(), "id"),
("select.magicWand", id(), "id"),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
// `layer.move` checks an `id` that stands in for `ids` before it runs.
let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id"));
assert!(named, "{cmd} {p}: {e}");
}
}
for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] {
let e = s.execute(cmd, &json!({})).unwrap_err().to_string();
assert!(e.contains("missing"), "{cmd}: {e}");
}
assert_eq!(undo(&s), before);
}
/// A command that takes the keys of one that isn't checked yet isn't checked either.
#[test]
fn a_reference_to_an_unchecked_command_is_unchecked() {
+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]);
}
+1
View File
@@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"),
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"),
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
("type.systemLayout", json!({"on": "true"}), "on"),
+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),
]
+46
View File
@@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
}
}
/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it
/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is
/// reported.
#[test]
fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() {
use vectorcraft_doc::TextObject;
for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] {
let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK));
t.para.korean_line_break_anywhere = on;
let mut doc = Document::new(200.0, 200.0);
let layer = doc.layers[0].id;
let n = text_node(&mut doc, t);
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings);
let mut all = vec![];
paths(&read_back(&doc).layers, &mut all);
let paras: Vec<bool> = all
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(t.para_at(0).korean_line_break_anywhere),
_ => None,
})
.collect();
assert_eq!(paras, [false], "{text} {on}");
}
}
#[test]
fn type_is_written_again_as_the_same_bytes() {
let doc = typed();
@@ -6640,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());
@@ -6656,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.
@@ -6676,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);
}
@@ -385,6 +385,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter {
return true;
}
let hangul =
text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}'));
let held = ParaStyle {
justify: p.justify,
left_indent: p.left_indent,
@@ -398,6 +400,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
// Width, Standard burasagari) isn't reported: it changes no other script's lines.
mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi },
burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari },
// The text document has no Korean Line Break Anywhere: it reads back off, which
// changes only the lines of a paragraph with Hangul in it.
korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul,
..ParaStyle::default()
};
*p != held
+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
@@ -1195,6 +1195,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
@@ -5484,7 +5484,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 {

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