Merge main into fix/read-roots-document-replies

This commit is contained in:
dotCooCoo
2026-11-12 05:53:13 -08:00
176 changed files with 8733 additions and 2167 deletions
+2
View File
@@ -122,6 +122,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
@@ -139,6 +140,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
- 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
+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.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "vectorcraft-cad"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader"
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader and writer"
[lints]
workspace = true
+7
View File
@@ -16,11 +16,18 @@
//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape
//! sequences and PJL lines around the instructions are skipped.
//!
//! [`export`] writes the visible art over a region as an HP-GL or HP-GL/2 plot, and
//! [`export_regions`] one plot per region with one pen numbering: see [`write`].
//!
//! - `lex`: the bytes as instructions, numbers, labels and PE data.
//! - `plot`: the instructions played on the pen, as shapes.
//! - `write`: the writer, paths to pen moves.
mod lex;
mod plot;
pub mod write;
pub use write::{HpglOptions, HpglOutput, Origin, Version, export, export_regions};
use std::sync::Arc;
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
//! Polylines cut by a region: each segment is split where it crosses the region's edges, and the
//! pieces whose middle lies inside are kept, joined where they meet.
use vectorcraft_geom::{FillRule, Point, Rect};
/// A clipping region: closed rings (each closes implicitly) and the rule that decides inside.
pub(super) struct Region {
rings: Vec<Vec<Point>>,
rule: FillRule,
bounds: Rect,
}
impl Region {
/// The region of `rings` under `rule`; `None` when it has no ring of three points or more.
pub fn new(rings: Vec<Vec<Point>>, rule: FillRule) -> Option<Self> {
let rings: Vec<Vec<Point>> = rings.into_iter().filter(|r| r.len() >= 3).collect();
let bounds = rings.iter().flatten().fold(None, |b: Option<Rect>, p| Some(b.map_or(Rect::from_points(*p, *p), |b| b.union_pt(*p))))?;
Some(Self { rings, rule, bounds })
}
/// The edges this region has, which [`Region::cut`] tests each segment against.
fn edges(&self) -> u64 {
self.rings.iter().map(|r| r.len() as u64).sum()
}
/// Does `p` lie on one of the region's edges (within a millionth of a point)?
fn on_edge(&self, p: Point) -> bool {
self.rings.iter().any(|ring| {
ring.iter().enumerate().any(|(i, &a)| {
let b = ring[(i + 1) % ring.len()];
let ab = b - a;
let len2 = ab.hypot2();
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
(a + ab * t).distance(p) <= 1e-6
})
})
}
/// Is `p` inside (non-zero winding or an odd number of crossings), or on an edge?
fn contains(&self, p: Point) -> bool {
if self.on_edge(p) {
return true;
}
let mut winding = 0i64;
for ring in &self.rings {
for (i, &a) in ring.iter().enumerate() {
let b = ring[(i + 1) % ring.len()];
if a.y <= p.y {
if b.y > p.y && cross(a, b, p) > 0.0 {
winding += 1;
}
} else if b.y <= p.y && cross(a, b, p) < 0.0 {
winding -= 1;
}
}
}
match self.rule {
FillRule::NonZero => winding != 0,
FillRule::EvenOdd => winding % 2 != 0,
}
}
/// Where segment `a`–`b` crosses this region's edges, as fractions of its length strictly
/// between 0 and 1, sorted.
fn crossings(&self, a: Point, b: Point) -> Vec<f64> {
let mut ts = vec![];
for ring in &self.rings {
for (i, &c) in ring.iter().enumerate() {
let d = ring[(i + 1) % ring.len()];
if let Some(t) = intersect(a, b, c, d)
&& t > 1e-12
&& t < 1.0 - 1e-12
{
ts.push(t);
}
}
}
ts.sort_by(f64::total_cmp);
ts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
ts
}
/// The parts of polyline `line` inside the region, each a polyline of two points or more.
/// Every test of a point or a segment against an edge takes one from `budget`; `None` when
/// the budget runs out first.
pub fn cut(&self, line: &[Point], budget: &mut u64) -> Option<Vec<Vec<Point>>> {
let edges = self.edges();
let mut out: Vec<Vec<Point>> = vec![];
let mut cur: Vec<Point> = vec![];
for w in line.windows(2) {
let (a, b) = (w[0], w[1]);
// A segment wholly off the region's box lies outside it.
let near = overlaps(Rect::from_points(a, b), self.bounds);
let ts = if near {
spend(budget, edges)?;
self.crossings(a, b)
} else {
vec![]
};
let mut from = 0.0;
for to in ts.into_iter().chain([1.0]) {
let (p, q) = (a.lerp(b, from), a.lerp(b, to));
// Each piece between crossings is tested on its own: its edge and winding tests.
let inside = if near {
spend(budget, edges.saturating_mul(2))?;
self.contains(p.midpoint(q))
} else {
false
};
if inside {
if cur.last().is_none_or(|l| l.distance(p) > 1e-9) {
flush(&mut out, &mut cur);
cur.push(p);
}
cur.push(q);
} else {
flush(&mut out, &mut cur);
}
from = to;
}
}
flush(&mut out, &mut cur);
// A closed line that leaves the region and comes back: its last piece ends where its
// first starts, at the line's start, and the two are one piece.
if line.len() > 2
&& line.first() == line.last()
&& out.len() >= 2
&& let (Some(end), Some(start)) = (out.last().and_then(|l| l.last()), out.first().and_then(|l| l.first()))
&& end.distance(*start) <= 1e-9
{
let first = out.remove(0);
if let Some(last) = out.last_mut() {
last.extend(first.into_iter().skip(1));
}
}
Some(out)
}
}
/// Take `n` from `budget`; `None` when it holds less.
fn spend(budget: &mut u64, n: u64) -> Option<()> {
*budget = budget.checked_sub(n)?;
Some(())
}
fn flush(out: &mut Vec<Vec<Point>>, cur: &mut Vec<Point>) {
let line = std::mem::take(cur);
if line.len() >= 2 {
out.push(line);
}
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Which side of line `a`–`b` point `p` lies on (positive: left).
fn cross(a: Point, b: Point, p: Point) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Where segment `a`–`b` crosses segment `c`–`d`, as a fraction of `a`–`b`; `None` when they
/// are parallel or don't meet.
fn intersect(a: Point, b: Point, c: Point, d: Point) -> Option<f64> {
let (r, s) = (b - a, d - c);
let den = r.x * s.y - r.y * s.x;
if den.abs() <= f64::EPSILON * (r.hypot() * s.hypot()).max(f64::MIN_POSITIVE) {
return None;
}
let ac = c - a;
let t = (ac.x * s.y - ac.y * s.x) / den;
let u = (ac.x * r.y - ac.y * r.x) / den;
((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t)
}
#[cfg(test)]
mod tests {
use super::*;
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<Point> {
vec![Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)]
}
/// `line` cut by `r` with no limit on the work.
fn cut(r: &Region, line: &[Point]) -> Vec<Vec<Point>> {
let mut unlimited = u64::MAX;
r.cut(line, &mut unlimited).unwrap()
}
#[test]
fn a_line_across_a_square_keeps_its_middle() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let pieces = cut(&r, &[Point::new(-5.0, 5.0), Point::new(15.0, 5.0)]);
assert_eq!(pieces, vec![vec![Point::new(0.0, 5.0), Point::new(10.0, 5.0)]]);
// Wholly outside, nothing; wholly inside, the line as it is.
assert!(cut(&r, &[Point::new(20.0, 0.0), Point::new(30.0, 0.0)]).is_empty());
let inside = vec![Point::new(1.0, 1.0), Point::new(9.0, 1.0), Point::new(9.0, 9.0)];
assert_eq!(cut(&r, &inside), vec![inside]);
}
/// Each piece between crossings costs a pass over the edges: a line through the teeth of a
/// comb spends far more than its two points times the edges.
#[test]
fn every_piece_between_crossings_is_paid_for() {
let mut ring = vec![];
for i in 0..100 {
let x = f64::from(i) * 4.0;
ring.extend([Point::new(x, 0.0), Point::new(x + 2.0, 0.0), Point::new(x + 2.0, 10.0), Point::new(x + 4.0, 10.0)]);
}
ring.extend([Point::new(400.0, 20.0), Point::new(0.0, 20.0)]);
let r = Region::new(vec![ring], FillRule::NonZero).unwrap();
let line = [Point::new(-1.0, 5.0), Point::new(401.0, 5.0)];
// 402 edges, 200 crossings, 201 pieces: 402 + 201 × 2 × 402 tests.
let need = 402 + 201 * 2 * 402;
let mut budget = need;
assert_eq!(r.cut(&line, &mut budget).map(|p| p.len()), Some(100));
assert_eq!(budget, 0);
let mut short = need - 1;
assert!(r.cut(&line, &mut short).is_none());
// A segment off the region's box costs nothing.
assert_eq!(r.cut(&[Point::new(0.0, 30.0), Point::new(9.0, 30.0)], &mut 0), Some(vec![]));
}
/// A line along any of the region's edges is inside it.
#[test]
fn a_line_on_an_edge_is_kept() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
for line in [
[Point::new(0.0, 0.0), Point::new(10.0, 0.0)],
[Point::new(10.0, 0.0), Point::new(10.0, 10.0)],
[Point::new(10.0, 10.0), Point::new(0.0, 10.0)],
[Point::new(0.0, 10.0), Point::new(0.0, 0.0)],
] {
assert_eq!(cut(&r, &line), vec![line.to_vec()], "{line:?}");
}
}
#[test]
fn a_line_leaving_and_coming_back_is_two_pieces() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0)];
assert_eq!(cut(&r, &line), vec![vec![Point::new(5.0, 5.0), Point::new(10.0, 5.0)], vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0)]]);
}
/// A closed line that starts inside, leaves and comes back is one piece: the part after it
/// comes back runs on into the part before it left.
#[test]
fn a_closed_line_cut_once_stays_one_piece() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0)];
assert_eq!(cut(&r, &line), vec![vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0), Point::new(10.0, 5.0)]]);
}
#[test]
fn the_fill_rule_decides_a_hole() {
let rings = vec![square(0.0, 0.0, 10.0, 10.0), square(3.0, 3.0, 7.0, 7.0)];
let line = [Point::new(-1.0, 5.0), Point::new(11.0, 5.0)];
// Both rings wind the same way: even-odd cuts a hole, non-zero doesn't.
let even_odd = cut(&Region::new(rings.clone(), FillRule::EvenOdd).unwrap(), &line);
assert_eq!(even_odd, vec![vec![Point::new(0.0, 5.0), Point::new(3.0, 5.0)], vec![Point::new(7.0, 5.0), Point::new(10.0, 5.0)]]);
let non_zero = cut(&Region::new(rings, FillRule::NonZero).unwrap(), &line);
assert_eq!(non_zero.len(), 1);
assert!(Region::new(vec![vec![Point::ZERO, Point::new(1.0, 1.0)]], FillRule::NonZero).is_none());
}
}
+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));
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
//! HP-GL/2 plot file reader.
//! HP-GL/2 plot file reader and writer.
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
@@ -12,13 +12,13 @@
//! clipping) is approximated or left out, and each such loss comes back as a warning.
//!
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`].
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
//!
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
//! - `writer`: group codes, handles and the file's sections, tables and objects.
//! - `scene`: the document walk that turns objects into entities.
//! - `import`: the reader, entities to art, blocks to symbols.
//! - `hpgl`: the HP-GL reader, the pen's moves to paths.
//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves.
mod aci;
pub mod hpgl;
+112 -1
View File
@@ -73,6 +73,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
lib!("sky-gradients", "Sky Gradients", sky_gradients),
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
lib!("organic-fades", "Organic Fades", organic_fades),
lib!("holographic", "Holographic", holographic),
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
lib!("radial-glows", "Radial Glows", radial_glows),
];
@@ -531,6 +532,62 @@ fn organic_fades() -> (Vec<Swatch>, Vec<SwatchGroup>) {
(s, vec![])
}
/// The stops of a [`holographic`] look: offset, opacity and midpoint toward the next stop.
type Bands = [(f32, f32, f32)];
/// Foil: six opaque linear bands, a narrow bright one among them, with off-center midpoints.
const FOIL: &Bands = &[(0.0, 1.0, 0.37), (0.13, 1.0, 0.64), (0.34, 1.0, 0.28), (0.48, 1.0, 0.68), (0.71, 1.0, 0.42), (1.0, 1.0, 0.5)];
/// Glow: five radial rings from a bright center out to a clear edge.
const GLOW: &Bands = &[(0.0, 0.95, 0.4), (0.12, 1.0, 0.58), (0.31, 0.72, 0.34), (0.58, 0.3, 0.62), (1.0, 0.0, 0.5)];
/// A [`holographic`] look: name, kind, stops and the OKLCH color of each stop in order.
type Look = (&'static str, GradientKind, &'static Bands, Anchors);
/// Iridescent looks mixed perceptually: linear foils of narrow pastel and pearl bands, and radial
/// glows that fade out to a clear edge. Each color is one stop of [`FOIL`] or [`GLOW`] in order.
fn holographic() -> (Vec<Swatch>, Vec<SwatchGroup>) {
use GradientKind::{Linear, Radial};
// Pale pearl, pastel and deep-violet OKLCH `(l, c, h°)` anchors.
const PEARL: (f64, f64, f64) = (0.97, 0.01, 90.0);
const SILVER: (f64, f64, f64) = (0.86, 0.015, 255.0);
const BLUSH: (f64, f64, f64) = (0.82, 0.055, 340.0);
const VIOLET: (f64, f64, f64) = (0.72, 0.1, 310.0);
const BLUE: (f64, f64, f64) = (0.73, 0.09, 255.0);
const CYAN: (f64, f64, f64) = (0.79, 0.09, 210.0);
const MINT: (f64, f64, f64) = (0.82, 0.08, 165.0);
const LIME: (f64, f64, f64) = (0.86, 0.085, 125.0);
const GOLD: (f64, f64, f64) = (0.89, 0.08, 85.0);
const AMBER: (f64, f64, f64) = (0.82, 0.1, 55.0);
const CORAL: (f64, f64, f64) = (0.78, 0.09, 25.0);
const DEEP_VIOLET: (f64, f64, f64) = (0.42, 0.04, 275.0);
let looks: [Look; 14] = [
("Prism Veil", Linear, FOIL, &[SILVER, BLUSH, PEARL, CYAN, VIOLET, SILVER]),
("Opal Ribbon", Linear, FOIL, &[PEARL, MINT, BLUSH, PEARL, BLUE, SILVER]),
("Pearl Flash", Linear, FOIL, &[SILVER, VIOLET, PEARL, GOLD, CYAN, PEARL]),
("Aurora Foil", Linear, FOIL, &[BLUE, MINT, PEARL, BLUSH, CYAN, SILVER]),
("Iris Sheen", Linear, FOIL, &[BLUSH, VIOLET, PEARL, CYAN, MINT, SILVER]),
("Frost Spectrum", Linear, FOIL, &[CYAN, BLUE, PEARL, MINT, BLUSH, SILVER]),
("Silver Tide", Linear, FOIL, &[SILVER, CYAN, PEARL, VIOLET, BLUE, SILVER]),
("Rose Quartz Gleam", Radial, GLOW, &[PEARL, BLUSH, CORAL, VIOLET, BLUSH]),
("Moonstone Shift", Radial, GLOW, &[PEARL, CYAN, MINT, BLUE, SILVER]),
("Violet Edge", Radial, GLOW, &[PEARL, VIOLET, BLUSH, BLUE, DEEP_VIOLET]),
("Mint Halo", Radial, GLOW, &[PEARL, MINT, CYAN, LIME, MINT]),
("Amber Corona", Radial, GLOW, &[PEARL, GOLD, AMBER, CORAL, AMBER]),
("Polar Glint", Radial, GLOW, &[PEARL, CYAN, BLUE, VIOLET, BLUE]),
("Pastel Orbit", Radial, GLOW, &[PEARL, BLUSH, MINT, CYAN, VIOLET]),
];
let s = looks
.iter()
.map(|&(name, kind, bands, colors)| {
let stops: Vec<(f32, Color, f32, f32)> =
bands.iter().zip(colors).map(|(&(at, opacity, mid), &(l, c, h))| (at, oklch(l, c, h), opacity, mid)).collect();
gradient_with(name, kind, GradientMethod::Perceptual, &stops)
})
.collect();
(s, vec![])
}
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
const SEQUENTIAL: [(&str, Anchors); 5] = [
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
@@ -708,9 +765,63 @@ mod tests {
}
}
#[test]
fn holographic_holds_seven_opaque_foils_and_seven_glows_to_a_clear_edge() {
let lib = builtin_library("holographic").unwrap();
let order: Vec<&str> = GRADIENT_LIBRARIES.iter().map(|b| b.name).collect();
assert_eq!(order.iter().position(|n| *n == "Holographic"), Some(4), "after Organic Fades: {order:?}");
assert!(lib.groups.is_empty());
assert_eq!(lib.len(), 14);
// No name another built-in gradient has, in any case.
let mut others = std::collections::HashSet::new();
for other in builtins().filter(|b| b.id != "holographic").map(|b| builtin_library(b.id).unwrap()) {
others.extend(other.iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| s.name.to_lowercase()));
}
assert!(lib.iter().all(|s| !others.contains(&s.name.to_lowercase())), "a name another library has");
let (mut foils, mut glows) = (0, 0);
for s in lib.iter() {
let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) };
let (g, n) = (&g.gradient, &s.name);
assert_eq!(g.method, GradientMethod::Perceptual, "{n}");
let bands = match &g.kind {
GradientKind::Linear => {
foils += 1;
FOIL
}
GradientKind::Radial => {
glows += 1;
GLOW
}
kind => panic!("{n}: {kind:?}"),
};
// Every stop takes its band's offset, opacity and midpoint, and none is dropped.
let shape: Vec<(f32, f32, f32)> = g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect();
assert_eq!(shape, bands, "{n}");
assert!(g.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{n}");
}
assert_eq!((foils, glows), (7, 7));
// Foils are opaque; a glow is clear at the edge.
assert!(FOIL.iter().all(|&(_, opacity, _)| opacity == 1.0));
assert_eq!(GLOW.last().map(|b| b.1), Some(0.0));
// Every segment's midpoint is off center and within the Gradient panel's 13 to 87%.
for bands in [FOIL, GLOW] {
assert!(bands[..bands.len() - 1].iter().all(|&(_, _, m)| (m - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&m)));
assert!(bands.windows(2).all(|w| w[0].0 < w[1].0) && bands[0].0 == 0.0 && bands[bands.len() - 1].0 == 1.0);
}
// Sampled colors: Prism Veil's bright band is the pearl anchor, and Violet Edge ends in deep violet.
let stop = |name: &str, i: usize| match &lib.swatch(name).unwrap().paint {
Paint::Gradient(g) => g.gradient.stops[i].color,
_ => panic!("{name} is a gradient"),
};
assert_eq!(stop("Prism Veil", 2), oklch(0.97, 0.01, 90.0));
assert_eq!(stop("Violet Edge", 4), oklch(0.42, 0.04, 275.0));
let [l, ..] = to_oklab(&stop("Violet Edge", 4));
assert!(l < 0.5 && to_oklab(&stop("Prism Veil", 2))[0] > 0.95, "{l}");
}
#[test]
fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() {
for b in builtins().filter(|b| b.id != "organic-fades") {
for b in builtins().filter(|b| !["organic-fades", "holographic"].contains(&b.id)) {
for s in builtin_library(b.id).unwrap().iter() {
if let Paint::Gradient(g) = &s.paint {
assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name);
+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
+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")]
+7
View File
@@ -28,6 +28,12 @@ pub struct CharStyle {
/// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
/// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by
/// itself: the first character of the text, of a paragraph, or after a forced line break. The
/// Type tool's caret before such a character kerns with it (#5111), since there is no character
/// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect.
#[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")]
pub kerning_before: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -411,6 +417,7 @@ impl Default for CharStyle {
leading: None,
tracking: 0.0,
kerning: None,
kerning_before: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
+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,
}
}
}
+1
View File
@@ -365,6 +365,7 @@ impl ParaList {
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
kerning_before: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
underline: false,
+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
+10 -5
View File
@@ -741,10 +741,11 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
if surfaces.is_empty() {
return None;
}
// A classic surface whose gradient has transparent stops carries an opacity mask, and stays whole.
let plain = surfaces.iter().all(|(depths, s, _)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
// A classic surface whose gradient has transparent stops carries an opacity mask: its faces go
// in runs, as a transparent member's do, and each run carries the mask.
let masks: Vec<Option<Box<vectorcraft_doc::OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
let opaque = opaque && masks.iter().all(Option::is_none);
let children = if !plain {
scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0)
} else {
@@ -786,7 +787,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend });
continue;
};
if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() {
let masked = masks.get(member).is_some_and(Option::is_some);
if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() && !masked {
runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend });
continue;
}
@@ -822,6 +824,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
let mut group = Node::group(n.id, r.faces);
group.opacity = r.opacity;
group.blend = r.blend;
if let Some(mask) = r.member.and_then(|m| masks.get(m)).cloned().flatten() {
group.extras_mut().mask = Some(mask);
}
Some((r.scopes, Arc::new(group)))
});
scoped(n.id, runs.collect(), 0)
+44
View File
@@ -152,6 +152,50 @@ mod tests {
assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn");
}
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
/// every member's faces: its faces go in runs that carry its opacity mask, and the other
/// member's faces are drawn between them where they are in front of some and behind others.
#[test]
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let art = |end_opacity: f32| {
let mut end = GradientStop::new(1.0, Color::BLACK);
end.opacity = end_opacity;
let gradient =
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end]))));
let mut b = BezPath::new();
b.move_to((150.0, 60.0));
b.line_to((170.0, 60.0));
b.line_to((170.0, 140.0));
let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(outer), std::sync::Arc::new(profile())]);
g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({})).unwrap());
crate::revolve_art(&g).unwrap()
};
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
fn visit(n: &Node, order: &mut String) {
if n.extras().mask.is_some() {
order.push('M');
return;
}
if n.path_data().is_some() {
order.push('F');
}
for c in n.children().into_iter().flatten() {
visit(c, order);
}
}
let order = |art: &Node| {
let mut order = String::new();
visit(art, &mut order);
order
};
assert!(!order(&art(1.0)).contains('M'), "an opaque gradient needs no mask");
let masked = order(&art(0.5));
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "faces between the member's runs: {masked}");
}
#[test]
fn bad_parameters_are_refused() {
let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p));
+2
View File
@@ -5,6 +5,7 @@ mod arrange;
mod cleanup;
mod color;
mod contour;
mod cutting;
mod document;
mod image;
mod pathtools;
@@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
v.extend(cleanup::specs());
v.extend(color::specs());
v.extend(contour::specs());
v.extend(cutting::specs());
v.extend(document::specs());
v.extend(image::specs());
v.extend(pathtools::specs());
+11
View File
@@ -0,0 +1,11 @@
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's.
mod stencil;
use super::super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
stencil::specs()
}
@@ -0,0 +1,167 @@
//! Stencil Bridges (`addon.cutting.bridges`), a command of the built-in Cutting Tools add-on: it
//! cuts bridges out of the selected art so that a stencil cut from it holds together. Each island
//! of the stencil sheet (the inside of an O, a hole in a shape) stays joined to the sheet by strips
//! of a set width across the art (<https://en.wikipedia.org/wiki/Stencil> describes islands and
//! bridges). The art becomes one compound path with the gaps cut, and type is measured by its
//! glyph outlines. The original goes to a new hidden layer or is replaced. The geometry is
//! `vectorcraft_pathops::addons::stencil`.
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::addons::stencil::{BRIDGE_WIDTH_RANGE, BridgeDirection, BridgeSpec, MAX_BRIDGES_PER_ISLAND, stencil_bridges};
use super::super::super::edit::selected_roots;
use super::super::super::object::choice;
use super::super::super::pathops::{length_given, node_path, pf_leaves, shape_node, style_of};
use super::super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or};
use crate::{EngineError, Result, Session};
/// Stencil Bridges' command (the id in [`specs`]).
pub const BRIDGES: &str = "addon.cutting.bridges";
/// The ways bridges run, as `direction` takes them.
pub const DIRECTIONS: [&str; 3] = ["vertical", "horizontal", "shortest"];
/// A bridge's width when none is given, in points.
pub const DEFAULT_WIDTH: f64 = 2.0;
/// The name of the hidden layer the original art goes to (numbered when it is taken).
const ORIGINAL_LAYER: &str = "Stencil Original";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.bridges",
"Stencil Bridges…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{width?: pt 0.01..1000 (2), count?: whole number 1..16 of bridges each island gets (1; fewer where it has no room and more where other islands are joined to the sheet through it), direction?: \"vertical\" | \"horizontal\" | \"shortest\" (the shortest crossing among 12 directions 15° apart) (\"vertical\"), keepOriginal?: true (the selected objects that make up the art go to a new hidden layer named \"Stencil Original\"; false: they are replaced)} cut bridges out of the selected art so that a stencil cut from it holds together: every island (an area the art encloses, such as the inside of an O or a hole in a shape) is joined to the area around the art by strips `width` wide across the art, the shortest crossings first, then spread around the island. The art is measured by the area its outlines enclose, whatever their paint (open paths closed as a fill closes them; type by its glyph outlines; groups by their members; hidden objects left out) and becomes one compound path with the gaps cut, painted as the topmost selected shape, in the place of the topmost selected object that has outlines. Selected objects with no outlines in the art stay where they are. Art with no islands, or none a bridge of the width fits, is left as it is, with a warning and no undo step; islands the cut result still encloses are counted in `unbridged`, with a warning. Art that reaches more than 10000000 pt from the origin, has more than 200000 anchors, whose combined area has more than 4000 outlines, whose outlines need more than 200000 straight segments, or that takes more work to measure than a fixed budget allows is refused. One undo step; the new path is selected → {id, islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}",
has_selection,
bridges
)]
}
fn bridges(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = BRIDGES;
let width = length_given(p, "width", C)?.unwrap_or(DEFAULT_WIDTH);
let (lo, hi) = BRIDGE_WIDTH_RANGE;
if !(lo..=hi).contains(&width) {
return Err(bad(C, format!("`width` must be from {lo} to {hi} pt")));
}
let count = number_or(p, "count", 1.0, C)?;
if count.fract() != 0.0 || !(1.0..=MAX_BRIDGES_PER_ISLAND as f64).contains(&count) {
return Err(bad(C, format!("`count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND}")));
}
let direction = match choice(p, C, "direction", &DIRECTIONS)?.unwrap_or("vertical") {
"horizontal" => BridgeDirection::Horizontal,
"shortest" => BridgeDirection::Shortest,
_ => BridgeDirection::Vertical,
};
let keep = switch_or(p, "keepOriginal", true, C)?;
let selected = selected_roots(s)?;
let (bridged, style, roots) = {
let d = &s.doc()?.doc;
// The art's outlines, the selected objects they come from, and the topmost object that
// has some, whose paint the result takes.
let mut parts: Vec<(PathData, FillRule)> = vec![];
let mut roots = vec![];
let mut top = None;
for id in selected {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
let mut leaves = vec![];
shown_leaves(d, n, &mut leaves);
let before = parts.len();
for leaf in leaves {
if let Some(part) = node_path(&leaf).filter(|(p, _)| !p.is_empty()) {
parts.push(part);
top = Some(leaf);
}
}
if parts.len() > before {
roots.push(id);
}
}
let Some(top) = top else {
let label = "Stencil Bridges";
return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text")));
};
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let spec = BridgeSpec { width, count: count as usize, direction };
let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?;
(bridged, style_of(&top), roots)
};
let summary = |id: Option<NodeId>, original: Option<NodeId>| {
json!({
"id": id.map(|i| i.0), "islands": bridged.islands, "bridges": bridged.bridges, "unbridged": bridged.unbridged,
"original": original.map(|i| i.0),
})
};
if bridged.islands == 0 {
let mut r = summary(None, None);
// The warnings are written as `EngineError::Other` so that the i18n message scan
// (`untranslated_report`) finds them.
r["warning"] = json!(EngineError::Other("The selected art has no islands to bridge".into()).to_string());
return Ok(r);
}
if bridged.bridges == 0 {
let mut r = summary(None, None);
r["warning"] = json!(EngineError::Other("No bridge of this width fits across the selected art".into()).to_string());
return Ok(r);
}
let path = bridged.path.clone();
let (id, original) = s.edit("Stencil Bridges", |d, sel| {
let mut made = shape_node(d, path, None);
style.apply(&mut made);
let mut taken = vec![];
let id = d.wrap(&roots, |nodes| {
taken = nodes;
made
})?;
let original = if keep { Some(hide_originals(d, taken)?) } else { None };
sel.set([id]);
Ok((id, original))
})?;
let mut r = summary(Some(id), original);
if bridged.unbridged > 0 {
r["warning"] = json!(EngineError::Other("Some islands could not be joined to the area around the art".into()).to_string());
}
Ok(r)
}
/// The leaf shapes of `n` as [`pf_leaves`] gives them, leaving out hidden objects and the members
/// of hidden groups.
fn shown_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
for c in children {
shown_leaves(d, c, out);
}
}
_ => pf_leaves(d, n, out),
}
}
/// Put `nodes` in a new hidden layer above the others, in their order, and return its id.
fn hide_originals(d: &mut Document, nodes: Vec<Arc<Node>>) -> Result<NodeId> {
let mut taken = std::collections::HashSet::new();
d.walk(|n| {
if n.is_layer() {
taken.extend(n.name.clone());
}
});
let name = std::iter::once(ORIGINAL_LAYER.to_string())
.chain((2..).map(|i| format!("{ORIGINAL_LAYER} {i}")))
.find(|name| !taken.contains(name))
.unwrap_or_else(|| ORIGINAL_LAYER.to_string());
let layer = d.add_layer(Some(&name));
for (i, n) in nodes.into_iter().enumerate() {
d.insert(Some(layer), i, Arc::unwrap_or_clone(n))?;
}
d.node_mut(layer).ok_or(EngineError::NoNode(layer))?.visible = false;
Ok(layer)
}
@@ -0,0 +1,328 @@
//! Tests for `addon.cutting.bridges`: bridges cut out of the selected art keep a stencil's islands
//! attached.
use kurbo::Shape;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::super::super::super::pathops::node_path;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
/// `outer` less each of `holes` (x, y, width, height), as one compound path, selected.
fn shape_with_holes(s: &mut Session, outer: (f64, f64, f64, f64), holes: &[(f64, f64, f64, f64)]) -> NodeId {
let mut ids = vec![rect(s, outer.0, outer.1, outer.2, outer.3)];
for h in holes {
ids.push(rect(s, h.0, h.1, h.2, h.3));
}
select(s, &ids);
let r = s.execute("object.pathfinder.minusFront", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
select(s, &[id]);
id
}
/// A square O: 100 pt across at (100, 100), with bars 20 pt thick.
fn letter_o(s: &mut Session) -> NodeId {
shape_with_holes(s, (100.0, 100.0, 100.0, 100.0), &[(120.0, 120.0, 60.0, 60.0)])
}
fn id_of(r: &Value) -> NodeId {
NodeId(r["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// Is `(x, y)` inside the area object `id` fills?
fn fills(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
let (path, rule) = node_path(&node(s, id)).unwrap();
let w = path.to_bezpath().winding(kurbo::Point::new(x, y));
match rule {
vectorcraft_geom::FillRule::EvenOdd => w % 2 != 0,
vectorcraft_geom::FillRule::NonZero => w != 0,
}
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn an_o_gets_a_bridge_and_the_original_goes_to_a_hidden_layer() {
let mut s = session();
let o = letter_o(&mut s);
let fill = node(&s, o).appearance.fill_paint();
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)));
assert!(r.get("warning").is_none(), "{r}");
let made = id_of(&r);
assert_eq!(s.doc().unwrap().selection.objects, vec![made]);
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
// The bridge crosses the top bar in the middle; the rest of the O stays, painted as it was.
assert!(!fills(&s, made, 150.0, 110.0));
assert!(fills(&s, made, 130.0, 110.0) && fills(&s, made, 150.0, 190.0) && fills(&s, made, 110.0, 150.0));
assert_eq!(node(&s, made).appearance.fill_paint(), fill);
// The original, unchanged, is on a new hidden layer.
let layer = NodeId(r["original"].as_u64().unwrap());
let l = node(&s, layer);
assert_eq!((l.name.as_deref(), l.visible), (Some("Stencil Original"), false));
let held = s.doc().unwrap().doc.children(Some(layer)).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
assert_eq!(held, Some(vec![o]));
// The island is joined to the sheet: the result has none left.
let again = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!(again["islands"], json!(0));
assert!(again["warning"].as_str().is_some_and(|w| w.contains("no islands")), "{again}");
assert_eq!(undo_steps(&s), steps + 1, "no islands: no undo step");
// Undo puts the O back where it was and takes the layer away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(layer).is_none() && d.node(made).is_none() && d.node(o).is_some_and(|n| n.visible));
}
#[test]
fn keep_original_off_replaces_the_art() {
let mut s = session();
let o = letter_o(&mut s);
let layers = s.doc().unwrap().doc.layers.len();
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
assert_eq!(r["original"], Value::Null);
let d = &s.doc().unwrap().doc;
assert!(d.node(o).is_none() && d.node(id_of(&r)).is_some());
assert_eq!(d.layers.len(), layers);
}
#[test]
fn the_bridge_is_as_wide_as_asked() {
for width in [2.0, 8.0] {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": width})).unwrap();
let made = id_of(&r);
let half = width / 2.0;
assert!(!fills(&s, made, 150.0 - half * 0.9, 110.0) && !fills(&s, made, 150.0 + half * 0.9, 110.0), "{width}");
assert!(fills(&s, made, 150.0 - half * 1.2, 110.0) && fills(&s, made, 150.0 + half * 1.2, 110.0), "{width}");
}
}
#[test]
fn the_width_takes_units() {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": "2 mm"})).unwrap();
let made = id_of(&r);
// 2 mm is about 5.67 pt.
assert!(!fills(&s, made, 152.5, 110.0) && fills(&s, made, 154.0, 110.0));
}
#[test]
fn the_shortest_crossing_takes_the_thinnest_bar() {
let mut s = session();
// The left bar is 5 pt thick, the others 20.
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(105.0, 120.0, 75.0, 60.0)]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "shortest"})).unwrap();
let made = id_of(&r);
assert!(!fills(&s, made, 102.5, 150.0), "the bridge crosses the thin bar");
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 190.0, 150.0));
}
#[test]
fn horizontal_bridges_cross_the_sides() {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "horizontal"})).unwrap();
let made = id_of(&r);
assert!(!fills(&s, made, 110.0, 150.0) || !fills(&s, made, 190.0, 150.0));
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 150.0, 190.0));
}
#[test]
fn a_shape_with_two_holes_gets_a_bridge_for_each() {
let mut s = session();
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 50.0), &[(110.0, 110.0, 30.0, 30.0), (160.0, 110.0, 30.0, 30.0)]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 3, "count": 2})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(4), json!(0)));
let made = id_of(&r);
for x in [125.0, 175.0] {
assert!(!fills(&s, made, x, 105.0) && !fills(&s, made, x, 145.0), "{x}: a bridge above and below each hole");
}
}
#[test]
fn type_is_bridged_by_its_glyph_outlines_and_stays_type() {
let mut s = session();
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "O", "size": 144})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 3})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)), "{r}");
assert!(matches!(node(&s, id_of(&r)).kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
// The type itself is not converted: it is on the hidden layer, still type.
assert!(matches!(node(&s, t).kind, NodeKind::Text(_)));
assert!(!node(&s, NodeId(r["original"].as_u64().unwrap())).visible);
}
#[test]
fn the_result_is_painted_as_the_topmost_object_with_outlines() {
let mut s = session();
let o = letter_o(&mut s);
// Type holding only a space draws no outlines, so it does not set the paint, though it is on top.
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[o]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let red = node(&s, o).appearance.fill_paint();
select(&mut s, &[o, t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!(node(&s, id_of(&r)).appearance.fill_paint(), red);
}
#[test]
fn hidden_members_of_a_group_are_left_out() {
let mut s = session();
let o = letter_o(&mut s);
// A hidden square that would fill the O's hole, grouped with it.
let cover = rect(&mut s, 120.0, 120.0, 60.0, 60.0);
select(&mut s, &[o, cover]);
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[cover]);
s.execute("object.hide", &json!({})).unwrap();
select(&mut s, &[g]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone()), (json!(1), json!(1)), "{r}");
}
#[test]
fn art_without_islands_changes_nothing() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
select(&mut s, &[a]);
let (steps, count) = (undo_steps(&s), s.doc().unwrap().doc.node_count());
let r = s.execute("addon.cutting.bridges", &json!({})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["id"].clone()), (json!(0), json!(0), Value::Null));
assert!(r["warning"].as_str().is_some(), "{r}");
assert_eq!((undo_steps(&s), s.doc().unwrap().doc.node_count()), (steps, count));
}
#[test]
fn an_island_too_narrow_for_the_width_is_reported() {
let mut s = session();
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(149.0, 120.0, 2.0, 60.0)]);
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(0), json!(1)));
assert!(r["warning"].as_str().is_some_and(|w| w.contains("No bridge")), "{r}");
assert_eq!(undo_steps(&s), steps);
}
#[test]
fn islands_left_without_a_bridge_are_reported_beside_the_ones_bridged() {
let mut s = session();
// A wide hole on the left and a 2 pt slot on the right.
shape_with_holes(&mut s, (100.0, 100.0, 200.0, 100.0), &[(120.0, 120.0, 60.0, 60.0), (249.0, 120.0, 2.0, 60.0)]);
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(1), json!(1)));
assert!(r["warning"].as_str().is_some_and(|w| w.contains("could not be joined")), "{r}");
assert_eq!(undo_steps(&s), steps + 1);
assert!(!fills(&s, id_of(&r), 150.0, 110.0), "the wide hole is bridged");
}
#[test]
fn each_run_keeps_its_original_on_a_layer_of_its_own() {
let mut s = session();
letter_o(&mut s);
let first = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
shape_with_holes(&mut s, (300.0, 100.0, 100.0, 100.0), &[(320.0, 120.0, 60.0, 60.0)]);
let second = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
let name = |r: &Value| node(&s, NodeId(r["original"].as_u64().unwrap())).name;
assert_eq!((name(&first).as_deref(), name(&second).as_deref()), (Some("Stencil Original"), Some("Stencil Original 2")));
}
#[test]
fn bad_params_are_refused() {
let mut s = session();
letter_o(&mut s);
let steps = undo_steps(&s);
for (key, p) in [
("width", json!({"width": 0})),
("width", json!({"width": 5000})),
("width", json!({"width": "wide"})),
("count", json!({"count": 0})),
("count", json!({"count": 1.5})),
("count", json!({"count": 17})),
("count", json!({"count": "two"})),
("direction", json!({"direction": "diagonal"})),
("direction", json!({"direction": 90})),
("keepOriginal", json!({"keepOriginal": "yes"})),
] {
let e = s.execute("addon.cutting.bridges", &p).unwrap_err().to_string();
assert!(e.contains(key), "{p}: {e}");
}
assert_eq!(undo_steps(&s), steps);
assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run");
}
#[test]
fn selected_objects_without_outlines_stay_where_they_are() {
for keep in [true, false] {
let mut s = session();
let o = letter_o(&mut s);
// Type holding only a space has no outlines.
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
let layer = |s: &Session, id: NodeId| s.doc().unwrap().doc.layer_of(id);
let before = layer(&s, t);
select(&mut s, &[o, t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": keep})).unwrap();
assert_eq!(r["bridges"], json!(1), "{r}");
assert!(node(&s, t).visible && layer(&s, t) == before, "keep {keep}: the type stays where it was");
if keep {
let held =
s.doc().unwrap().doc.children(Some(NodeId(r["original"].as_u64().unwrap()))).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
assert_eq!(held, Some(vec![o]));
}
}
}
#[test]
fn the_result_takes_the_place_of_the_topmost_selected_object() {
let mut s = session();
let o = letter_o(&mut s);
let between = rect(&mut s, 400.0, 100.0, 50.0, 50.0);
let o2 = shape_with_holes(&mut s, (500.0, 100.0, 100.0, 100.0), &[(520.0, 120.0, 60.0, 60.0)]);
let above = rect(&mut s, 400.0, 300.0, 50.0, 50.0);
select(&mut s, &[o, o2]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
let made = id_of(&r);
let d = &s.doc().unwrap().doc;
let order: Vec<NodeId> = d.children(d.layer_of(made)).unwrap().iter().map(|n| n.id).collect();
let at = |id: NodeId| order.iter().position(|i| *i == id).unwrap();
assert!(at(between) < at(made) && at(made) < at(above), "{order:?}");
}
#[test]
fn the_result_is_one_path_when_one_outline_is_left() {
for (count, plain) in [(1, true), (2, false)] {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "count": count})).unwrap();
let kind = node(&s, id_of(&r)).kind;
// One bridge leaves a C, one outline; two leave two pieces.
assert_eq!(matches!(kind, NodeKind::Path { .. }), plain, "count {count}");
assert_eq!(matches!(kind, NodeKind::Compound { .. }), !plain, "count {count}");
}
}
+8 -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()) {
@@ -1512,6 +1512,9 @@ impl Picked {
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
// The space before a line's first character belongs to where the character is,
// not to how it looks: a sample from one doesn't move the target's (#5111).
kerning_before: st.kerning_before,
..cs.clone()
};
}
+29 -1
View File
@@ -11,7 +11,9 @@ use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_brush::{
self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation,
};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes};
@@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary {
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes },
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
@@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary {
built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes)
}
/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity
/// and stiffness (%).
fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush {
Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) }
}
/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for
/// blending and texture.
fn bristle_brushes() -> BrushLibrary {
use BristleShape::*;
let brushes = vec![
bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]),
bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]),
bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]),
bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]),
bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]),
bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]),
bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]),
bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]),
bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]),
bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]),
];
built("Bristle Brushes", Document::new(100.0, 100.0), brushes)
}
fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush {
let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() });
Brush { name: name.into(), kind }
+1 -1
View File
@@ -36,7 +36,7 @@ use brushes::{
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush,
};
pub(crate) use dynamic::{edit_part, part_only, selected_part};
pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part};
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{
+34 -9
View File
@@ -2,7 +2,7 @@
//! appearance of objects in the symbol's art and stays linked to the symbol
//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance
//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still
//! there ([`fit_overrides`]).
//! there and draws them ([`fit_overrides`]).
use std::sync::Arc;
@@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str {
}
/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or
/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art.
/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]).
pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
for id in ids {
let art = match d.node(*id) {
@@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
let Some(n) = d.node_mut(*id) else { continue };
let mut e = n.extras_mut();
match art {
Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art),
Some(art) => fit_parts(&mut e.symbol_overrides, &art),
None => e.symbol_overrides.clear(),
}
}
@@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<(
fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> {
let asked = p.get("fill").is_some_and(|v| !v.is_null());
let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }));
let draws_nothing = |f: &vectorcraft_color::Paint| match f {
vectorcraft_color::Paint::Gradient(_) => true,
vectorcraft_color::Paint::Pattern { .. } => true,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false,
};
if asked && is_instance && fill.is_some_and(draws_nothing) {
if asked && is_instance && fill.is_some_and(|f| !stains(f)) {
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
}
Ok(())
}
/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only.
fn stains(f: &vectorcraft_color::Paint) -> bool {
match f {
vectorcraft_color::Paint::Gradient(_) => false,
vectorcraft_color::Paint::Pattern { .. } => false,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true,
}
}
/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each
/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change
/// left with nothing in it is dropped.
pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
symbol_overrides::retain_parts(overrides, art);
for o in overrides.iter_mut() {
let Some(n) = symbol_overrides::part(art, &o.path) else { continue };
let instance = matches!(n.kind, NodeKind::SymbolInstance { .. });
if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) {
o.fill = None;
}
if paint_refusal(n, "stroke", FIT).is_some() {
o.stroke = None;
}
}
overrides.retain(|o| !o.is_empty());
}
/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show.
const FIT: &str = "symbol.update";
/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or
/// an object of `paint.setFill`'s paint params sets it.
fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> {
+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};
@@ -271,7 +271,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()) {
+2
View File
@@ -17,6 +17,7 @@
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//! - `hpgl`: HP-GL export options and plots.
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
//! built-in format reads).
//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`).
@@ -40,6 +41,7 @@ mod load;
mod mesh3d;
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
mod heifread;
mod hpgl;
mod jp2read;
mod metafile;
mod naming;
@@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def
| "dxf"
| "emf"
| "wmf"
| "hpgl"
| "ai"
)
};
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
}
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
// themselves: SVG only when it outlines or embeds fonts).
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl"))
&& let Some(w) = crate::cmd::fonts::substitution_warning(doc)
{
enc.warnings.push(w);
@@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
@@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
"ai" => return super::save::encode_ai(C, f, doc, p),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
+13 -3
View File
@@ -1,6 +1,7 @@
use super::super::unique_name;
use super::{
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported,
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text,
unsupported,
};
/// One option a format's encoder reads from the export params.
@@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them.
reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false),
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves.
Format {
id: "hpgl",
label: "HP-GL",
extensions: &["plt", "hpgl", "hgl", "hpg"],
mime: "application/vnd.hp-hpgl",
read: true,
write: true,
raster: false,
options: hpgl::OPTIONS,
},
Format {
id: "tga",
label: "Targa",
+126
View File
@@ -0,0 +1,126 @@
//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format:
//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the
//! options parsed from the params.
use serde_json::Value;
use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE};
use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version};
use vectorcraft_doc::Document;
use super::super::{Result, bad, number_or, switch_or, text_param};
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// The options `document.formats` lists for HP-GL.
pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
super::USE_ARTBOARDS,
FormatOption {
name: "hpglVersion",
ty: "string",
default: "\"hpgl\"",
description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)",
},
FormatOption {
name: "curveTolerance",
ty: "number",
default: "0.1",
description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)",
},
FormatOption {
name: "pens",
ty: "array",
default: "null",
description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export",
},
FormatOption {
name: "origin",
ty: "string",
default: "\"bottomLeft\"",
description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)",
},
FormatOption {
name: "sortPaths",
ty: "boolean",
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).
fn parse(p: &Value) -> Result<HpglOptions> {
let mut o = HpglOptions::default();
if let Some(s) = text_param(p, "hpglVersion", C)? {
let key = "hpglVersion";
o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?;
}
let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?;
if !TOLERANCE_RANGE.contains(&x) {
let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end());
return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})")));
}
o.tolerance = x;
match p.get("pens") {
None | Some(Value::Null) => {}
Some(v) => {
let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect());
o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?;
}
}
if let Some(s) = text_param(p, "origin", C)? {
let key = "origin";
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)
}
/// A `#rrggbb` color.
fn hex(v: &Value) -> Option<[u8; 3]> {
let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
}
/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with
/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all.
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> {
let opts = parse(p)?;
let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?;
let pick: ArtboardPick = super::encode::options(f, p)?;
let n = doc.artboards.len();
let boards = match use_artboards {
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
};
let regions = boards
.iter()
.map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
.collect::<Result<Vec<_>>>()?;
let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, &regions).map_err(|e| bad(C, e))?;
let mut enc = Encoded::default();
for (b, out) in boards.into_iter().zip(plots) {
enc.files.push((Some(b), out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+10 -1
View File
@@ -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)?;
}
+1 -1
View File
@@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String {
pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
/// The writable formats a format row can't name.
pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"];
pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"];
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
struct Row {
+2
View File
@@ -431,6 +431,7 @@ fn export_extensions_name_every_writable_format() {
"eps",
"emf",
"wmf",
"plt",
"tga",
"psd"
]
@@ -466,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() {
"eps",
"emf",
"wmf",
"hpgl",
"tga",
"psd"
]
+131 -4
View File
@@ -1,8 +1,9 @@
//! HP-GL plotter files through the commands: `document.open` by extension or by content, File ›
//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't
//! HP-GL refused with a message.
//! Place as one group of paths, the dialogs' filters and the format list, files that aren't
//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its
//! options checked.
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::*;
@@ -100,5 +101,131 @@ fn the_dialogs_and_the_format_list_name_hpgl() {
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
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(false), Some(false)));
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", "insideFirst"] {
assert!(f["options"].get(o).is_some(), "{o}: {f}");
}
assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl");
assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl");
}
fn bytes(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red.
fn two_boards() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
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": 30, "height": 30})).unwrap();
let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap();
s
}
#[test]
fn export_writes_a_plot_of_one_artboard_or_each() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap();
assert_eq!(r["format"], "hpgl");
// The first artboard's square, 1016 units to the inch from its bottom-left corner.
assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap();
assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1");
assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r));
let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}");
// One pen numbering for every file: the red square is pen 2 in the second.
assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
// The bounds of the art: both squares, the second pen red, read back as two paths.
let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap());
assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art));
let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap();
assert_eq!(back["format"], "hpgl", "{back}");
// One file holds one artboard.
assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err());
}
/// A plot read back opens with the same squares: as wide as the art drew them, a point at most
/// apart from the 30 pt sides plus the 0.35 mm pen.
#[test]
fn a_plot_reads_back_as_its_art() {
let mut s = two_boards();
let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap());
let mut back = Session::new();
back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap();
let doc = &back.doc().unwrap().doc;
let mut paths = 0;
doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(paths, 2);
let board = doc.artboards[0].rect;
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
let pen = 0.35 * 72.0 / 25.4;
assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}");
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
}
#[test]
fn export_options_are_checked() {
let mut s = two_boards();
let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string();
let cases = [
(json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"),
(json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"),
(json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"),
(json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"),
(json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(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());
assert!(e.contains(want), "{p}: {e}");
}
let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect();
assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`"));
// Given options take effect: the center as the origin, a pen table (black is pen 2), and the
// square starting at its corner nearest the origin.
let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true});
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
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]
fn export_selection_writes_a_plot_of_the_selection() {
let mut s = two_boards();
let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap();
assert_eq!(r["format"], "hpgl");
let plot = String::from_utf8(bytes(&r)).unwrap();
assert_eq!(plot.matches("PD").count(), 1, "{plot}");
assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}");
}
+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))?;
+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);
+2 -2
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 ----------
@@ -419,7 +419,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 -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);
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.color.strokeToFill",
"addon.color.swatchLegend",
"addon.contour.set",
"addon.cutting.bridges",
"addon.image.removeBackground",
"addon.pathtools.addPoints",
"addon.pathtools.close",
File diff suppressed because one or more lines are too long
+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() {
+20 -57
View File
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Copy to User Defined",
["Window", "Swatch Libraries"],
None,
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
has_user_folder,
copy_to_user
),
@@ -368,9 +368,7 @@ impl<L: LibraryFile> Libraries<L> {
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
Some(p) => {
// The file's bytes are read: outside the request's read roots, the error names no
// path. A document's swatches (the arm below) are written from the loaded library,
// and the document is not read again.
// Outside the request's read roots: the error names no path.
if !readable(&p) {
return Err(bad(cmd, "the library's file is outside the read roots"));
}
@@ -471,34 +469,19 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
}
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
pub fn concealed_file_name(path: &str) -> String {
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
}
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
/// never readable under a read root.
/// roots in force.
fn readable(path: &str) -> bool {
!crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
crate::file_access::check_read(path).is_ok()
}
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
/// path counts as one the request may not read. The ids are those of the
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
/// to). When another id already is or shows that id, the next free
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
/// keeps the id it was first shown under for the rest of the session, so an id a client was given
/// finds the same library after another one with the same file name is loaded; only a library that
/// shows that id itself (one loaded later without a file under that name) moves it to the next free
/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
/// their full ids, then `others`, in the same order. Without roots in force, none.
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
if crate::file_access::current().is_none() {
return vec![];
@@ -510,7 +493,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
let mut taken = BTreeSet::new();
for ids in [session, others] {
for id in ids {
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
match name {
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
_ => {
@@ -519,32 +502,16 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
}
}
}
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
let mut kept = vec![None; hidden.len()];
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
taken.insert(shown.clone());
*slot = Some(shown.clone());
}
}
// A new id is never one given to another library earlier in the session.
let mut given: BTreeSet<String> = assigned.values().cloned().collect();
hidden
.into_iter()
.zip(kept)
.map(|((id, name), kept)| {
let shown = kept.unwrap_or_else(|| {
let mut shown = format!("loaded/{name}");
let mut n = 2;
while taken.contains(&shown) || given.contains(&shown) {
shown = format!("loaded/{name} ({n})");
n += 1;
}
taken.insert(shown.clone());
given.insert(shown.clone());
assigned.insert(id.clone(), shown.clone());
shown
});
.map(|(id, name)| {
let mut shown = format!("loaded/{name}");
let mut n = 2;
while taken.contains(&shown) {
shown = format!("loaded/{name} ({n})");
n += 1;
}
taken.insert(shown.clone());
(id, shown)
})
.collect()
@@ -779,12 +746,8 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
"user" => (info, lib, false),
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
};
let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
// As the saves with `user: true`: no `path` when the request may not read the file.
if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
out["path"] = json!(path);
}
Ok(out)
let path = s.swatch_libraries.shown_path(&info.id);
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
+4 -4
View File
@@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration};
use vectorcraft_geom::{Affine, Point};
use super::brushsym::{natural_size, place_xf};
use super::brushsym::{fit_parts, natural_size, place_xf};
use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param};
/// The name of the undo step that enters symbol editing mode.
@@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy
}
/// Each instance's appearance changes to objects of symbol `name`'s art follow those objects
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to
/// objects the art still has. That covers the instances on the layers and in the art of the
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art
/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the
/// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts
/// back included) and brushes, opacity-mask art included. The editing layer holds no instance of
/// the symbol, since [`sync`] refuses one.
@@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new:
let mut follow = |o: &mut Vec<SymbolOverride>| {
follow_paths(o, old, new);
match &art {
Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art),
Some(art) => fit_parts(o, art),
None => o.clear(),
}
};
+71 -8
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Tab Stops",
["Window", "Tabs"],
None,
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
has_selection,
set
),
@@ -38,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> {
fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
const C: &str = "text.tabs.set";
let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?;
let max = TabStop::MAX_STOPS;
if arr.len() > max {
return Err(bad(C, format!("at most {max} tab stops")));
}
let mut stops = vec![];
for s in arr {
let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?;
@@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
// More than the limit is taken only when every targeted paragraph already holds at least as
// many stops, so a paragraph that holds more can still lose them one at a time. The supplied
// list is counted before it is read.
let max = TabStop::MAX_STOPS;
let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len);
if supplied > max && supplied > fewest_held(s, p)? {
return Err(bad("text.tabs.set", format!("at most {max} tab stops")));
}
let stops = parse_stops(p)?;
apply(s, p, stops, "Tab Stops")
}
/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none).
fn fewest_held(s: &Session, p: &Value) -> Result<usize> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
let doc = &s.doc()?.doc;
let mut fewest: Option<usize> = None;
for id in &ids {
if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) {
let held = match para_span(range, t) {
None => t.para_styles().map(|pa| pa.tabs.len()).min(),
Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(),
};
fewest = match (fewest, held) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
}
Ok(fewest.unwrap_or(0))
}
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
@@ -159,14 +184,52 @@ mod tests {
let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string();
assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters"));
assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character"));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap();
let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect();
assert!(err(Value::from(many.clone())).contains("at most 256 tab stops"));
let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap();
assert_eq!(ok.len(), 256);
let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string();
assert!(e.contains("at most 256 tab stops"), "{e}");
// The supplied list is counted, not the stops left once those at one position merge.
let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect();
assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops"));
assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256));
let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap();
assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€'));
}
/// A paragraph that already holds more than 256 stops (from a file) can lose them one at a
/// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and
/// a paragraph with fewer stops targeted beside it keeps the limit.
#[test]
fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() {
use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop};
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap());
let held: Vec<TabStop> =
(0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect();
s.edit("Load", |d, _| {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.edit_paras(None, |pa| pa.tabs = held.clone());
}
Ok(())
})
.unwrap();
let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() });
assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops"));
assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299));
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299);
// The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused,
// and 298 for the first alone is taken.
s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap();
let mut both = list(298);
assert!(s.execute("text.tabs.set", &both).is_err());
both["start"] = json!(0);
both["end"] = json!(0);
assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298));
}
#[test]
fn set_get_clear_tabs() {
let mut s = Session::new();
+63 -42
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> {
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Step Type",
[],
None,
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}",
has_doc,
type_step
),
@@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Tracking and Kerning",
[],
Some("Cmd+Alt+Q"),
"{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
"{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_tracking
),
@@ -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);
}
@@ -430,6 +430,8 @@ pub(crate) struct CharChange {
leading: Option<Option<f64>>,
tracking: Option<f64>,
kerning: Option<Option<f64>>,
/// The space before a character that starts a line by itself (`kerningBefore`).
kerning_before: Option<f64>,
optical_kerning: Option<bool>,
baseline_shift: Option<f64>,
h_scale: Option<f64>,
@@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_switches(
p,
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
@@ -720,6 +722,7 @@ impl CharChange {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
},
kerning_before: num("kerningBefore"),
optical_kerning: match p.get("kerning") {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
@@ -777,6 +780,7 @@ impl CharChange {
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
&& self.kerning_before.is_none()
&& self.optical_kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
@@ -883,6 +887,9 @@ impl CharChange {
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.kerning_before {
st.kerning_before = v.clamp(-1000.0, 10000.0);
}
if let Some(v) = self.optical_kerning {
st.optical_kerning = v;
}
@@ -1254,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,
@@ -1344,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);
@@ -1359,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())) {
@@ -1585,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,
@@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
}
/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it
/// came from a caret ([`type_ranges`]).
pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>);
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
/// (None: nothing changes there). Each range comes with whether it came from a caret.
pub(crate) fn type_ranges(
s: &mut Session,
p: &Value,
c: &str,
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret.
pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> {
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
// An object that isn't type is refused as `ids` of it is.
@@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges(
let text = text_ref(s, id)?.plain_text();
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
let (a, b, caret) = if a < b {
(a, b, false)
(a, b, None)
} else {
match at_caret(&text, a) {
Some((a, b)) => (a, b, true),
Some((x, y)) => (x, y, Some(a)),
None => continue,
}
};
@@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
Step::Tracking | Step::Kerning => prefs.tracking_increment,
Step::BaselineShift => prefs.baseline_shift_increment,
};
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
// the paragraph's; the rest wait for a selection.
// The ranges stepped. At a caret: kerning is the space after the character before it, or at
// the start of a line the space before the character after it (#5111), leading the
// paragraph's; the rest wait for a selection.
let ranges = type_ranges(s, p, C, |text, a| match step {
Step::Kerning => Some((edit::prev_char(text, a), a)),
Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)),
Step::Leading => {
let para = edit::paragraph_at(text, a);
Some((para.start, para.end))
}
_ => None,
})?;
// At a caret, the kerning stepped: (whether it is the space before the character, its value).
let mut caret_kerning = Vec::with_capacity(ranges.len());
for &(id, a, b, caret) in &ranges {
let value = if matches!(step, Step::Kerning) && caret {
let t = text_ref(s, id)?;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
} else {
None
let value = match caret {
Some(c) if matches!(step, Step::Kerning) => {
let t = text_ref(s, id)?;
if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) {
Some((true, edit::style_at(&t.runs, a + 1).kerning_before))
} else {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)))
}
}
_ => None,
};
caret_kerning.push(value);
}
@@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => {
let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
Step::Kerning => match caret_kerning.get(range_index).copied().flatten() {
Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0),
caret => {
let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v);
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
},
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
@@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
/// The automatic method remains selected.
/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns
/// there: of the character before it, or at the start of a line the space before the character
/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected.
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?;
if !ranges.is_empty() {
s.edit("Reset Tracking and Kerning", |d, _| {
for &(id, a, b, caret) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| {
st.kerning = None;
if !caret {
let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before);
edit::style_range(&mut t.runs, a, b, |st| match before {
Some(true) => st.kerning_before = 0.0,
Some(false) => st.kerning = None,
None => {
st.kerning = None;
st.kerning_before = 0.0;
st.tracking = 0.0;
}
});
@@ -1975,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}));
}
+10 -1
View File
@@ -188,8 +188,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
/// from a style instead leaves the text's own value alone.
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
// A chosen glyph belongs to one character, not to a reusable character style.
// A chosen glyph belongs to one character, and the space before a line's first character to
// where it stands (#5111), not to a reusable character style.
m.remove("glyphAlternate");
m.remove("kerningBefore");
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
@@ -243,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);
}
@@ -530,6 +533,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
if m.contains_key("glyphAlternate") {
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
}
if m.contains_key("kerningBefore") {
return Err(bad(
&c,
"`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)",
));
}
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
for (key, value, lo, hi) in
+3 -1
View File
@@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to
/// one character, so it is not a reusable style attribute.
/// one character, and the space before a line's first character to where it stands (#5111), so
/// neither is a reusable style attribute.
#[test]
fn char_style_redefined_to_the_defaults_updates_its_text() {
use vectorcraft_doc::CharAlign;
@@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() {
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
let mut reusable = settable::<CharStyle>();
reusable.remove("glyphAlternate");
reusable.remove("kerningBefore");
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute");
+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.
+11 -3
View File
@@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
// The frame is worked out in line space, where the lines are; vertical type turns it
// into text space with the lines.
let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds);
// Across, the frame spans the lines as they are aligned: from their starts to their ends
// without the tracking after their last characters (`LineInfo::trailing`), not to the
// ink, which tracking can pull past an advance. Area type aligns them alike.
@@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
_ => (0.0, slack),
};
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)));
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
refresh_bounds(t);
}
@@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
// Point type is in no thread: its first paragraph starts in it.
t.first_para_continues = false;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
// The lines are in line space; the frame's bounds are taken there too.
let fb = lay.line_xf.inverse().transform_rect_bbox(fb);
let plain = t.plain_text();
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
// Soft wraps → explicit breaks (replace the space before the wrap when there is one).
@@ -414,7 +419,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
// converting back to area type does not turn it on again.
t.dynamic_text = false;
t.kind = TextKind::Point;
t.xf *= Affine::translate((ox, baseline));
// The origin is in the area type's line space; point type's line space puts its own
// origin there (vertical point type turns its lines about a different point).
let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY };
t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse();
refresh_bounds(t);
}
Ok(())
+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();
-14
View File
@@ -215,20 +215,6 @@ pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one
/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder.
pub fn written_elsewhere(path: &str) -> bool {
let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
!cfg!(windows) && foreign && current().is_some()
}
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
/// `destinationFolder`)?
pub fn is_path_key(key: &str) -> bool {
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
}
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
-3
View File
@@ -151,9 +151,6 @@ pub struct Session {
pub symbol_libraries: cmd::symbollib::Libraries,
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
pub brush_libraries: cmd::brushlib::Libraries,
/// The id each loaded library outside a request's read roots has been shown under, by its own id
/// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
+7
View File
@@ -88,6 +88,13 @@ impl SourceMap {
let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from(""));
(seg.file, name, seg.line.saturating_add(line - seg.out_line), column)
}
/// The memory the map holds, in bytes: its lists and its file names, each name counted in full
/// even when another map shares it.
pub(crate) fn bytes(&self) -> usize {
let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::<usize>()).sum();
self.files.capacity() * size_of::<Arc<str>>() + self.segments.capacity() * size_of::<Segment>() + names
}
}
/// How a preprocessor failure ends the run.
+145 -28
View File
@@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> {
}
/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it),
/// the link its host functions use, and its heap after its last run.
/// the link its host functions use, its heap after its last run, and the source maps of the code
/// its earlier runs compiled.
struct Engine {
name: String,
realm: Option<Realm>,
link: Link,
heap: usize,
maps: Rc<RefCell<KeptMaps>>,
}
/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its
/// kept source maps.
type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>);
/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its
/// earlier runs.
pub const KEPT_MAP_BYTES: usize = 8 << 20;
/// A source map an engine kept from an earlier run: the realm's tag for the source
/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for
/// a script given as text.
struct KeptMap {
tag: u32,
map: include::SourceMap,
script: Option<Arc<str>>,
bytes: usize,
}
/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most
/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept.
#[derive(Default)]
struct KeptMaps {
maps: std::collections::VecDeque<KeptMap>,
bytes: usize,
}
impl KeptMaps {
fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) {
let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len());
let limit = kept_map_bytes();
if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) {
return;
}
while self.bytes + bytes > limit {
let Some(old) = self.maps.pop_front() else { break };
self.bytes -= old.bytes;
}
self.bytes += bytes;
self.maps.push_back(KeptMap { tag, map, script, bytes });
}
fn get(&self, tag: u32) -> Option<&KeptMap> {
self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i))
}
}
/// The bytes of source maps a named engine keeps.
fn kept_map_bytes() -> usize {
#[cfg(test)]
if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) {
return n;
}
KEPT_MAP_BYTES
}
/// The named engines of one thread's sessions, by their key, each in the order they were made.
@@ -665,6 +722,14 @@ struct Host {
/// The file, line and column of each error `$.evalFile` raised for a file it could not run, in
/// order: the error's mark ([`HostError::Marked`]) is its index.
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
/// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before.
script_tag: Cell<u32>,
/// The run's name as its report gives it, which [`Host::name`] shortens for console lines.
report_name: Arc<str>,
/// The source maps the named engine of the run kept from its earlier runs; `None` for a run
/// without one.
kept_maps: Option<Rc<RefCell<KeptMaps>>>,
/// When the run started, where the platform has a clock.
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
@@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<c
}
impl Host {
/// Gives the named engine of the run the source maps of the code the run compiled: its script
/// and each file `$.evalFile` gave. A run that never started its script compiled nothing.
fn keep_maps(&self) {
let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return };
if tag == vectorcraft_js::UNKNOWN_SOURCE {
return;
}
let mut kept = kept.borrow_mut();
kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone()));
for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() {
match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) {
Some(t) => kept.keep(t, map, None),
None => break,
}
}
}
/// Adds a line to the run's console and, during a slice, to the session's.
fn line(&self, entry: console::Entry, cut: bool) {
let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry };
@@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) {
(RunError { kind, message, line: f.line, column: f.column }, file)
}
/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark`
/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name
/// unless it is the script's own.
fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap, own: bool| {
/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in,
/// with that file's name unless it is the run's own script.
fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) {
let map = |m: &include::SourceMap| {
let (index, file, line, column) = m.locate(e.line, e.column);
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
(RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file))
(RunError { kind: e.kind.clone(), message, line, column }, index, file)
};
// An error `$.evalFile` raised for a file it could not run, and no script caught, is at that
// file's place.
if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) {
let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() };
return (RunError { kind: e.kind.clone(), message, line, column }, Some(file));
}
if e.line == 0 {
return (e.clone(), None);
}
// An error in code from another source, such as a function an earlier run of a named engine
// defined, has no place in this run's files, so it is given without one.
// An error in code from a source whose map is not at hand, such as code that `eval` compiled,
// is given without a place.
let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None);
match tag {
0 => map(&host.script_map.borrow(), true),
match realm.error_source() {
0 => {
let (e, index, file) = map(&host.script_map.borrow());
(e, (index > 0).then_some(file))
}
n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) {
Some(m) => map(m, false),
Some(m) => {
let (e, _, file) = map(m);
(e, Some(file))
}
None => unplaced(),
},
_ => unplaced(),
// Code an earlier run of the named engine compiled, such as a function it defined, is at
// its place in that run's files: the run's script, a file it included, or a file
// `$.evalFile` gave. A script given as text is named by that run's name.
_ => {
let kept = host.kept_maps.as_ref().map(|k| k.borrow());
match kept.as_ref().and_then(|k| k.get(realm.error_tag())) {
Some(kept) => {
let (e, index, file) = map(&kept.map);
let file = match &kept.script {
Some(name) if index == 0 && file.is_empty() => name.clone(),
_ => file,
};
(e, Some(file))
}
None => unplaced(),
}
}
}
}
@@ -1438,17 +1541,17 @@ impl Session {
}
}
/// The realm of the named engine `name` for a run, with the link its host functions use: the
/// realm its last run left, or `None` for an engine made now. → the kind and text of the
/// failure when the engine cannot be had.
fn take_engine(&mut self, name: &str) -> std::result::Result<(Option<Realm>, Link), (include::FailKind, String)> {
/// The realm of the named engine `name` for a run, with the link its host functions use and the
/// source maps it kept: the realm its last run left, or `None` for an engine made now. → the
/// kind and text of the failure when the engine cannot be had.
fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> {
if cfg!(target_arch = "wasm32") {
return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string()));
}
with_engines(&self.script_engines, |engines| {
if let Some(e) = engines.iter_mut().find(|e| e.name == name) {
let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?;
return Ok((Some(realm), e.link.clone()));
return Ok((Some(realm), e.link.clone(), e.maps.clone()));
}
if engines.len() >= MAX_ENGINES {
return Err((
@@ -1457,15 +1560,20 @@ impl Session {
));
}
let link: Link = Rc::new(RefCell::new(None));
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 });
Ok((None, link))
let maps = Rc::new(RefCell::new(KeptMaps::default()));
engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() });
Ok((None, link, maps))
})
}
/// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer
/// holds the run, and the engine keeps `realm`, or is dropped without one.
/// holds the run, and the engine keeps `realm`, with the source maps of the code the run
/// compiled, or is dropped without one.
fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) {
let host = link.borrow_mut().take();
if let (Some(h), Some(_)) = (&host, &realm) {
h.keep_maps();
}
drop(host);
let Some(name) = engine else { return };
let mut realm = realm;
@@ -1668,9 +1776,9 @@ impl Session {
};
let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES };
let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() };
let (engine, realm, link) = match kept {
None => (None, None, Rc::new(RefCell::new(None))),
Some((engine, (realm, link))) => (Some(engine), realm, link),
let (engine, realm, link, kept_maps) = match kept {
None => (None, None, Rc::new(RefCell::new(None)), None),
Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)),
};
// A realm made here gets the script extensions, `$` and the dialogs; a kept one has them
// from its first run, and they act for the run that calls them.
@@ -1716,6 +1824,9 @@ impl Session {
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failures: RefCell::default(),
script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE),
report_name: Arc::from(name.as_str()),
kept_maps,
started: clock(),
wake_at: Cell::new(None),
can_wait: realm.wait_flag(),
@@ -1752,8 +1863,12 @@ impl Session {
.and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) })
.and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) })
.and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() })
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
.and_then(|()| {
let r = realm.start(&expanded.source);
host.script_tag.set(realm.script_tag());
r
})
.map_err(|e| located(&host, &realm, &e))
}
};
if let Err((e, file)) = started {
@@ -1832,7 +1947,7 @@ impl Session {
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e);
let (e, file) = located(&job.host, &job.realm, e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
close_run(self, &job.host, &mut report, ending(&e.kind));
self.end_job(job, e.kind == RunErrorKind::Internal);
@@ -2127,6 +2242,8 @@ thread_local! {
pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) };
/// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`].
pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) };
/// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`].
pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
/// Tests: the `JSINCLUDE` folders in place of the environment's.
pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) };
/// Tests: the time since a run started, in place of its clock's.
-1
View File
@@ -80,7 +80,6 @@ impl Session {
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
shown_library_ids: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
+1
View File
@@ -20,6 +20,7 @@ mod brushlib;
mod brushsym;
mod build;
mod cancel;
mod caret_kerning;
mod charstroke;
pub(super) mod clip;
mod clipboard;
+1 -1
View File
@@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() {
}
/// The formats whose exports draw type.
const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"];
const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"];
/// The warnings of `document.export {format, …options}` about type that doesn't fit its frame.
fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> {
+75 -3
View File
@@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
let list = run(&mut s, "brush.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
assert_eq!(
@@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
);
assert_eq!(libs[4]["count"].as_u64(), Some(4));
assert_eq!(libs[5]["count"].as_u64(), Some(4));
let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"}));
assert_eq!(
beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
);
assert_eq!(libs[5]["count"].as_u64(), Some(4));
assert_eq!(libs[6]["count"].as_u64(), Some(4));
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
@@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
for (id, kind) in [
("art-ribbons", "art"),
("calligraphic-nibs", "calligraphic"),
("bristle-brushes", "bristle"),
("pattern-borders", "pattern"),
("scatter-shapes", "scatter"),
("game-trails", "art"),
@@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
}
/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value
/// outside would be clamped without notice), names no other library or default brush uses; added
/// in one step, not twice, and saved as a library they reopen unchanged.
#[test]
fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() {
use vectorcraft_brush::{Bristle, BristleShape};
let mut s = session();
let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap();
assert_eq!(lib.name, "Bristle Brushes");
let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect();
assert!((8..=10).contains(&names.len()), "{names:?}");
let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v);
let mut shapes = vec![];
for b in &lib.brushes {
let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) };
assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name);
for v in [x.density, x.thickness, x.opacity, x.stiffness] {
assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name);
}
let mut kept = b.clone();
kept.sanitize();
assert_eq!(&kept, b, "sanitizing changes {}", b.name);
assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name);
if !shapes.contains(&x.shape) {
shapes.push(x.shape);
}
}
let every = [
BristleShape::RoundPoint,
BristleShape::FlatPoint,
BristleShape::RoundBlunt,
BristleShape::FlatBlunt,
BristleShape::RoundCurve,
BristleShape::FlatCurve,
BristleShape::RoundAngle,
BristleShape::FlatAngle,
BristleShape::RoundFan,
BristleShape::FlatFan,
];
assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}");
// Names of its own: no default brush's, no other built-in library's, none twice.
let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect();
let others: Vec<String> = cmd::brushlib::BUILTIN
.iter()
.filter(|b| b.id != "bristle-brushes")
.flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>())
.collect();
for (i, n) in names.iter().enumerate() {
assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}");
}
// Added to a document in one undo step; adding them again adds nothing.
let before = (undo_len(&s), brushes(&s));
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"}));
assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::<Vec<_>>());
assert_eq!(undo_len(&s), before.0 + 1);
assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat());
for b in &lib.brushes {
assert_eq!(&brush(&s, &b.name), b);
}
let again = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"}));
assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again));
// Saved as a library and loaded back: the same brushes, in the same order.
let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" }));
let b64 = saved["dataBase64"].as_str().unwrap().to_string();
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"}));
let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert_eq!(reopened.brushes, lib.brushes);
}
#[test]
fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() {
let dir = folder("user");
+183
View File
@@ -0,0 +1,183 @@
//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line
//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before
//! the character after it (`kerningBefore`), as one undo step, and the character moves.
use serde_json::json;
use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn text(s: &mut Session, t: &str) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
}
fn obj(s: &Session, id: NodeId) -> TextObject {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
(**t).clone()
}
fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle {
vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1)
}
/// Where glyph `byte` is drawn along its line, in text space.
fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 {
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id));
l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4]
}
/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`.
fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo {
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap();
}
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
v
}
fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value {
s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap()
}
#[test]
fn a_step_at_the_start_of_the_text_moves_the_first_character() {
let mut s = session();
let id = text(&mut s, "AV");
let inc = s.prefs.tracking_increment;
let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1));
assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0]));
let st = style_at(&s, id, 0);
assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it");
// A step of `inc` thousandths of the 20 pt em moves both letters along the line.
let moved = inc / 1000.0 * 20.0;
assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6);
// A negative step goes back towards the line's start, past it.
step_at(&mut s, id, 0, -3);
assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc);
assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6);
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).kerning_before, 0.0);
assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6);
}
#[test]
fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() {
let mut s = session();
let id = text(&mut s, "AV");
step_at(&mut s, id, 1, 1);
let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1));
assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None));
assert!(a.kerning.is_some(), "the pair after A");
}
#[test]
fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() {
let mut s = session();
let inc = s.prefs.tracking_increment;
let id = text(&mut s, "One\nTwo");
let t = drawn_x(&s, id, 4);
step_at(&mut s, id, 4, 1);
assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning");
assert!(drawn_x(&s, id, 4) > t, "T moves");
let forced = text(&mut s, "One\u{2028}Two");
let after = "One\u{2028}".len();
step_at(&mut s, forced, after, 1);
assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc));
}
#[test]
fn an_empty_line_has_nothing_to_kern() {
let mut s = session();
let id = text(&mut s, "One\n\nTwo");
let before = obj(&s, id).runs;
assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([]));
assert_eq!(obj(&s, id).runs, before);
// Nor does the end of text after a line break.
let end = text(&mut s, "One\n");
assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([]));
}
#[test]
fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() {
let mut s = session();
let id = text(&mut s, "AV");
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap();
step_at(&mut s, id, 0, 2);
assert_ne!(style_at(&s, id, 0).kerning_before, 0.0);
// At the caret before A only the space before A goes; A keeps its kerning after it.
caret_in(&mut s, id, 0);
s.execute("type.resetTracking", &json!({})).unwrap();
let st = style_at(&s, id, 0);
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0));
step_at(&mut s, id, 0, 2);
s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap();
let st = style_at(&s, id, 0);
assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning");
}
#[test]
fn set_range_style_sets_the_space_before() {
let mut s = session();
let id = text(&mut s, "AV");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap();
assert_eq!(style_at(&s, id, 0).kerning_before, 50.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap();
assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits");
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err());
}
/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back.
#[test]
fn alt_arrows_at_the_caret_before_the_first_character_kern_it() {
let mut s = session();
let inc = s.prefs.tracking_increment;
let id = text(&mut s, "Hello");
let v = caret_in(&mut s, id, 0);
let alt = Mods { alt: true, ..Mods::default() };
s.tool_key(ToolKey::Left, alt, v).unwrap();
assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None));
s.tool_key(ToolKey::Right, alt, v).unwrap();
s.tool_key(ToolKey::Right, alt, v).unwrap();
assert_eq!(style_at(&s, id, 0).kerning_before, inc);
}
/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character
/// with a space before it leaves the target's first character where it was.
#[test]
fn the_eyedropper_keeps_the_targets_space_before() {
let mut s = session();
let src = text(&mut s, "Source");
s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap();
let dst = text(&mut s, "Target");
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let st = style_at(&s, dst, 0);
assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't");
}
/// A character style captured from a line's first character leaves its space before out, and a
/// style can't be given one: it would space the first character of every paragraph set in it.
#[test]
fn a_character_style_leaves_the_space_before_out() {
let mut s = session();
let id = text(&mut s, "Source");
s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap();
s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap();
let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone();
assert_eq!(attrs.get("tracking"), Some(&json!(50.0)));
assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}");
let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err();
assert!(err.to_string().contains("kerningBefore"), "{err}");
}
@@ -432,6 +432,30 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn redefining_the_symbol_drops_paints_its_new_objects_do_not_draw() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let p = json!({"id": inst.0, "path": [1], "fill": "#ff0000", "stroke": "#ff0000", "opacity": 50});
s.execute("symbol.instance.setAppearance", &p).unwrap();
// The second object is now an image, which draws no fill or stroke of its own.
let a = square(&mut s, 10.0);
place_image(&mut s);
let image = top(&s);
s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, image.0]})).unwrap();
let o = node(&s, inst).extras().symbol_overrides.clone();
assert_eq!(o.len(), 1, "the opacity change stays: {o:?}");
assert_eq!((&o[0].fill, &o[0].stroke, o[0].opacity), (&None, &None, Some(0.5)));
// A change of paint alone goes with its paint: the first object is now a blend.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "stroke": "#ff0000"})).unwrap();
let (b, c) = (square(&mut s, 10.0), square(&mut s, 60.0));
let blend = id(&s.execute("object.blend.make", &json!({"ids": [b.0, c.0], "steps": 1})).unwrap());
let d = square(&mut s, 120.0);
s.execute("symbol.update", &json!({"name": "Pair", "ids": [blend.0, d.0]})).unwrap();
let o = node(&s, inst).extras().symbol_overrides.clone();
assert_eq!(o.iter().map(|o| o.path.clone()).collect::<Vec<_>>(), [vec![1]], "{o:?}");
}
#[test]
fn replace_symbol_drops_the_changes_made_for_the_old_symbol() {
let mut s = session();
+46 -1
View File
@@ -40,7 +40,16 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() {
libraries.dedup();
assert_eq!(
libraries,
["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]
[
"Spectrum Gradients",
"Sky Gradients",
"Neutral Gradients",
"Organic Fades",
"Holographic",
"Duotone Gradients",
"Radial Glows",
"Metallic Gradients"
]
);
let mut seen = std::collections::HashSet::new();
assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset");
@@ -82,6 +91,42 @@ fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() {
assert_eq!(row["gradient"]["method"], "perceptual");
}
#[test]
fn a_holographic_foil_applies_in_place_as_one_undo_step() {
let (mut s, id) = session_with(json!({"gradient": {"angle": 30}}));
let before = paint(&s, id, false);
let undo = s.doc().unwrap().history.undo.len();
let veil = s.gradient_preset("prism veil").unwrap();
assert_eq!((veil.library, veil.gradient.method), (Some("Holographic"), GradientMethod::Perceptual));
let r = s.execute("gradient.presets.apply", &json!({"name": "prism veil"})).unwrap();
assert_eq!(r["name"].as_str(), Some("Prism Veil"));
let g = paint(&s, id, false);
assert_eq!((g.gradient.kind, g.gradient.method, &g.gradient.stops), (GradientKind::Linear, GradientMethod::Perceptual, &veil.gradient.stops));
assert_eq!(g.gradient.stops.len(), 6);
assert_eq!(g.angle, before.angle, "the placement stays");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Prism Veil"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paint(&s, id, false), before);
}
/// A preset saved as "Mint Halo" before Holographic existed stays listed under a free name.
#[test]
fn a_saved_preset_named_like_a_holographic_glow_gets_a_free_name() {
let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}}));
let halo = s.gradient_preset("Mint Halo").unwrap().gradient;
let mut own = paint(&s, id, false).gradient;
own.dither = true;
let mut prefs = s.prefs.clone();
prefs.gradient_presets = vec![crate::cmd::GradientPreset { name: "MINT HALO".into(), gradient: own.clone() }];
s.apply_prefs(prefs);
assert_eq!(s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["MINT HALO 2"]);
s.execute("gradient.presets.apply", &json!({"name": "mint halo 2"})).unwrap();
assert_eq!(paint(&s, id, false).gradient, own);
s.execute("gradient.presets.apply", &json!({"name": "Mint Halo"})).unwrap();
assert_eq!(paint(&s, id, false).gradient.stops, halo.stops);
}
#[test]
fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() {
let (mut s, id) = session_with(json!({"color": "#336699"}));
+5 -51
View File
@@ -214,72 +214,26 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
let mine = format!("loaded/{}", text(&inside));
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
assert_eq!(copied.get("path"), None, "{copied}");
assert_eq!(copied["path"], Value::Null, "{copied}");
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
for reply in [&added, &copied, &saved] {
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
}
// A library loaded from data under the file name keeps its id; the concealed one shown under that
// id takes the next free one, the other keeps its id, and no two rows share an id.
// A library loaded from data under the file name keeps its id; each concealed one takes the next
// free one, so no two rows share an id.
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}");
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
// Copied into the user library folder, it is still found by that id, which no other library takes.
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}");
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
// The person sees the folder.
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
let _ = std::fs::remove_dir_all(base);
}
/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl`
/// is shown under its whole file name. Another platform's absolute path is still named by the part
/// after its last `\`.
#[cfg(not(windows))]
#[test]
fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
let base = folder("backslash-name");
let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
let s = Session::new();
let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
let _ = std::fs::remove_dir_all(base);
hidden.sort();
let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
want.sort();
assert_eq!(hidden, want);
}
/// A concealed library keeps the id a client was shown when another library with the same file name,
/// whose full id sorts before it, is loaded later: the id still finds the first library.
#[test]
fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() {
let base = folder("keeps-id");
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches"));
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
(SWATCHES.add)(&mut s, "First");
run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"}));
(SWATCHES.add)(&mut s, "Second");
run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"}));
let shown = "loaded/Kit.vcswatches";
let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone();
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}");
run(&mut s, "swatch.library.load", json!({"path": text(&later)}));
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}");
let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))}));
let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown})));
assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}");
let _ = std::fs::remove_dir_all(base);
}
+87
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();
+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() {
+108 -4
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for};
use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for};
use crate::*;
fn run(s: &mut Session, source: &str) -> Result<Value> {
@@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() {
assert_eq!(report["line"], 2, "{report}");
assert_eq!(names(&mut s), ["e"]);
let _ = std::fs::remove_dir_all(base);
// An error in a function an earlier run defined has no place in the run that calls it.
// An error in a function an earlier run defined is at that run's line; a script given as text
// is named by its run's name.
run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap();
assert!(run(&mut s, "#targetengine e\nbad();").is_err());
let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string();
let report = s.script_report().unwrap().clone();
assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}");
assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}");
let column = report["column"].as_u64().unwrap();
assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]);
assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}");
}
/// The console error lines of a run's report.
fn error_lines(report: &Value) -> Vec<String> {
let lines = report["console"]["lines"].as_array().unwrap();
lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect()
}
/// The file, line and column of the last run's error.
fn place(s: &Session) -> (Option<String>, u64, u64) {
let r = s.script_report().unwrap();
(r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap())
}
/// An error in code an earlier run of a named engine compiled is at the place of that code: in that
/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console
/// line keep the calling run's name.
#[test]
fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() {
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap();
std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap();
let def = base.join("def.jsx");
let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\
function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}";
std::fs::write(&def, text).unwrap();
let mut s = Session::new();
s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap();
let call = |s: &mut Session, code: &str| {
let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"}));
assert!(e.is_err(), "{code}");
};
let shown = def.to_string_lossy().to_string();
let ends = |p: &(Option<String>, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0;
call(&mut s, "fromMain();");
let p = place(&s);
assert!(ends(&p, "def.jsx", 5), "{p:?}");
let report = s.script_report().unwrap().clone();
assert_eq!(report["name"], "caller.js");
assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]);
call(&mut s, "fromInclude();");
assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s));
call(&mut s, "fromEval();");
assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s));
call(&mut s, "badEval();");
assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s));
let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000}));
assert!(e.is_err());
assert_eq!(s.script_report().unwrap()["kind"], "limit");
assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s));
// A caught error thrown again is where that throw is, in the calling run.
call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}");
assert_eq!(place(&s), (None, 4, 3));
// A run that fails before it starts compiles nothing, and the kept places stay.
assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err());
call(&mut s, "fromMain();");
assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s));
assert!(place(&s).0.is_some_and(|f| f == shown));
// A dropped engine takes its places with it: the new one has its own.
s.execute("script.engine.drop", &json!({"name": "e"})).unwrap();
run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap();
call(&mut s, "fromMain();");
assert_eq!(place(&s).0.as_deref(), Some("t.js"));
assert_eq!(place(&s).1, 2);
// The name stands for the earlier script as that run's report gave it, not as console lines
// show it.
s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap();
call(&mut s, "named();");
assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js"));
let _ = std::fs::remove_dir_all(base);
}
/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going
/// first, and the code still runs once its place is gone.
#[test]
fn an_engine_keeps_the_newest_places_within_its_budget() {
let mut s = Session::new();
run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap();
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300)));
run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap();
for _ in 0..20 {
run(&mut s, "#targetengine e\nvar x = 1;").unwrap();
}
run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap();
// Each run's own map takes the room of the one before, so c is called first.
assert!(run(&mut s, "#targetengine e\nc();").is_err());
let c_place = place(&s);
let a = run(&mut s, "#targetengine e\na();").map(drop);
let a_place = place(&s);
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1)));
run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap();
assert!(run(&mut s, "#targetengine e\nd();").is_err());
let d_place = place(&s);
TEST_KEPT_MAP_BYTES.with(|b| b.set(None));
assert!(a.is_err());
assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room");
assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4));
assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept");
}
/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to
+38
View File
@@ -671,6 +671,44 @@ fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk()
}
}
#[test]
fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() {
let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap();
let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") };
for mode in ["rgb", "cmyk"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
let list = run(&mut s, "swatch.library.list", json!({}));
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone();
assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14)));
let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"}));
assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo");
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}");
assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step");
// The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method.
let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") };
assert_eq!(added.gradient, want.gradient, "{mode}");
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB");
assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge");
let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") };
assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}");
assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}");
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add");
assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill");
// A document swatch already named Mint Halo stays; the glow comes in under a number.
run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"}));
let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]}));
assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}");
assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept");
assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient));
}
}
#[test]
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
let mut s = session();
+63
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"),
@@ -2523,6 +2524,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() {
}
}
/// Horizontal and vertical type keep every line where it is when converted from point to area
/// type and back, and from area to point type and back, however the lines are aligned. Vertical
/// lines run down their columns, and each next column sits to the left of the one before it.
#[test]
fn converting_vertical_type_between_point_and_area_keeps_it_in_place() {
for vertical in [false, true] {
for j in ["left", "center", "right"] {
for text in ["Wave", "Wave\nWaves"] {
let mut s = session();
let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap();
let before = line_starts_doc(&obj(&s, id));
assert_eq!(before.len(), text.lines().count());
if vertical && before.len() > 1 {
assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}");
}
for round in 0..2 {
s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap();
let t = obj(&s, id);
assert!(matches!(t.kind, TextKind::Area { .. }));
let area = line_starts_doc(&t);
assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap");
for (a, b) in area.iter().zip(&before) {
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}");
}
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
let t = obj(&s, id);
assert!(matches!(t.kind, TextKind::Point));
let point = line_starts_doc(&t);
for (a, b) in point.iter().zip(&before) {
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}");
}
}
}
// Area type to point type and back.
let mut s = session();
let r = s
.execute(
"text.create",
&json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}),
)
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap();
let before = line_starts_doc(&obj(&s, id));
assert_eq!(before.len(), 2);
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
let point = line_starts_doc(&obj(&s, id));
for (a, b) in point.iter().zip(&before) {
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}");
}
s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap();
let area = line_starts_doc(&obj(&s, id));
assert_eq!(area.len(), 2);
for (a, b) in area.iter().zip(&before) {
assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}");
}
}
}
}
#[test]
fn convert_area_to_point_keeps_a_deeper_list_item_in_place() {
let mut s = session();
+6
View File
@@ -809,6 +809,12 @@ fn structured_junk() {
("addon.type.releasePath", json!({"mode": "text", "keepOriginal": false, "id": 2})),
("addon.type.releasePath", json!({"mode": ["both"], "keepOriginal": "no", "ids": "all", "id": -1})),
("addon.type.releasePath", json!({"mode": "nope", "keepOriginal": 1e308, "ids": [1.5, null, "x"]})),
// Cutting Tools add-on: Stencil Bridges.
("addon.cutting.bridges", json!({"width": 0.01, "count": 16, "direction": "shortest", "keepOriginal": true})),
("addon.cutting.bridges", json!({"width": 1000, "count": 1, "direction": "horizontal", "keepOriginal": false})),
("addon.cutting.bridges", json!({"width": "3 mm", "direction": "vertical"})),
("addon.cutting.bridges", json!({"width": f64::MAX, "count": 1e308, "direction": ["vertical"], "keepOriginal": "no"})),
("addon.cutting.bridges", json!({"width": -1, "count": -3, "direction": "diagonal", "keepOriginal": 1e308})),
("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})),
("addon.cleanup.breakText", json!({"by": ["lines"]})),
("addon.cleanup.breakText", json!({"by": "paragraphs"})),
+28
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();
@@ -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 -1
View File
@@ -395,7 +395,7 @@ impl Interp {
Err(e) => return Err(self.host_error(e)?),
};
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
self.evaluated = self.evaluated.saturating_add(1);
self.evaluated = crate::realm::next_tag(self.evaluated);
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
// UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the
// bytes is taken before the source is encoded, and the rest after. A source within the
+31
View File
@@ -638,6 +638,37 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn
assert_eq!(r.error_source(), 0, "a syntax error is in the script");
}
#[test]
fn error_tags_stay_the_same_from_run_to_run_and_are_never_shared() {
let mut r = realm(10_000_000);
let source: HostFunction = Rc::new(|_| Ok(Data::String("function inEval() { throw new Error('in eval'); }".into())));
let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) };
r.define_host_object("host", vec![("load".into(), load)]).unwrap();
assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "no script yet");
r.run("function late() { throw new Error('late'); }\nhost.load(); host.load();").unwrap();
let first = r.script_tag();
assert_ne!(first, UNKNOWN_SOURCE);
assert!(r.run("\nlate()").is_err());
let second = r.script_tag();
assert!(second > first + 2, "the evaluator's two sources took the tags after the first script");
assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, first), "late keeps its script's tag");
assert!(r.run("inEval()").is_err());
assert_eq!(r.error_tag(), first + 2, "the second source the evaluator gave in the first run");
assert!(r.run("\nthrow 1").is_err());
assert_eq!((r.error_source(), r.error_tag()), (0, r.script_tag()));
// Once the tags are used up, a source gets no tag rather than one an earlier source has.
r.set_last_tag(UNKNOWN_SOURCE - 3);
r.run("function nearEnd() { throw 1; }\nhost.load(); host.load(); host.load();").unwrap();
assert_eq!(r.script_tag(), UNKNOWN_SOURCE - 2);
assert!(r.run("nearEnd()").is_err());
assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "a script after the last tag");
assert_eq!(r.error_tag(), UNKNOWN_SOURCE - 2);
assert!(r.run("inEval()").is_err());
assert_eq!(r.error_tag(), UNKNOWN_SOURCE, "the third source came after the last tag");
assert!(r.run("\nthrow 1").is_err());
assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, UNKNOWN_SOURCE));
}
#[test]
fn an_evaluators_source_takes_fuel_before_it_is_parsed() {
// The evaluator gives 131,072 spaces and a number: far more code units than the fuel left.
+34 -3
View File
@@ -63,6 +63,12 @@ pub enum LimitKind {
/// evaluator's: code that `eval` or `Function` compiled, or an error with no place.
pub const UNKNOWN_SOURCE: u32 = u32::MAX;
/// The source tag after `tag`. Once the tags are used up, every later source gets
/// [`UNKNOWN_SOURCE`], so no two sources share a tag.
pub(crate) fn next_tag(tag: u32) -> u32 {
if tag >= UNKNOWN_SOURCE - 1 { UNKNOWN_SOURCE } else { tag + 1 }
}
/// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run
/// it cannot pause.
pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000;
@@ -151,7 +157,9 @@ pub struct Realm {
unbroken_fuel: u64,
/// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each
/// script gets a new one, and the sources an evaluator gives during its run get the ones after
/// it, so code that an earlier script defined keeps a tag of its own.
/// it, so code that an earlier script defined keeps a tag of its own. [`UNKNOWN_SOURCE`]
/// before the first script, for a script the realm did not compile, and once the tags are used
/// up.
script_tag: u32,
}
@@ -170,7 +178,7 @@ impl Realm {
/// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it
/// counted together.
pub fn with_options(options: Options, limits: Limits) -> Self {
Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 }
Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: UNKNOWN_SOURCE }
}
/// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default
@@ -226,6 +234,7 @@ impl Realm {
/// Compiles `source` and readies the realm for a run.
fn compile(&mut self, source: &str) -> Result<std::rc::Rc<crate::bytecode::Code>, RunError> {
self.script_tag = UNKNOWN_SOURCE;
let interp = &mut self.interp;
if let Some(message) = &interp.install_error {
return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 });
@@ -244,7 +253,7 @@ impl Realm {
interp.finally_sites.clear();
interp.error_mark = None;
let strict = interp.options.strict;
interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1);
interp.evaluated = next_tag(interp.evaluated);
self.script_tag = interp.evaluated;
interp.source_tag = self.script_tag;
let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global));
@@ -397,6 +406,28 @@ impl Realm {
}
}
/// The source tag of the code the last error's `line` and `column` are in, which stays the same
/// from run to run: each script given to [`Realm::run`] or [`Realm::start`] and each source a
/// [`crate::HostItem::Evaluator`] gives gets the next tag, so a function an earlier script
/// defined has its script's tag. [`UNKNOWN_SOURCE`] for code that `eval` or `Function`
/// compiled, an error with no place, and sources given once the tags are used up.
pub fn error_tag(&self) -> u32 {
self.interp.error_tag
}
/// The source tag ([`Realm::error_tag`]) of the script the last [`Realm::run`] or
/// [`Realm::start`] compiled; the `n`th source an evaluator gave during that run has this tag
/// plus `n`. [`UNKNOWN_SOURCE`] when that call compiled nothing.
pub fn script_tag(&self) -> u32 {
self.script_tag
}
/// Tests: makes `tag` the last source tag given out.
#[cfg(test)]
pub(crate) fn set_last_tag(&mut self, tag: u32) {
self.interp.evaluated = tag;
}
/// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no
/// `catch` clause took that error's exception on its way out; `None` otherwise.
pub fn error_mark(&self) -> Option<u32> {
+1
View File
@@ -77,6 +77,7 @@ fn export_tool_lists_engine_formats_and_options() {
"eps",
"emf",
"wmf",
"hpgl",
"tga",
"psd"
])
+2 -2
View File
@@ -393,14 +393,14 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf, hpgl pen moves for plotters and cutters), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
"path": string("Destination file (omit to get dataBase64)"),
"scale": num("Raster pixels per point (without scale or options.ppi, uses the document's raster effects resolution; options.ppi wins)"),
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"},
"range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"),
"range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF, WMF and HP-GL: one, or one file each with options.useArtboards: true)"),
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
"outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"},
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"},
+3
View File
@@ -10,12 +10,15 @@
//! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that
//! reshape segments directly).
//! - [`pen`]: the Smart Pen's straight and curved segments and its point edits.
//! - [`stencil`]: Stencil Bridges, the strips cut out of art so that a stencil cut from it keeps
//! its islands attached.
pub mod cleanup;
pub mod keep_shape;
pub mod pathtools;
pub mod pen;
pub mod segment_reshape;
pub mod stencil;
pub use cleanup::{
Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles,
+653
View File
@@ -0,0 +1,653 @@
//! Stencil Bridges: the art a stencil is cut from, with strips cut out of it so that every island
//! of the stencil sheet stays attached.
//!
//! The art is the area the stencil's holes take. Each bounded area outside the art (the inside of
//! an O, any hole of a shape) is an island of the sheet. A bridge is a straight strip of a set
//! width cut out of the art, from an island across the art to the sheet beyond it, or to another
//! island. The bridges are picked as a spanning tree over the islands and the sheet (shortest
//! crossings first), so every island that has room for one is joined to the sheet, then each
//! island gets more bridges, spread around it, up to the number asked for.
//!
//! Crossings are measured along straight lines in one direction at a time (vertical, horizontal,
//! or every 15 degrees for the shortest crossing), spaced half a bridge width apart.
#[cfg(test)]
mod tests;
use std::cell::Cell;
use kurbo::{BezPath, ParamCurve, PathEl, Point};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use crate::{BoolOp, DEFAULT_PRECISION, PathOpsError, try_boolean, try_unite_all};
/// The narrowest and widest bridge, in points.
pub const BRIDGE_WIDTH_RANGE: (f64, f64) = (0.01, 1000.0);
/// The most bridges one island is given.
pub const MAX_BRIDGES_PER_ISLAND: usize = 16;
/// The most straight segments the art is followed by.
pub const MAX_STENCIL_SEGMENTS: usize = 200_000;
/// The most outlines the art may have.
pub const MAX_STENCIL_CONTOURS: usize = 4000;
/// How far from the origin the art and its handles may reach, in points: beyond the coordinates a
/// document holds.
pub const MAX_STENCIL_EXTENT: f64 = 1.0e7;
/// The most segments all lines across the art may look at together.
const MAX_WORK: usize = 300_000_000;
/// The most edges the outlines are tested against when finding which ones bound islands.
const MAX_NESTING_WORK: usize = 50_000_000;
/// The most bands one direction's segments are listed in.
const MAX_LINES: f64 = 1000.0;
/// The most lines all islands are looked across along, in every direction together.
const MAX_ISLAND_LINES: usize = 400_000;
/// The most lines on each side of an island's middle, in one direction.
const MAX_LINES_PER_SIDE: usize = 64;
/// The most times segments are listed against the lines they reach, in one direction.
const MAX_BIN_ENTRIES: usize = 20_000_000;
/// The lines across one bridge its width is checked along.
const CHECKS: usize = 5;
/// The directions the shortest crossing is looked for in, in degrees.
const SHORTEST_STEP_DEG: usize = 15;
/// Which way bridges run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BridgeDirection {
Vertical,
Horizontal,
/// The shortest crossing in any of 12 directions, 15 degrees apart.
Shortest,
}
/// What [`stencil_bridges`] makes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BridgeSpec {
/// Each bridge's width (pt), in [`BRIDGE_WIDTH_RANGE`].
pub width: f64,
/// The bridges each island is given, 1 to [`MAX_BRIDGES_PER_ISLAND`] (fewer where it has no
/// room for more, and more where other islands are joined to the sheet through it).
pub count: usize,
pub direction: BridgeDirection,
}
/// The art with its bridges cut out.
#[derive(Clone, Debug, PartialEq)]
pub struct Bridged {
/// The art as one area, less the bridges.
pub path: PathData,
/// The islands the art held.
pub islands: usize,
/// The bridges cut.
pub bridges: usize,
/// The islands the cut result still encloses: no bridge of the width fits, or the bridges that
/// fit join them only to each other.
pub unbridged: usize,
}
/// `art` (each path with its fill rule; open subpaths are closed as a fill closes them) as one
/// area with bridges cut out of it as `spec` says. With no islands, the area is returned as it is
/// and no bridge is cut.
pub fn stencil_bridges(art: &[(&PathData, FillRule)], spec: BridgeSpec) -> Result<Bridged, PathOpsError> {
let (lo, hi) = BRIDGE_WIDTH_RANGE;
if !(spec.width.is_finite() && (lo..=hi).contains(&spec.width)) || !(1..=MAX_BRIDGES_PER_ISLAND).contains(&spec.count) {
return Err(PathOpsError::Degenerate);
}
if too_large(art) {
return Err(PathOpsError::TooComplex);
}
let closed: Vec<PathData> =
art.iter().map(|(p, _)| PathData::new(p.subpaths.iter().map(|sp| SubPath { closed: true, ..sp.clone() }).collect())).collect();
let refs: Vec<(&PathData, FillRule)> = closed.iter().zip(art).map(|(p, (_, r))| (p, *r)).collect();
bridge_region(try_unite_all(&refs)?, spec)
}
/// Whether `art` has more than [`MAX_STENCIL_SEGMENTS`] anchors or reaches past
/// [`MAX_STENCIL_EXTENT`]: art refused before it is united.
fn too_large(art: &[(&PathData, FillRule)]) -> bool {
let anchors: usize = art.iter().flat_map(|(p, _)| &p.subpaths).map(|sp| sp.anchors.len()).sum();
anchors > MAX_STENCIL_SEGMENTS || !art.iter().all(|(p, _)| within_extent(&p.to_bezpath()))
}
/// [`stencil_bridges`] on `region`, an area with no overlaps.
fn bridge_region(region: PathData, spec: BridgeSpec) -> Result<Bridged, PathOpsError> {
let contours = contours(&region, spec.width)?;
let holes = contours.iter().filter(|c| c.hole).count();
if holes == 0 {
return Ok(Bridged { path: region, islands: 0, bridges: 0, unbridged: 0 });
}
let (chosen, unjoined) = choose(&contours, spec, MAX_WORK)?;
if chosen.is_empty() {
return Ok(Bridged { path: region, islands: holes, bridges: 0, unbridged: unjoined });
}
let strips = PathData::new(chosen.iter().map(|c| SubPath::polyline(&c.strip, true)).collect());
let path = try_boolean(&region, FillRule::NonZero, &strips, FillRule::NonZero, BoolOp::Difference, DEFAULT_PRECISION)?;
// The islands the result still encloses, counted on the cut outlines themselves (removing art
// never encloses a new one).
let unbridged = match self::contours(&path, spec.width) {
Ok(left) => left.iter().filter(|c| c.hole).count(),
Err(_) => unjoined,
};
Ok(Bridged { path, islands: holes, bridges: chosen.len(), unbridged })
}
/// The bridges `spec` asks for across the art that `contours` outline, and how many islands they
/// leave apart from the sheet. Lines across the art look at no more than `work` segments in all;
/// past that, the art is refused when the islands are not all joined yet, and otherwise the extra
/// bridges found so far are kept.
fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec<Bridge>, usize), PathOpsError> {
let degrees: Vec<f64> = match spec.direction {
BridgeDirection::Vertical => vec![90.0],
BridgeDirection::Horizontal => vec![0.0],
BridgeDirection::Shortest => (0..180).step_by(SHORTEST_STEP_DEG).map(|d| d as f64).collect(),
};
let budget = work / degrees.len().max(1);
let mut views = vec![];
for deg in degrees {
views.push(View::new(contours, deg.to_radians(), spec.width, budget)?);
}
let mut picker = Picker::new(contours, &views, spec.width);
picker.connect();
if views.iter().any(View::exhausted) {
return Err(PathOpsError::TooComplex);
}
picker.spread(spec.count);
let unjoined = picker.unjoined();
Ok((picker.chosen, unjoined))
}
/// One closed outline of the art, followed in straight segments.
struct Contour {
points: Vec<Point>,
/// Whether it bounds an island (it lies inside an odd number of the others).
hole: bool,
/// Its area, without sign.
area: f64,
/// Its box: (min x, min y, max x, max y).
bounds: (f64, f64, f64, f64),
}
/// The outlines of `region` (an area with no overlaps) followed within a tolerance set by the
/// bridge width: a twentieth of it, from 0.002 to 0.25 pt, raised for a curve that would need more
/// than [`vectorcraft_geom::MAX_FLATTEN_PER_CURVE`] segments. Art reaching past
/// [`MAX_STENCIL_EXTENT`] is refused.
fn contours(region: &PathData, width: f64) -> Result<Vec<Contour>, PathOpsError> {
if !within_extent(&region.to_bezpath()) {
return Err(PathOpsError::TooComplex);
}
let tolerance = (width / 20.0).clamp(0.002, 0.25);
let mut out: Vec<Contour> = vec![];
let mut segments = 0usize;
for sp in &region.subpaths {
let room = MAX_STENCIL_SEGMENTS.saturating_sub(segments);
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut points = follow(&bp, tolerance, room)?;
while points.len() > 1 && points.first() == points.last() {
points.pop();
}
if points.len() < 3 {
continue;
}
segments += points.len();
if segments > MAX_STENCIL_SEGMENTS || out.len() >= MAX_STENCIL_CONTOURS {
return Err(PathOpsError::TooComplex);
}
if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite())) {
return Err(PathOpsError::NonFinite);
}
let area = signed_area(&points).abs();
let bounds = points.iter().fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
(b.0.min(p.x), b.1.min(p.y), b.2.max(p.x), b.3.max(p.y))
});
out.push(Contour { points, hole: false, area, bounds });
}
// An outline too small to see is dropped, so its inside counts as art (or, for a speck of art,
// as the area around it).
let tiny = (tolerance * tolerance).max(1e-9);
out.retain(|c| c.area > tiny);
// An outline inside an odd number of others bounds an island. Each outline is placed by the
// middle of its longest edge: outlines of an area with no overlaps meet at most at corners, so
// that point lies on no other outline.
let mut work = 0usize;
let mut depths = Vec::with_capacity(out.len());
for (i, c) in out.iter().enumerate() {
let p = longest_edge_middle(&c.points);
let mut depth = 0usize;
for (j, o) in out.iter().enumerate() {
if j == i || o.area < c.area || !holds_box(o.bounds, c.bounds) {
continue;
}
work += o.points.len();
if work > MAX_NESTING_WORK {
return Err(PathOpsError::TooComplex);
}
if contains(o, p) {
depth += 1;
}
}
depths.push(depth);
}
for (c, depth) in out.iter_mut().zip(depths) {
c.hole = depth % 2 == 1;
}
Ok(out)
}
/// The middle of the longest edge of the closed outline through `points`.
fn longest_edge_middle(points: &[Point]) -> Point {
let n = points.len();
(0..n)
.map(|i| (points[i], points[(i + 1) % n]))
.max_by(|(a, b), (c, d)| a.distance(*b).total_cmp(&c.distance(*d)))
.map_or(Point::ZERO, |(a, b)| a.midpoint(b))
}
/// Whether box `outer` holds box `inner`, edges included. Boxes are (min x, min y, max x, max y).
fn holds_box(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool {
outer.0 <= inner.0 && outer.1 <= inner.1 && outer.2 >= inner.2 && outer.3 >= inner.3
}
/// Whether `bp` and its handles stay within [`MAX_STENCIL_EXTENT`] of the origin.
fn within_extent(bp: &BezPath) -> bool {
let reach = bp.control_box();
[reach.x0, reach.y0, reach.x1, reach.y1].iter().all(|v| v.abs() <= MAX_STENCIL_EXTENT)
}
/// The points that follow outline `bp` within `tolerance`, segment by segment, or
/// [`PathOpsError::TooComplex`] once they pass `room` (and one more for a closed outline's
/// repeated start, which the caller drops). It stops at the first segment past the limit.
fn follow(bp: &BezPath, tolerance: f64, room: usize) -> Result<Vec<Point>, PathOpsError> {
let limit = room.saturating_add(1);
let mut points: Vec<Point> = vec![];
for seg in bp.segments() {
if points.is_empty() {
points.push(seg.start());
}
let mut over = false;
vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tolerance, |el| match el {
PathEl::LineTo(p) if points.len() < limit => points.push(p),
PathEl::LineTo(_) => over = true,
_ => {}
});
if over {
return Err(PathOpsError::TooComplex);
}
}
Ok(points)
}
fn signed_area(points: &[Point]) -> f64 {
let n = points.len();
(0..n).map(|i| points[i].to_vec2().cross(points[(i + 1) % n].to_vec2())).sum::<f64>() / 2.0
}
/// Whether `p` is inside contour `c` (even-odd).
fn contains(c: &Contour, p: Point) -> bool {
let (x0, y0, x1, y1) = c.bounds;
if p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 {
return false;
}
let n = c.points.len();
let mut inside = false;
for i in 0..n {
let (a, b) = (c.points[i], c.points[(i + 1) % n]);
if (a.y <= p.y) != (b.y <= p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) {
inside = !inside;
}
}
inside
}
/// The area outside the art that a point is in: 0 for the sheet around the art, `i + 1` for the
/// island contour `i` bounds.
type Area = usize;
const SHEET: Area = 0;
/// The art seen along lines in one direction: rotated so the lines run along x, with its segments
/// listed by the bands of y they reach.
struct View {
angle: f64,
/// Rotated segments, each with the contour it belongs to.
segments: Vec<(Point, Point, usize)>,
/// The lowest y of the rotated art.
y0: f64,
/// The band height, and the lines' spacing.
step: f64,
bands: Vec<Vec<u32>>,
/// Each contour's lowest and highest rotated y.
ranges: Vec<(f64, f64)>,
/// The segments lines may still look at; past it, every line is missing.
budget: Cell<usize>,
}
/// Where a line crosses the art: the edges in order (even-odd pairs are the art), and the area
/// each stretch outside the art is in (one more than there are pairs).
struct Line {
xs: Vec<f64>,
areas: Vec<Area>,
}
impl View {
fn new(contours: &[Contour], angle: f64, width: f64, budget: usize) -> Result<Self, PathOpsError> {
let (sin, cos) = angle.sin_cos();
let rotate = |p: Point| Point::new(p.x * cos + p.y * sin, -p.x * sin + p.y * cos);
let mut segments = vec![];
let mut ranges = vec![];
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
for (k, c) in contours.iter().enumerate() {
let pts: Vec<Point> = c.points.iter().map(|p| rotate(*p)).collect();
let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y)));
ranges.push((lo, hi));
y0 = y0.min(lo);
y1 = y1.max(hi);
for i in 0..pts.len() {
segments.push((pts[i], pts[(i + 1) % pts.len()], k));
}
}
let span = (y1 - y0).max(width);
let step = (width / 2.0).max(span / MAX_LINES);
let count = (span / step).ceil() as usize + 1;
let mut bands: Vec<Vec<u32>> = vec![vec![]; count];
let band = |y: f64| (((y - y0) / step).floor().max(0.0) as usize).min(count - 1);
let mut entries = 0usize;
for (i, (a, b, _)) in segments.iter().enumerate() {
let (from, to) = (band(a.y.min(b.y)), band(a.y.max(b.y)));
entries += to - from + 1;
if entries > MAX_BIN_ENTRIES {
return Err(PathOpsError::TooComplex);
}
for list in &mut bands[from..=to] {
list.push(i as u32);
}
}
Ok(View { angle, segments, y0, step, bands, ranges, budget: Cell::new(budget) })
}
/// Whether lines looked at more segments than the budget allows.
fn exhausted(&self) -> bool {
self.budget.get() == 0
}
/// The art along the line at rotated y `y`, `None` where its edges don't pair up or the budget
/// is spent.
fn line(&self, y: f64, contours: &[Contour]) -> Option<Line> {
let band = ((y - self.y0) / self.step).floor();
let list = self.bands.get(if band < 0.0 { usize::MAX } else { band as usize })?;
let left = self.budget.get().checked_sub(list.len()).filter(|left| *left > 0);
self.budget.set(left.unwrap_or(0));
left?;
let mut hits: Vec<(f64, usize)> = vec![];
for &i in list {
let (a, b, k) = self.segments[i as usize];
if (a.y <= y) != (b.y <= y) {
hits.push((a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), k));
}
}
if hits.len() % 2 == 1 {
return None;
}
hits.sort_by(|p, q| p.0.total_cmp(&q.0));
// Walking along the line, each contour crossed an odd number of times so far holds the
// point; the smallest island contour holding a stretch outside the art is its island.
let mut open: Vec<usize> = vec![];
let mut areas = vec![SHEET];
for (n, (_, k)) in hits.iter().enumerate() {
match open.iter().position(|o| o == k) {
Some(at) => {
open.swap_remove(at);
}
None => open.push(*k),
}
if n % 2 == 1 {
let island = open.iter().filter(|o| contours[**o].hole).min_by(|a, b| contours[**a].area.total_cmp(&contours[**b].area));
areas.push(island.map_or(SHEET, |k| k + 1));
}
}
Some(Line { xs: hits.into_iter().map(|(x, _)| x).collect(), areas })
}
/// The point at rotated `(x, y)` in the art's own coordinates.
fn unrotate(&self, x: f64, y: f64) -> Point {
let (sin, cos) = self.angle.sin_cos();
Point::new(x * cos - y * sin, x * sin + y * cos)
}
}
/// A crossing of the art along one line, between the areas on either side of it.
#[derive(Clone, Copy, Debug)]
struct Crossing {
view: usize,
y: f64,
/// Where it enters and leaves the art.
from: f64,
to: f64,
areas: (Area, Area),
/// How far its line is from the middle of the island it starts from.
off_middle: f64,
}
impl Crossing {
fn len(&self) -> f64 {
self.to - self.from
}
}
/// A bridge that fits: its crossing, its middle and its strip's corners.
struct Bridge {
areas: (Area, Area),
middle: Point,
strip: Vec<Point>,
}
struct Picker<'a> {
contours: &'a [Contour],
views: &'a [View],
width: f64,
crossings: Vec<Crossing>,
/// The crossings that touch each area.
touching: Vec<Vec<usize>>,
parent: Vec<usize>,
chosen: Vec<Bridge>,
}
impl<'a> Picker<'a> {
fn new(contours: &'a [Contour], views: &'a [View], width: f64) -> Self {
let areas = contours.len() + 1;
let holes = contours.iter().filter(|c| c.hole).count().max(1);
// Each island is looked across along lines through its middle and out from it on both
// sides, half a width apart, or fewer where there are many islands.
let each_side = (MAX_ISLAND_LINES / (holes * views.len()).max(1)).clamp(1, MAX_LINES_PER_SIDE) as f64;
let mut crossings = vec![];
for (v, view) in views.iter().enumerate() {
for (k, _) in contours.iter().enumerate().filter(|(_, c)| c.hole) {
let island = k + 1;
let (lo, hi) = view.ranges[k];
let (middle, reach) = ((lo + hi) / 2.0, (hi - lo) / 2.0);
let step = (width / 2.0).max(reach / each_side);
let mut ys = vec![middle];
let mut d = step;
while d < reach {
ys.extend([middle - d, middle + d]);
d += step;
}
for y in ys {
let Some(line) = view.line(y, contours) else { continue };
for (j, pair) in line.xs.as_chunks::<2>().0.iter().enumerate() {
let (a, b) = (line.areas[j], line.areas[j + 1]);
if a == b || (a != island && b != island) {
continue;
}
crossings.push(Crossing { view: v, y, from: pair[0], to: pair[1], areas: (a, b), off_middle: (y - middle).abs() });
}
}
}
}
// Shortest first; among crossings of about the same length, the one nearest the middle of
// its island.
let grain = width * 0.05;
crossings.sort_by(|p, q| (p.len() / grain).round().total_cmp(&(q.len() / grain).round()).then(p.off_middle.total_cmp(&q.off_middle)));
let mut touching = vec![vec![]; areas];
for (i, c) in crossings.iter().enumerate() {
touching[c.areas.0].push(i);
touching[c.areas.1].push(i);
}
Picker { contours, views, width, crossings, touching, parent: (0..areas).collect(), chosen: vec![] }
}
fn root(&mut self, mut a: usize) -> usize {
while self.parent[a] != a {
self.parent[a] = self.parent[self.parent[a]];
a = self.parent[a];
}
a
}
/// Join every island to the sheet by the shortest crossings that fit, as a spanning tree. Bridges
/// that only join islands that stay apart from the sheet are not cut.
fn connect(&mut self) {
let mut apart = self.contours.iter().filter(|c| c.hole).count();
for i in 0..self.crossings.len() {
if apart == 0 {
break;
}
let c = self.crossings[i];
let (a, b) = (self.root(c.areas.0), self.root(c.areas.1));
if a == b {
continue;
}
if let Some(bridge) = self.fit(&c) {
self.parent[a] = b;
self.chosen.push(bridge);
apart -= 1;
}
}
let sheet = self.root(SHEET);
for bridge in std::mem::take(&mut self.chosen) {
if self.root(bridge.areas.0) == sheet {
self.chosen.push(bridge);
}
}
}
/// Give each island more bridges, up to `count`, each far from the island's others (at least
/// two widths away, and as short as can be among those at least 80% as far as the farthest),
/// and no more than twice as long as its shortest crossing that a bridge fits plus a width.
/// A crossing a bridge does not fit is not tried again, and once the lines' budget is spent no
/// more bridges are found.
fn spread(&mut self, count: usize) {
let sheet = self.root(SHEET);
let mut misfit = vec![false; self.crossings.len()];
for island in 1..self.touching.len() {
if !self.contours[island - 1].hole || self.root(island) != sheet {
continue;
}
let mut limit = None;
loop {
let mine: Vec<Point> = self.chosen.iter().filter(|b| b.areas.0 == island || b.areas.1 == island).map(|b| b.middle).collect();
if mine.len() >= count {
break;
}
let longest = match limit {
Some(l) => l,
None => {
// The island's crossings are listed shortest first, so the first that fits
// is its shortest bridge.
let mut shortest = None;
for &i in &self.touching[island] {
if misfit[i] {
continue;
}
if self.fit(&self.crossings[i]).is_some() {
shortest = Some(self.crossings[i].len());
break;
}
misfit[i] = true;
}
let Some(shortest) = shortest else { break };
*limit.insert(shortest * 2.0 + self.width)
}
};
let mut ranked: Vec<(f64, usize)> = self.touching[island]
.iter()
.filter(|&&i| !misfit[i] && self.crossings[i].len() <= longest)
.map(|&i| {
let m = self.middle(&self.crossings[i]);
(mine.iter().map(|p| p.distance(m)).fold(f64::INFINITY, f64::min), i)
})
.filter(|(d, _)| *d >= self.width * 2.0)
.collect();
// The crossings nearly as far as the farthest go first, in the order `connect`
// takes them (shortest, then nearest the middle); then the rest, farthest first.
let far = ranked.iter().map(|r| r.0).fold(0.0, f64::max) * 0.8;
ranked.sort_by(|p, q| (q.0 >= far).cmp(&(p.0 >= far)).then_with(|| if p.0 >= far { p.1.cmp(&q.1) } else { q.0.total_cmp(&p.0) }));
let mut found = None;
for &(_, i) in &ranked {
match self.fit(&self.crossings[i]) {
Some(bridge) => {
found = Some(bridge);
break;
}
None => misfit[i] = true,
}
}
match found {
Some(bridge) => self.chosen.push(bridge),
None => break,
}
if self.views.iter().any(View::exhausted) {
return;
}
}
}
}
/// The islands not joined to the sheet.
fn unjoined(&mut self) -> usize {
let sheet = self.root(SHEET);
(1..self.parent.len()).filter(|&a| self.contours[a - 1].hole).filter(|&a| self.root(a) != sheet).count()
}
fn middle(&self, c: &Crossing) -> Point {
self.views[c.view].unrotate((c.from + c.to) / 2.0, c.y)
}
/// The bridge of crossing `c`, if a strip of the width fits there: along lines across its
/// width, the art is crossed once between the same two areas, and the strip ends outside the
/// art on each of them.
fn fit(&self, c: &Crossing) -> Option<Bridge> {
let view = &self.views[c.view];
let half = self.width / 2.0;
let (mut start, mut end) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut room_before, mut room_after) = (f64::NEG_INFINITY, f64::INFINITY);
for k in 0..CHECKS {
let y = c.y - half + self.width * k as f64 / (CHECKS - 1) as f64;
let line = view.line(y, self.contours)?;
let last = line.areas.len() - 1;
// The crossing between the same two areas that overlaps this one, the nearest if several do.
let (j, _) = line
.xs
.as_chunks::<2>()
.0
.iter()
.enumerate()
.filter(|(j, p)| (line.areas[*j], line.areas[j + 1]) == c.areas && p[1] > c.from - self.width && p[0] < c.to + self.width)
.min_by(|(_, p), (_, q)| (p[0] - c.from).abs().total_cmp(&(q[0] - c.from).abs()))?;
start = start.min(line.xs[2 * j]);
end = end.max(line.xs[2 * j + 1]);
if j > 0 {
room_before = room_before.max(line.xs[2 * j - 1]);
}
if j + 1 < last {
room_after = room_after.min(line.xs[2 * j + 2]);
}
}
if room_before >= start || room_after <= end {
return None;
}
// The strip reaches a little past the art at each end, so no sliver of it is left.
let reach = self.width.min(1.0);
let s0 = (start - reach).max((room_before + start) / 2.0);
let s1 = (end + reach).min((room_after + end) / 2.0);
let strip = [(s0, c.y - half), (s1, c.y - half), (s1, c.y + half), (s0, c.y + half)].map(|(x, y)| view.unrotate(x, y)).to_vec();
Some(Bridge { areas: c.areas, middle: self.middle(c), strip })
}
}
+326
View File
@@ -0,0 +1,326 @@
use kurbo::{BezPath, Point, Shape};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use super::*;
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
}
/// A square ring: `outer` with the hole `inner`, each (x0, y0, x1, y1).
fn ring(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> PathData {
PathData::new(vec![rect(outer.0, outer.1, outer.2, outer.3), rect(inner.0, inner.1, inner.2, inner.3)])
}
fn spec(width: f64, count: usize, direction: BridgeDirection) -> BridgeSpec {
BridgeSpec { width, count, direction }
}
fn run(art: &PathData, spec: BridgeSpec) -> Bridged {
stencil_bridges(&[(art, FillRule::EvenOdd)], spec).unwrap()
}
/// Is `(x, y)` inside the area `p` draws?
fn inside(p: &PathData, x: f64, y: f64) -> bool {
p.to_bezpath().winding(Point::new(x, y)) != 0
}
/// The islands left once the bridges are cut.
fn islands_left(b: &Bridged) -> usize {
run(&b.path, spec(1.0, 1, BridgeDirection::Shortest)).islands
}
/// A letter O made square: 100 pt across with bars 20 pt thick.
fn letter_o() -> PathData {
ring((0.0, 0.0, 100.0, 100.0), (20.0, 20.0, 80.0, 80.0))
}
#[test]
fn an_o_gets_a_bridge_and_its_island_joins_the_sheet() {
let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0));
assert_eq!(islands_left(&b), 0, "the inside of the O is joined to the sheet");
// The bridge crosses the top bar at the middle of the hole; the rest of the O stays.
assert!(!inside(&b.path, 50.0, 10.0));
assert!(inside(&b.path, 30.0, 10.0) && inside(&b.path, 50.0, 90.0) && inside(&b.path, 10.0, 50.0));
}
#[test]
fn the_bridge_is_as_wide_as_asked() {
for width in [2.0, 6.0] {
let b = run(&letter_o(), spec(width, 1, BridgeDirection::Vertical));
let half = width / 2.0;
assert!(!inside(&b.path, 50.0 - half * 0.9, 10.0) && !inside(&b.path, 50.0 + half * 0.9, 10.0), "{width}");
assert!(inside(&b.path, 50.0 - half * 1.2, 10.0) && inside(&b.path, 50.0 + half * 1.2, 10.0), "{width}");
}
}
#[test]
fn horizontal_bridges_cross_the_sides() {
let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Horizontal));
assert_eq!((b.bridges, islands_left(&b)), (1, 0));
assert!(!inside(&b.path, 10.0, 50.0) || !inside(&b.path, 90.0, 50.0));
assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 50.0, 90.0));
}
#[test]
fn the_shortest_crossing_takes_the_thinnest_bar() {
// The left bar is 5 pt thick, the others 20.
let art = ring((0.0, 0.0, 100.0, 100.0), (5.0, 20.0, 80.0, 80.0));
let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest));
assert_eq!((b.bridges, islands_left(&b)), (1, 0));
assert!(!inside(&b.path, 2.5, 50.0), "the bridge crosses the thin bar");
assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 90.0, 50.0));
// Vertical bridges cross a 20 pt bar instead.
let v = run(&art, spec(4.0, 1, BridgeDirection::Vertical));
assert!(inside(&v.path, 2.5, 50.0) && !inside(&v.path, 42.5, 10.0));
}
#[test]
fn a_shape_with_two_holes_gets_a_bridge_for_each() {
let art = PathData::new(vec![rect(0.0, 0.0, 100.0, 50.0), rect(10.0, 10.0, 40.0, 40.0), rect(60.0, 10.0, 90.0, 40.0)]);
let b = run(&art, spec(3.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0));
assert_eq!(islands_left(&b), 0);
assert!(!inside(&b.path, 25.0, 5.0) && !inside(&b.path, 75.0, 5.0));
}
#[test]
fn more_bridges_per_island_spread_around_it() {
let b = run(&letter_o(), spec(4.0, 2, BridgeDirection::Vertical));
assert_eq!(b.bridges, 2);
assert!(!inside(&b.path, 50.0, 10.0) && !inside(&b.path, 50.0, 90.0), "one at the top, one at the bottom");
}
#[test]
fn a_short_crossing_too_narrow_for_a_bridge_does_not_limit_the_others() {
// Hole A has a 2 pt slot B just above it: the 5 pt crossing from A to B is the shortest, but
// no 4 pt bridge fits there. A's 20 pt crossings to the sheet take its two bridges.
let art = PathData::new(vec![rect(0.0, 0.0, 200.0, 100.0), rect(20.0, 20.0, 80.0, 80.0), rect(49.0, 5.0, 51.0, 15.0)]);
let b = run(&art, spec(4.0, 2, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 1));
}
#[test]
fn islands_that_only_reach_each_other_get_no_bridge() {
// Holes Y and X, one above the other, with combs of 2 pt slots above Y and below X: no 4 pt
// vertical bridge reaches the sheet from either, so the bridge that fits between them would
// join nothing to the sheet.
let mut subpaths = vec![rect(0.0, 0.0, 100.0, 100.0), rect(20.0, 20.0, 80.0, 30.0), rect(30.0, 40.0, 70.0, 60.0)];
for k in 0..27 {
let x = 10.0 + 3.0 * k as f64;
subpaths.push(rect(x, 5.0, x + 2.0, 15.0));
subpaths.push(rect(x, 85.0, x + 2.0, 95.0));
}
let b = run(&PathData::new(subpaths), spec(4.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (56, 0, 56));
}
#[test]
fn an_island_inside_an_island_is_joined_through_it() {
// A ring in the hole of a ring: two islands, the gap between the rings and the inner hole.
let art =
PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(10.0, 10.0, 90.0, 90.0), rect(30.0, 30.0, 70.0, 70.0), rect(40.0, 40.0, 60.0, 60.0)]);
let b = run(&art, spec(2.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0));
assert_eq!(islands_left(&b), 0);
}
#[test]
fn an_island_too_narrow_for_the_width_is_counted() {
let art = ring((0.0, 0.0, 100.0, 100.0), (49.0, 20.0, 51.0, 80.0));
let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (1, 0, 1));
assert_eq!(islands_left(&b), 1);
}
#[test]
fn art_without_islands_is_left_whole() {
let art = PathData::new(vec![rect(0.0, 0.0, 50.0, 50.0), rect(100.0, 0.0, 150.0, 50.0)]);
let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest));
assert_eq!((b.islands, b.bridges, b.unbridged), (0, 0, 0));
assert!(inside(&b.path, 25.0, 25.0) && inside(&b.path, 125.0, 25.0));
}
#[test]
fn the_shortest_crossing_may_run_at_an_angle() {
// A diamond ring: its walls are 20 pt across horizontally and vertically but about 14 pt
// across at 45°, so only a diagonal line finds the shortest crossing.
let diamond = |r: f64| {
let pts = [(100.0, 100.0 - r), (100.0 + r, 100.0), (100.0, 100.0 + r), (100.0 - r, 100.0)].map(|(x, y)| Point::new(x, y));
SubPath::polyline(&pts, true)
};
let art = PathData::new(vec![diamond(60.0), diamond(40.0)]);
let b = run(&art, spec(2.0, 1, BridgeDirection::Shortest));
assert_eq!((b.bridges, islands_left(&b)), (1, 0));
// The middles of the four diagonal walls: one is cut. The middles of the walls straight
// above, below and beside the hole all stay.
let diagonal = [(125.0, 125.0), (75.0, 75.0), (125.0, 75.0), (75.0, 125.0)];
assert!(diagonal.iter().any(|&(x, y)| !inside(&b.path, x, y)), "a diagonal bridge");
for (x, y) in [(150.0, 100.0), (50.0, 100.0), (100.0, 150.0), (100.0, 50.0)] {
assert!(inside(&b.path, x, y), "({x}, {y}) stays");
}
let h = run(&art, spec(2.0, 1, BridgeDirection::Horizontal));
assert!(diagonal.iter().all(|&(x, y)| inside(&h.path, x, y)));
}
#[test]
fn a_hole_touching_another_outline_at_a_corner_is_still_an_island() {
// A diamond hole whose bottom corner, where its outline starts, touches the bottom edge of the
// square around it.
let hole = SubPath::polyline(&[(50.0, 100.0), (70.0, 70.0), (50.0, 40.0), (30.0, 70.0)].map(|(x, y)| Point::new(x, y)), true);
let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), hole]);
let found = contours(&region, 1.0).unwrap();
assert_eq!(found.iter().filter(|c| c.hole).count(), 1);
}
#[test]
fn a_sliver_too_small_to_see_counts_as_art() {
// A hole 10 pt long and 0.005 pt high between the island and the top edge: a bridge that
// reaches only the sliver would join nothing to the sheet.
let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(40.0, 40.0, 60.0, 60.0), rect(45.0, 20.0, 55.0, 20.005)]);
let b = bridge_region(region, spec(5.0, 1, BridgeDirection::Vertical)).unwrap();
assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0));
assert_eq!(islands_left(&b), 0);
}
#[test]
fn extra_bridges_stay_two_widths_apart() {
// Sixteen 10 pt bridges have no room around a hole 60 pt across with 20 pt between them.
let width = 10.0;
let found = contours(&letter_o(), width).unwrap();
let (bridges, unjoined) = choose(&found, spec(width, 16, BridgeDirection::Shortest), MAX_WORK).unwrap();
assert!(bridges.len() > 4 && bridges.len() < 16, "{}", bridges.len());
assert_eq!(unjoined, 0);
for (i, a) in bridges.iter().enumerate() {
for b in &bridges[i + 1..] {
assert!(a.middle.distance(b.middle) >= width * 2.0, "{:?} {:?}", a.middle, b.middle);
}
}
}
#[test]
fn bridges_found_before_the_budget_runs_out_are_kept() {
let found = contours(&letter_o(), 4.0).unwrap();
// The least work that joins the island with one bridge also gives one with up to 16 asked
// for: the extra bridges stop where the budget ends.
let least = (1..100_000).find(|&work| choose(&found, spec(4.0, 1, BridgeDirection::Vertical), work).is_ok()).unwrap();
let (bridges, unjoined) = choose(&found, spec(4.0, 16, BridgeDirection::Vertical), least).unwrap();
assert_eq!((bridges.len(), unjoined), (1, 0));
assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex)));
}
#[test]
fn curves_are_followed_within_a_twentieth_of_the_width() {
let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001));
let mut counts = vec![];
for (width, tolerance) in [(0.01, 0.002), (1.0, 0.05), (20.0, 0.25)] {
let found = contours(&circle, width).unwrap();
let points = &found[0].points;
// The sag of each chord below the circle.
let sag = (0..points.len()).map(|i| 100.0 - points[i].midpoint(points[(i + 1) % points.len()]).to_vec2().hypot()).fold(0.0, f64::max);
assert!(sag <= tolerance + 0.002, "{width}: {sag}");
counts.push(points.len());
}
assert!(counts[0] > counts[1] && counts[1] > counts[2], "{counts:?}");
}
#[test]
fn open_outlines_are_closed_as_a_fill_closes_them() {
let mut art = letter_o();
art.subpaths[0].closed = false;
let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0));
}
#[test]
fn art_too_large_is_refused_before_it_is_combined() {
let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]);
let points: Vec<Point> = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect();
let dense = PathData::new(vec![SubPath::polyline(&points, true)]);
assert!(too_large(&[(&far, FillRule::EvenOdd)]) && too_large(&[(&dense, FillRule::EvenOdd)]));
assert!(!too_large(&[(&letter_o(), FillRule::EvenOdd), (&letter_o(), FillRule::NonZero)]));
}
#[test]
fn outlines_nested_too_deep_to_place_are_refused() {
// Rings of 190-sided polygons, one inside the next: placing each inside the others takes
// about 95 million tests of a point against an edge with 1000 of them, and 8 million with 300.
let polygon = |r: f64| {
let pts: Vec<Point> = (0..190).map(|i| (i as f64 / 190.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| Point::new(c * r, s * r)).collect();
SubPath::polyline(&pts, true)
};
let nested = |n: usize| PathData::new((0..n).map(|i| polygon(10.0 + i as f64)).collect());
assert!(contours(&nested(300), 1.0).is_ok());
assert!(matches!(contours(&nested(1000), 1.0), Err(PathOpsError::TooComplex)));
}
#[test]
fn a_curved_o_is_bridged_at_its_thinnest() {
// Two circles, the hole 8 pt above the middle: the O is 2 pt thick at the top and 18 pt at
// the bottom.
let circle = |cy: f64, r: f64| PathData::from_bezpath(&kurbo::Circle::new((100.0, cy), r).to_path(0.01));
let mut art = circle(100.0, 60.0);
art.subpaths.extend(circle(92.0, 50.0).subpaths);
let b = run(&art, spec(1.0, 1, BridgeDirection::Vertical));
assert_eq!((b.islands, b.bridges, islands_left(&b)), (1, 1, 0));
assert!(!inside(&b.path, 100.0, 41.0), "it crosses the thin top");
assert!(inside(&b.path, 100.0, 151.0), "the thick bottom stays");
}
#[test]
fn art_with_too_many_outlines_is_refused() {
let squares = (0..MAX_STENCIL_CONTOURS + 1)
.map(|i| {
let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0);
rect(x, y, x + 1.0, y + 1.0)
})
.collect();
let region = PathData::new(squares);
assert!(matches!(contours(&region, 1.0), Err(PathOpsError::TooComplex)));
let mut fewer = region.clone();
fewer.subpaths.pop();
assert_eq!(contours(&fewer, 1.0).unwrap().len(), MAX_STENCIL_CONTOURS);
}
#[test]
fn art_as_large_as_a_document_holds_is_followed_and_larger_art_is_refused() {
// An O 8000000 pt across, the most a document holds, with a 60 pt hole near its edge, followed
// within 0.002 pt.
let circle = |c: (f64, f64), r: f64| PathData::from_bezpath(&kurbo::Circle::new(c, r).to_path(0.01));
let mut art = circle((0.0, 0.0), 4e6);
art.subpaths.extend(circle((0.0, 0.0), 2e6).subpaths);
art.subpaths.extend(circle((4e6 - 100.0, 0.0), 30.0).subpaths);
let found = contours(&art, 0.01).unwrap();
assert_eq!(found.iter().filter(|c| c.hole).count(), 2);
let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]);
assert!(matches!(contours(&far, 0.01), Err(PathOpsError::TooComplex)));
let r = stencil_bridges(&[(&far, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical));
assert!(matches!(r, Err(PathOpsError::TooComplex)));
}
#[test]
fn art_with_too_many_anchors_is_refused() {
let points: Vec<Point> = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect();
let art = PathData::new(vec![SubPath::polyline(&points, true)]);
let r = stencil_bridges(&[(&art, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical));
assert!(matches!(r, Err(PathOpsError::TooComplex)));
}
#[test]
fn following_stops_past_the_limit_and_allows_the_closing_point() {
// A closed square: its four corners and the start again.
let square = rect(0.0, 0.0, 10.0, 10.0);
let mut bp = BezPath::new();
square.to_bezpath_into(&mut bp);
assert_eq!(follow(&bp, 0.01, 4).unwrap().len(), 5);
assert!(matches!(follow(&bp, 0.01, 3), Err(PathOpsError::TooComplex)));
}
#[test]
fn bad_widths_and_counts_are_refused() {
let art = letter_o();
for s in [spec(0.0, 1, BridgeDirection::Vertical), spec(f64::NAN, 1, BridgeDirection::Vertical), spec(4.0, 0, BridgeDirection::Vertical)] {
assert!(stencil_bridges(&[(&art, FillRule::EvenOdd)], s).is_err(), "{s:?}");
}
}
+268 -60
View File
@@ -19,7 +19,7 @@
//! [`revision`] changes, so that drawings made with the deferred stage are drawn again.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
@@ -106,9 +106,108 @@ enum Phase {
Open,
}
/// The deferred records in the order they were made, indexed by stage and cache key. A stage holds
/// at most one record for each key.
#[derive(Default)]
struct Records {
/// The order number of the next record.
next: u64,
by_order: BTreeMap<u64, Deferred>,
/// For each stage with records, the order number of its record for each key.
by_stage: HashMap<Stage, HashMap<u64, u64>>,
}
impl Records {
/// Every record, oldest first.
fn iter(&self) -> impl Iterator<Item = &Deferred> {
self.by_order.values().inspect(|_| looked_at(1))
}
/// The stages with records, in no particular order.
fn stages(&self) -> impl Iterator<Item = Stage> + '_ {
self.by_stage.keys().copied()
}
/// The records of `stage`, in no particular order.
fn of(&self, stage: Stage) -> impl Iterator<Item = &Deferred> {
self.by_stage.get(&stage).into_iter().flat_map(|keys| keys.values()).filter_map(|n| {
looked_at(1);
self.by_order.get(n)
})
}
/// Is `stage` recorded with cache key `key`?
fn contains(&self, stage: Stage, key: u64) -> bool {
looked_at(1);
self.by_stage.get(&stage).is_some_and(|keys| keys.contains_key(&key))
}
/// Adds `d` as the newest record, in place of the record of its stage and key.
fn push(&mut self, d: Deferred) {
let order = self.next;
self.next = self.next.wrapping_add(1);
if let Some(old) = self.by_stage.entry(d.stage).or_default().insert(d.key, order)
&& let Some(old) = self.by_order.remove(&old)
{
unmark(old.key);
}
self.by_order.insert(order, d);
}
/// Drops the records of `stage` whose key `drop` gives true for.
fn drop_where(&mut self, stage: Stage, drop: impl Fn(u64) -> bool) {
let Some(keys) = self.by_stage.get_mut(&stage) else { return };
looked_at(keys.len());
let by_order = &mut self.by_order;
keys.retain(|&key, order| {
let keep = !drop(key);
if !keep && let Some(d) = by_order.remove(order) {
unmark(d.key);
}
keep
});
if keys.is_empty() {
self.by_stage.remove(&stage);
}
}
/// Drops the records for which `keep` is false.
fn retain(&mut self, mut keep: impl FnMut(&Deferred) -> bool) {
let by_stage = &mut self.by_stage;
self.by_order.retain(|_, d| {
looked_at(1);
let k = keep(d);
if !k {
if let Some(keys) = by_stage.get_mut(&d.stage) {
keys.remove(&d.key);
if keys.is_empty() {
by_stage.remove(&d.stage);
}
}
unmark(d.key);
}
k
});
}
}
#[cfg(test)]
thread_local! {
/// How many records the lookups on this thread have looked at.
static LOOKED_AT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` records looked at, in tests.
fn looked_at(n: usize) {
#[cfg(test)]
LOOKED_AT.with(|c| c.set(c.get() + n));
#[cfg(not(test))]
let _ = n;
}
struct State {
phase: Phase,
deferred: Vec<Deferred>,
deferred: Records,
/// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it.
report: Option<usize>,
/// The refresh running, if one is ([`Deferrals::refresh`]).
@@ -123,13 +222,13 @@ struct Refresh {
/// When its budget started (`None`: no run yet).
started: Option<u64>,
/// The objects whose stages run again; the others' stay deferred.
ids: Vec<u64>,
ids: HashSet<u64>,
/// The cache keys whose cached failures have been run again.
retried: Vec<u64>,
retried: HashSet<u64>,
/// The stages of `ids` that were drawn.
visited: Vec<Stage>,
visited: HashSet<Stage>,
/// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again.
touched: Vec<(Stage, u64)>,
touched: HashSet<(Stage, u64)>,
}
impl State {
@@ -155,7 +254,7 @@ impl State {
impl Drop for State {
fn drop(&mut self) {
for d in &self.deferred {
for d in self.deferred.iter() {
unmark(d.key);
}
}
@@ -197,7 +296,7 @@ impl Deferrals {
pub fn with_clock(clock: Clock) -> Self {
Self(Arc::new(Mutex::new(State {
phase: Phase::Budgeted { started: None, first: true },
deferred: Vec::new(),
deferred: Records::default(),
report: None,
refresh: None,
clock,
@@ -211,7 +310,7 @@ impl Deferrals {
/// The deferred stages, by object and effect, each once (a stage drawn with several inputs,
/// such as the paths of a group, is listed with the first one's reason).
pub fn list(&self) -> Vec<Deferred> {
let mut out = self.state().deferred.clone();
let mut out: Vec<Deferred> = self.state().deferred.iter().cloned().collect();
out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect));
out.dedup_by_key(|d| d.stage);
out
@@ -238,13 +337,10 @@ impl Deferrals {
if pass.ran.is_empty() {
return;
}
self.state().deferred.retain(|d| {
let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key));
if !keep {
unmark(d.key);
}
keep
});
let mut s = self.state();
for &stage in &pass.ran {
s.deferred.drop_where(stage, |key| !pass.seen.contains(&(stage, key)));
}
}
/// The first view has been drawn: later drawings leave deferred stages deferred, and
@@ -271,14 +367,14 @@ impl Deferrals {
pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed {
let (targets, ids) = {
let mut s = self.state();
let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect();
let asked: Option<HashSet<u64>> = objects.map(|o| o.iter().copied().collect());
let mut targets: Vec<Stage> = s.deferred.stages().filter(|t| asked.as_ref().is_none_or(|o| o.contains(&t.object))).collect();
targets.sort_by_key(|t| (t.object, t.item, t.effect));
targets.dedup();
let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect();
ids.dedup();
let thread = std::thread::current().id();
let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched });
let (retried, visited, touched) = (HashSet::new(), HashSet::new(), HashSet::new());
s.refresh = Some(Refresh { thread, started: None, ids: ids.iter().copied().collect(), retried, visited, touched });
(targets, ids)
};
// The refresh ends when `run` returns, also when it unwinds.
@@ -289,20 +385,16 @@ impl Deferrals {
}
}
let reopen = Reopen(self);
let whole = run(&ids);
let whole: HashSet<u64> = run(&ids).into_iter().collect();
let mut s = self.state();
let touched = match s.refresh.take() {
Some(r) => {
s.deferred.retain(|d| {
let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key));
if gone {
unmark(d.key);
}
!gone
});
for &stage in r.visited.iter().filter(|t| whole.contains(&t.object)) {
s.deferred.drop_where(stage, |key| !r.touched.contains(&(stage, key)));
}
r.touched
}
None => Vec::new(),
None => HashSet::new(),
};
drop(s);
drop(reopen);
@@ -310,11 +402,11 @@ impl Deferrals {
// than time; one with records left that this refresh did not run is still deferred.
let s = self.state();
let mut out = Refreshed::default();
for t in &targets {
let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable();
for &t in &targets {
let mut left = s.deferred.of(t).peekable();
if left.peek().is_none() {
out.refreshed += 1;
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) {
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(t, d.key))) {
out.failed += 1;
} else {
out.still_deferred += 1;
@@ -355,7 +447,7 @@ impl Deferrals {
pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false);
let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key);
let held = settled && s.deferred.contains(stage, key);
drop(s);
if held {
seen(stage, key);
@@ -367,30 +459,22 @@ impl Deferrals {
/// reached the stage.
pub(crate) fn visit(&self, stage: Stage) {
let mut s = self.state();
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) {
r.visited.push(stage);
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.visited.insert(stage);
}
}
/// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is
/// false, such as those of effects or objects removed from the document.
pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) {
self.state().deferred.retain(|d| {
let k = keep(d.stage, &d.plugin);
if !k {
unmark(d.key);
}
k
});
self.state().deferred.retain(|d| keep(d.stage, &d.plugin));
}
/// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key
/// while a refresh running here runs the stages of `stage`'s object.
pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool {
let mut s = self.state();
let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false };
r.retried.push(key);
true
s.refresh_here().filter(|r| r.ids.contains(&stage.object)).is_some_and(|r| r.retried.insert(key))
}
/// What a run starting now may take of the budget: the budget starts with the first run.
@@ -425,13 +509,7 @@ impl State {
/// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0
/// removes every record of the stage.
fn remove(&mut self, stage: Stage, key: u64) {
self.deferred.retain(|d| {
let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0);
if !keep {
unmark(d.key);
}
keep
});
self.deferred.drop_where(stage, |k| k == key || k == 0 || key == 0);
}
/// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this
@@ -439,12 +517,12 @@ impl State {
/// view [`Deferrals::settle`] looks at the stage when that drawing ends.
fn ran_here(&mut self, stage: Stage, key: u64) {
if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
r.touched.push((stage, key));
r.touched.insert((stage, key));
}
if self.phase_here() == Phase::Open {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) {
pass.ran.push(stage);
if let Some(pass) = p.borrow_mut().last_mut() {
pass.ran.insert(stage);
}
});
}
@@ -452,9 +530,8 @@ impl State {
/// The deferred stages, each once.
fn stages(&self) -> Vec<Stage> {
let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect();
let mut out: Vec<Stage> = self.deferred.stages().collect();
out.sort_by_key(|t| (t.object, t.item, t.effect));
out.dedup();
out
}
}
@@ -469,15 +546,15 @@ thread_local! {
/// deferred again), and the stage inputs it left deferred.
#[derive(Default)]
struct Pass {
ran: Vec<Stage>,
seen: Vec<(Stage, u64)>,
ran: HashSet<Stage>,
seen: HashSet<(Stage, u64)>,
}
/// The drawing on this thread left `stage` deferred with cache key `key`.
fn seen(stage: Stage, key: u64) {
let _ = PASSES.try_with(|p| {
if let Some(pass) = p.borrow_mut().last_mut() {
pass.seen.push((stage, key));
pass.seen.insert((stage, key));
}
});
}
@@ -676,6 +753,79 @@ mod tests {
assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]);
}
/// Objects asked for or drawn whole more than once count once; a stage with several inputs
/// gives a result when its object is drawn whole, fails when an input it ran with fails again
/// (even beside an older failure it did not run), and stays deferred when it is reached but
/// not drawn whole or not reached at all.
#[test]
fn a_refresh_with_repeated_objects_and_several_inputs_per_stage() {
const K: u64 = 0xdef1_0000;
let marked = |key| MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key));
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let st = |object| Stage { object, effect: 0, item: None };
for (object, reason, key) in [
(10, Reason::Budget, K + 1),
(10, Reason::Budget, K + 2),
(11, Reason::Timeout, K + 3),
(11, Reason::Budget, K + 4),
(12, Reason::Timeout, K + 5),
(13, Reason::Budget, K + 6),
(14, Reason::Budget, K + 7),
] {
d.defer(st(object), "a", reason, key);
}
let before = revision();
let seen = std::cell::RefCell::new(vec![]);
let counts = d.refresh(Some(&[10, 11, 12, 13, 10, 11, 99]), |ids| {
seen.borrow_mut().extend_from_slice(ids);
d.visit(st(10));
d.visit(st(10));
d.ran(st(10), K + 1);
d.visit(st(11));
d.defer(st(11), "a", Reason::Failed, K + 8);
d.visit(st(12));
vec![10, 13, 10, 13]
});
assert_eq!(*seen.borrow(), [10, 11, 12, 13]);
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 1 });
assert!(revision() > before);
let keys = || d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>();
assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]);
assert!(!marked(K + 1) && !marked(K + 2), "the records of the stage that gave a result");
assert!(marked(K + 3) && marked(K + 8));
// Again, with nothing reached: every stage asked for stays as it was.
let counts = d.refresh(Some(&[11, 12, 12]), |_| vec![11, 12]);
assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 2, failed: 0 });
assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]);
}
/// A refresh whose drawing unwinds ends: later drawings on this thread are no longer part of
/// it, and the next refresh has a budget of its own.
#[test]
fn a_refresh_that_unwinds_ends() {
let d = Deferrals::with_clock(stepping(1));
d.first_view_done();
let s = Stage { object: 5, effect: 0, item: None };
d.defer(s, "a", Reason::Budget, 0xdef2_0001);
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
d.refresh(None, |_| {
assert!(!d.holds(s, 0xdef2_0001));
panic!("the drawing stopped")
})
}));
assert!(unwound.is_err());
assert!(matches!(d.allowance(), Allowance::Unlimited));
assert!(d.holds(s, 0xdef2_0001), "the stage stays deferred");
let counts = d.refresh(Some(&[5]), |_| {
assert!(matches!(d.allowance(), Allowance::Until { .. }));
d.visit(s);
vec![5]
});
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 0, failed: 0 });
}
/// After the first view, a stage drawn with several inputs (the paths of a group) whose one
/// changed input runs keeps the records of the inputs the same drawing left deferred, and drops
/// the record of the input it no longer has.
@@ -724,4 +874,62 @@ mod tests {
drop((d, other));
assert!(!marked(), "the document closed");
}
/// Deferrals after the first view with `n` stages, one input each, keyed from `base`.
fn many(n: u64, base: u64) -> Deferrals {
let d = Deferrals::with_clock(stepping(1));
for object in 0..n {
d.defer(Stage { object, effect: 0, item: None }, "a", Reason::Budget, base + object);
}
d.first_view_done();
d
}
/// How many records `f` looks at on this thread.
fn looked_at_by(f: impl FnOnce()) -> usize {
let before = LOOKED_AT.with(std::cell::Cell::get);
f();
LOOKED_AT.with(std::cell::Cell::get) - before
}
/// Asking whether a stage input stays deferred looks at that stage's records only, however
/// many other stages are deferred.
#[test]
fn holding_a_stage_looks_at_its_own_records_only() {
const K: u64 = 0xdef3_0000;
let d = many(2_000, K);
let s = Stage { object: 1_000, effect: 0, item: None };
let n = looked_at_by(|| {
assert!(d.holds(s, K + 1_000));
assert!(!d.holds(s, K + 1_001), "another stage's input");
assert!(!d.holds(Stage { effect: 1, ..s }, K + 1_000), "another stage");
});
assert!(n <= 3, "looked at {n} records");
}
/// A stage that gives a result, or is deferred again, replaces its own records only, and so
/// does the end of a drawing after the first view: the other stages' records are not looked at.
#[test]
fn replacing_a_stage_record_looks_at_its_own_records_only() {
const K: u64 = 0xdef4_0000;
let d = many(2_000, K);
let st = |object| Stage { object, effect: 0, item: None };
let n = looked_at_by(|| {
d.ran(st(1_000), K + 1_000);
d.defer(st(1_001), "a", Reason::Failed, K + 9_999);
d.defer(st(1_002), "a", Reason::Missing, 0);
d.ran(st(1_002), 7);
});
assert!(n <= 6, "looked at {n} records");
let n = looked_at_by(|| {
d.view(|| {
assert!(!d.holds(st(1_003), K + 9_998));
d.ran(st(1_003), K + 9_998);
});
});
assert!(n <= 4, "looked at {n} records");
let keys = d.state().deferred.iter().filter(|x| (1_000..1_004).contains(&x.stage.object)).map(|x| x.key).collect::<Vec<_>>();
assert_eq!(keys, [K + 1_001, K + 9_999]);
assert_eq!(d.list().len(), 1_997);
}
}
+35 -4
View File
@@ -258,6 +258,19 @@ impl Writer<'_> {
self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look");
return self.text_outlines(n, t);
}
// The space before the first character is written as its `dx`; one before a character
// after a line break, which the `<textPath>` writes as a space, keeps its look as outlines.
let plain = t.plain_text();
let mut at = 0;
let spaced_break = t.runs.iter().any(|r| {
let start = at;
at += r.text.len();
r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0)
});
if spaced_break {
self.warn("type on a path with space before a character after a line break is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -559,10 +572,17 @@ impl Writer<'_> {
let a = self.attrs(&props);
let offset = fmt_num(at * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &drawn.runs {
let plain = drawn.plain_text();
let first = drawn.runs.iter().position(|r| !r.text.is_empty());
for (i, run) in drawn.runs.iter().enumerate() {
let mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
let a = self.attrs(&diff);
let mut a = self.attrs(&diff);
// The space before the first character (`kerningBefore`) moves it along the path.
let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 };
if before != 0.0 && before.is_finite() {
a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals)));
}
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
}
s.push_str("</textPath></text>");
@@ -751,6 +771,14 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
let x = g.xf.translation().x - offset;
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
// The space before a line's first character (`kerningBefore`) is in its advance and
// moves it along the line: a reader sets it that far along, without it. (Shrink
// Text to Fit scales it with the sizes.)
None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => {
let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale;
let before = if before.is_finite() { before } else { 0.0 };
(g.advance - before, g.origin.x + before)
}
None => (g.advance, g.origin.x),
};
let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
@@ -838,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
// A reader spaces words and letters as the font does.
p.justification = vectorcraft_doc::JustificationOptions::default();
}
if !kerning {
for r in &mut set.runs {
for r in &mut set.runs {
if !kerning {
r.style.kerning.get_or_insert(0.0);
}
// A reader puts no space before a line's first character (`kerningBefore`): the segment
// starts that much further along instead.
r.style.kerning_before = 0.0;
}
let relaid;
let glyphs = if set == *t {
+54
View File
@@ -109,6 +109,60 @@ fn rich_text_writes_spacing_shift_and_scale() {
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first
/// `<tspan>` to where the character is drawn, left aligned or anchored at a centred line's centre.
#[test]
fn the_space_before_a_lines_first_character_moves_its_tspan() {
let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
// 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after
// the first "H" is inside its line, so its value does nothing.
let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))];
let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default());
// The first <tspan> on each baseline.
let mut starts: Vec<(f64, f64)> = vec![];
for t in tspans(&svg) {
let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap());
if starts.last().is_none_or(|&(_, last)| last != y) {
starts.push((x, y));
}
}
assert_eq!(starts.iter().map(|s| s.0).collect::<Vec<_>>(), vec![3.0, 3.0], "{svg}");
// Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws.
let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default());
let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap();
assert!((x - 101.5).abs() < 0.01, "{svg}");
// A justified line, which a reader's own advances place word by word, starts there too.
let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))];
let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default());
assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}");
}
/// On a path, the space before the first character is its `dx` along the path. One before a
/// character after a line break, which the `<textPath>` can't write, is kept as outlines.
#[test]
fn the_space_before_the_first_character_on_a_path_is_its_dx() {
let doc = |runs: Vec<TextRun>| {
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.runs = runs;
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
vectorcraft_svg::export_full(&d, &ExportOptions::default(), None)
};
let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]);
let tags = tspans(&out.svg);
assert!(out.svg.contains("<textPath"), "{}", out.svg);
assert_eq!((attr(tags[0], "dx"), attr(tags[1], "dx")), (Some(3.0), None), "{}", out.svg);
let out = doc(vec![run("HO\n", style(12.0)), run("H", before)]);
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("line break")), "{:?}", out.warnings);
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
+5 -7
View File
@@ -502,17 +502,15 @@ pub fn param_keys(doc: &str) -> Vec<String> {
out
}
/// Parameter-name keys that name a file or folder a command may write
/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could
/// write files anywhere, so callers substitute a temp path.
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
vectorcraft_engine::file_access::is_path_key(k)
matches!(k, "path" | "diffPath") || is_folder_key(k)
}
/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in
/// `Folder` (`destinationFolder`).
/// Path keys that name a folder a command creates or writes files into.
pub fn is_folder_key(k: &str) -> bool {
k == "folder" || k.ends_with("Folder")
matches!(k, "folder" | "destinationFolder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
+22
View File
@@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize {
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
/// Does the character at byte `i` of `s` start a line by itself: the first character of the text,
/// of a paragraph, or after a forced line break (U+2028)?
pub fn starts_line(s: &str, i: usize) -> bool {
i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}']))
}
/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field,
/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it
/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it
/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character
/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before
/// the caret, so it is the space before the character after it (#5111). None at the start of an
/// empty line, where no character follows either.
pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range<usize>, bool)> {
let i = floor_char(s, i);
if !starts_line(s, i) {
return Some((prev_char(s, i)..i, false));
}
let c = s.get(i..)?.chars().next()?;
(c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true))
}
/// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and
/// tone marks, an accented letter or an emoji sequence is passed over whole.
pub fn prev_cluster(s: &str, i: usize) -> usize {
+3 -1
View File
@@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool {
/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the
/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper
/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike`
/// and change no glyph, advance or position. Nor is the space before a line's first character
/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike`
/// (which also ignores baseline shift, tracking, underline, …).
pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
// Exhaustive: a new attribute must explicitly keep or relax this boundary rule.
@@ -51,6 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
leading,
tracking,
kerning,
kerning_before: _,
optical_kerning,
baseline_shift,
h_scale,
+28 -15
View File
@@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool {
///
/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn
/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift
/// kept across the column and a leading space taken off moving it along the path, then moved across
/// kept across the column and a leading space taken off (or the space before a column's first
/// character) moving it along the path, then moved across
/// the path as a glyph lying on its side is.
fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) {
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift)));
let about = Point::new(cell * 0.5, -upright_centre(g, cell, em));
let mut turn =
Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift;
let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em))
* shift
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about)
* unshift;
// The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on
// a path that runs to the left: it turns half a turn about its cell's center.
let tangent = Vec2::new(angle.cos(), angle.sin());
@@ -359,19 +362,20 @@ impl Ctx<'_> {
} else {
self.db.outline(&g.face, g.gid)
};
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by moving it up the column once it stands upright.
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one
// moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an
// upright one in vertical type by moving it along the column once it stands upright.
let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some());
let lead = if upright { 0.0 } else { g.lead };
let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) };
let local = match &g.inline {
// Inline graphics: art space to glyph space (no font outline), moved along the line by
// right-to-left manual kerning as a glyph is.
Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf,
Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf,
// Baseline shift moves the glyph across its line, and Character Rotation then turns it
// about its shifted pen position, so the shift is across the line at any rotation.
// Manual kerning and composition trim move that pen position along the line.
None => {
Affine::translate((g.kern_shift - lead, -g.bshift))
Affine::translate((kern_shift - lead, -g.bshift))
* Affine::rotate(-g.rotation.to_radians())
* Affine::translate((g.dx, g.dy))
* Affine::scale_non_uniform(g.sx, g.sy)
@@ -379,7 +383,7 @@ impl Ctx<'_> {
};
// The turning point in font units, which later moves of the glyph's transform keep.
let pivot = match local.determinant() {
det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift),
det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift),
_ => Point::ZERO,
};
let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 {
@@ -490,9 +494,10 @@ impl Ctx<'_> {
// space after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
// A leading space taken off moves it up the column after the turn (moving the
// turning point instead would move it across the column too).
let turn = Affine::translate((-g.lead, 0.0))
// A leading space taken off moves it up the column after the turn, and the space
// before a column's first character down it (moving the turning point instead
// would move it across the column too).
let turn = Affine::translate((g.kern_shift - g.lead, 0.0))
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)));
m = shift * turn * unshift * m;
// Its own advance across, from where the cell centres it: where the shaper set the
@@ -527,10 +532,16 @@ impl Ctx<'_> {
};
// Where its own width starts along its advance: the shaper's placement, a leading space
// taken off and right-to-left manual kerning move the glyph along the line. An upright or
// tate-chu-yoko glyph in vertical type starts its cell at the start of its advance.
let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
// tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an
// upright one after the space before a column's first character.
let along = {
let [a, b, ..] = pre.as_coeffs();
Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0)
a.hypot(b)
};
let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0)
} else if upright {
Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0)
} else {
0.0
};
@@ -676,6 +687,7 @@ mod emit_kashida_mark_last {
tcy: None,
inline: None,
word_end: false,
keeps_word: false,
lead: 0.0,
proportional: false,
vpal: None,
@@ -737,6 +749,7 @@ mod emit_kashida_mark_last {
tcy: None,
inline: None,
word_end: false,
keeps_word: false,
lead: 0.0,
proportional: false,
vpal: None,

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