Merge main into contextual glyph alternates

This commit is contained in:
Taha-Mahmoodi
2026-11-12 15:56:45 +04:30
113 changed files with 6835 additions and 271 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
@@ -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());
}
}
+973
View File
@@ -0,0 +1,973 @@
//! HP-GL export: the instructions written, pens, origins, what is cut and left out, limits, and
//! plots read back by the HP-GL reader.
use std::sync::Arc;
use kurbo::{PathEl, Shape as _};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, LayerColor, Node, NodeKind, TextObject};
use vectorcraft_geom::{PathData, Point, Rect, shapes};
use super::*;
/// A 200 × 100 pt document whose first layer holds what `nodes` makes.
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
let mut d = Document::new(200.0, 100.0);
let nodes = nodes(&mut d);
let layer = d.layers[0].id;
for n in nodes {
d.insert(Some(layer), usize::MAX, n).unwrap();
}
d
}
fn stroked(d: &mut Document, p: PathData, c: Color) -> Node {
Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::solid(c), 1.0))
}
fn filled(d: &mut Document, p: PathData, c: Color) -> Node {
Node::path(d.alloc_id(), p, Appearance::basic(Paint::solid(c), Paint::None, 1.0))
}
fn line(a: (f64, f64), b: (f64, f64)) -> PathData {
let mut bp = BezPath::new();
bp.move_to(a);
bp.line_to(b);
PathData::from_bezpath(&bp)
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData {
shapes::rectangle(Rect::new(x0, y0, x1, y1))
}
/// The options with the first artboard as the region.
fn opts(d: &Document) -> HpglOptions {
HpglOptions { region: d.artboards[0].rect, ..HpglOptions::default() }
}
fn plot(d: &Document, o: &HpglOptions) -> HpglOutput {
match export(d, o) {
Ok(out) => out,
Err(e) => panic!("{e}"),
}
}
fn text(out: &HpglOutput) -> String {
String::from_utf8(out.bytes.clone()).expect("ASCII")
}
/// A black square outline, a red line and a blue ellipse filled without a stroke.
fn three() -> Document {
doc_with(|d| {
vec![
stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK),
stroked(d, line((70.0, 20.0), (150.0, 80.0)), Color::rgb8(255, 0, 0)),
filled(d, shapes::ellipse(Rect::new(70.0, 30.0, 130.0, 70.0)), Color::rgb8(0, 0, 255)),
]
})
}
/// Every instruction in the HP-GL reference guide's form: a two-letter mnemonic, integer
/// parameters separated by commas, and a semicolon (https://support.hp.com/sg-en/document/c01121263).
#[test]
fn a_plot_is_pen_moves_in_plotter_units_from_the_bottom_left() {
let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]);
let out = plot(&d, &opts(&d));
// 1016 units to the inch, y up from the artboard's bottom edge; the square closes.
assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD847,1270,847,847,141,847,141,1270;PU;SP0;");
for i in text(&out).split_terminator(';') {
let (op, params) = i.split_at(2);
assert!(op.bytes().all(|c| c.is_ascii_uppercase()), "{i}");
assert!(params.is_empty() || params.split(',').all(|v| v.parse::<i64>().is_ok()), "{i}");
}
assert_eq!(out.pens, [[0, 0, 0]]);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn the_origin_can_be_the_center() {
let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]);
let out = plot(&d, &HpglOptions { origin: Origin::Center, ..opts(&d) });
// The artboard's center is (100, 50): x from -1270, y from 564 (40 pt up).
assert_eq!(text(&out), "IN;PU;SP1;PU-1270,564;PD-564,564,-564,141,-1270,141,-1270,564;PU;SP0;");
}
#[test]
fn each_new_color_is_the_next_pen_and_pens_draw_in_turn() {
let d = doc_with(|d| {
vec![
stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::rgb8(255, 0, 0)),
stroked(d, line((10.0, 20.0), (20.0, 20.0)), Color::BLACK),
stroked(d, line((10.0, 30.0), (20.0, 30.0)), Color::rgb8(255, 0, 0)),
]
});
let out = plot(&d, &opts(&d));
assert_eq!(out.pens, [[255, 0, 0], [0, 0, 0]]);
assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD282,1270;PU141,988;PD282,988;PU;SP2;PU141,1129;PD282,1129;PU;SP0;");
// A pen table: each color takes the nearest pen's (red is as near black as blue, and black
// comes first).
let table = plot(&d, &HpglOptions { pens: vec![[0, 0, 0], [0, 0, 255]], ..opts(&d) });
assert_eq!(table.pens, [[0, 0, 0], [0, 0, 255]]);
assert!(text(&table).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&table));
assert!(!text(&table).contains("SP2"), "{}", text(&table));
}
#[test]
fn a_filled_path_is_its_closed_outline_and_a_stroke_its_line() {
let d = doc_with(|d| {
let mut open = BezPath::new();
open.move_to((10.0, 10.0));
open.line_to((20.0, 10.0));
open.line_to((20.0, 20.0));
let open = PathData::from_bezpath(&open);
let both = Node::path(d.alloc_id(), open.clone(), Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb8(255, 0, 0)), 1.0));
vec![filled(d, open.clone(), Color::BLACK), stroked(d, open, Color::BLACK), both]
});
let out = plot(&d, &opts(&d));
let t = text(&out);
assert!(t.contains("PU141,1270;PD282,1270,282,1129,141,1270;"), "the fill closes: {t}");
assert!(t.contains("PU141,1270;PD282,1270,282,1129;PU;"), "the stroke doesn't: {t}");
// A path with a fill and a stroke is closed too, in its stroke's color.
assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]]);
assert!(t.ends_with("SP2;PU141,1270;PD282,1270,282,1129,141,1270;PU;SP0;"), "{t}");
}
/// A gradient mesh is its outline, once, in the average of its points' colors.
#[test]
fn a_gradient_mesh_is_its_outline() {
let d = doc_with(|d| {
let mesh = vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(10.0 + u * 50.0, 10.0 + v * 50.0), &|u, _| {
if u < 0.5 { Color::rgb8(255, 0, 0) } else { Color::rgb8(0, 0, 255) }
});
vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh))]
});
let out = plot(&d, &opts(&d));
let t = text(&out);
assert_eq!(t.matches("PD").count(), 1, "{t}");
assert!(t.starts_with("IN;PU;SP1;PU"), "{t}");
// The square's four corners are on the line it draws.
for corner in ["141,1270", "847,1270", "847,564", "141,564"] {
assert!(t.contains(corner), "{corner} in {t}");
}
let [r, g, b] = out.pens[0];
assert!(r > 0 && b > 0 && g == 0, "{:?}", out.pens);
}
/// A square gradient mesh, 30 pt a side, at `x`.
fn mesh_at(x: f64) -> vectorcraft_doc::live::GradientMesh {
vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(x + u * 30.0, 10.0 + v * 30.0), &|_, _| Color::BLACK)
}
fn blend_of(d: &mut Document, keys: Vec<Node>, steps: u32) -> Node {
let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Steps(steps), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })
}
/// A mesh inside a blend is its outline too: a blend of two meshes with one step between them
/// draws three outlines. A mesh whose every point is at 0% opacity draws nothing.
#[test]
fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() {
let d = doc_with(|d| {
let keys = vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(110.0)))];
let mut clear = mesh_at(60.0);
clear.points.iter_mut().for_each(|p| p.opacity = 0.0);
vec![blend_of(d, keys, 1), Node::new(d.alloc_id(), NodeKind::Mesh(clear))]
});
let t = text(&plot(&d, &opts(&d)));
assert_eq!(t.matches("PD").count(), 3, "{t}");
}
/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a
/// warning.
#[test]
fn live_art_past_its_limit_is_left_out() {
let d = doc_with(|d| {
let keys = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((190.0, 10.0), (190.0, 20.0)), Color::BLACK)];
vec![blend_of(d, keys, 1_200)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == LIVE_LEFT_OUT), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP0;");
}
/// A line shorter than a plotter unit draws nothing and takes no pen.
#[test]
fn a_line_that_draws_nothing_takes_no_pen() {
let d = doc_with(|d| {
vec![stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::rgb8(255, 0, 0)), stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert_eq!(out.pens, [[0, 0, 0]]);
assert!(text(&out).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&out));
let two = plot(&d, &HpglOptions { version: Version::Hpgl2, ..opts(&d) });
assert!(two.bytes.starts_with(b"IN;NP2;PC1,0,0,0;PU;SP1;PE"), "{:?}", String::from_utf8_lossy(&two.bytes));
}
/// The plots of one export share the pens: a color is the same pen in each, numbered in the
/// order the plots first draw in it.
#[test]
fn the_plots_of_one_export_share_their_pens() {
let d = doc_with(|d| {
vec![
stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK),
stroked(d, line((110.0, 10.0), (120.0, 10.0)), Color::rgb8(255, 0, 0)),
stroked(d, line((110.0, 20.0), (120.0, 20.0)), Color::BLACK),
]
});
let regions = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)];
let plots = export_regions(&d, &HpglOptions::default(), &regions).unwrap();
assert_eq!(plots.len(), 2);
for p in &plots {
assert_eq!(p.pens, [[0, 0, 0], [255, 0, 0]]);
}
assert_eq!(text(&plots[0]), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
assert_eq!(text(&plots[1]), "IN;PU;SP1;PU141,1129;PD282,1129;PU;SP2;PU141,1270;PD282,1270;PU;SP0;");
}
#[test]
fn hidden_and_non_printing_art_is_left_out() {
let mut d = doc_with(|d| {
let mut hidden = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK);
hidden.visible = false;
let none = Node::path(d.alloc_id(), rect(30.0, 10.0, 40.0, 20.0), Appearance::basic(Paint::None, Paint::None, 1.0));
vec![hidden, none]
});
let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1));
if let NodeKind::Layer { printable, .. } = &mut layer.kind {
*printable = false;
}
let art = stroked(&mut d, rect(50.0, 10.0, 60.0, 20.0), Color::BLACK);
layer.children_mut().unwrap().push(Arc::new(art));
d.layers.push(Arc::new(layer));
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
}
/// Hidden and template layers, guides of either kind and placed images draw nothing; an image
/// comes back as a warning.
#[test]
fn layers_guides_and_images_that_draw_nothing_are_left_out() {
let mut d = doc_with(|d| {
let mut guide = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK);
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
let ruler = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 50.0, artboard: None });
let image = vectorcraft_doc::ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
vec![ruler, guide, Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))]
});
let hidden = d.add_layer(Some("Notes"));
let template = d.add_layer(Some("Tracing"));
for l in [hidden, template] {
let n = stroked(&mut d, rect(30.0, 10.0, 40.0, 20.0), Color::BLACK);
d.insert(Some(l), 0, n).unwrap();
}
if let Some(n) = d.node_mut(hidden) {
n.visible = false;
}
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
*template = true;
}
let out = plot(&d, &opts(&d));
assert_eq!(text(&out), "IN;PU;SP0;");
assert_eq!(out.warnings, [IMAGES]);
}
/// A non-printing layer's Crop Marks are left out with the layer, and so is a template layer's.
#[test]
fn the_crop_marks_of_a_layer_left_out_are_left_out() {
for template in [false, true] {
let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]);
let layer = d.layers[0].id;
if let Some(n) = d.node_mut(layer) {
n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
if let NodeKind::Layer { printable, template: t, .. } = &mut n.kind {
(*printable, *t) = (template, template);
}
}
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;", "template: {template}");
}
}
/// A guide's Crop Marks are left out with the guide, in the layers and in a symbol's art.
#[test]
fn the_crop_marks_of_a_guide_are_left_out() {
let guide = |d: &mut Document| {
let mut g = stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
g.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
g
};
let d = doc_with(|d| {
let g = guide(d);
vec![Node::group(d.alloc_id(), vec![Arc::new(g)])]
});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
let mut d = Document::new(200.0, 100.0);
let g = guide(&mut d);
let art = Node::group(d.alloc_id(), vec![Arc::new(g)]);
let d = symbol_doc(art, |_| {});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
}
/// Inline graphics in type are left out with a warning.
#[test]
fn inline_graphics_in_type_are_left_out_with_a_warning() {
let d = doc_with(|d| {
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = TextObject::point(Point::new(10.0, 60.0), "O", style.clone());
t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("mark"), style));
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == INLINE_GRAPHICS), "{:?}", out.warnings);
}
/// A gradient's pen is the average of its stops' colors.
#[test]
fn a_gradient_takes_the_average_of_its_stops() {
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
g.gradient.stops[0].color = Color::rgb8(255, 0, 0);
g.gradient.stops[1].color = Color::rgb8(0, 0, 255);
let d =
doc_with(|d| vec![Node::path(d.alloc_id(), rect(10.0, 10.0, 60.0, 40.0), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 1.0))]);
assert_eq!(plot(&d, &opts(&d)).pens, [[128, 0, 128]]);
}
/// An object or group at 0% opacity shows nothing, and a fill or stroke at 0% opacity gives a
/// path no color: here only the blue fill's outline is drawn.
#[test]
fn art_at_no_opacity_is_left_out() {
let (red, green, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 255, 0), Color::rgb8(0, 0, 255));
let d = doc_with(|d| {
let mut faded = stroked(d, rect(10.0, 10.0, 20.0, 20.0), red);
faded.opacity = 0.0;
let inner = stroked(d, line((30.0, 10.0), (40.0, 10.0)), green);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
group.opacity = 0.0;
let mut clear_fill = filled(d, rect(10.0, 30.0, 20.0, 40.0), green);
let mut fill_and_clear_stroke =
Node::path(d.alloc_id(), rect(50.0, 10.0, 60.0, 20.0), Appearance::basic(Paint::solid(blue), Paint::solid(red), 1.0));
for item in clear_fill.appearance.items.iter_mut().chain(fill_and_clear_stroke.appearance.items.iter_mut()) {
match item {
AppearanceItem::Fill(f) if f.paint == Paint::solid(green) => f.opacity = 0.0,
AppearanceItem::Stroke(s) => s.opacity = 0.0,
_ => {}
}
}
vec![faded, group, clear_fill, fill_and_clear_stroke]
});
let out = plot(&d, &opts(&d));
assert_eq!(out.pens, [[0, 0, 255]]);
assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out));
}
/// A pattern's pen is the color of its first visible solid fill: a hidden tile object, one at 0%
/// opacity and a fill at 0% opacity are skipped.
#[test]
fn a_pattern_takes_the_color_of_its_first_visible_fill() {
let mut d = doc_with(|_| vec![]);
let mut hidden = filled(&mut d, rect(0.0, 0.0, 5.0, 5.0), Color::rgb8(255, 0, 0));
hidden.visible = false;
let mut faded = filled(&mut d, rect(0.0, 5.0, 5.0, 10.0), Color::rgb8(0, 255, 0));
faded.opacity = 0.0;
let mut clear_fill = filled(&mut d, rect(5.0, 5.0, 10.0, 10.0), Color::rgb8(255, 255, 0));
for item in &mut clear_fill.appearance.items {
if let AppearanceItem::Fill(f) = item {
f.opacity = 0.0;
}
}
let shown = filled(&mut d, rect(5.0, 0.0, 10.0, 5.0), Color::rgb8(0, 0, 255));
let art = [hidden, faded, clear_fill, shown].map(Arc::new).to_vec();
d.patterns.push(vectorcraft_doc::PatternDef::new("tiles", art));
let path = Node::path(
d.alloc_id(),
rect(10.0, 10.0, 50.0, 50.0),
Appearance::basic(Paint::Pattern { pattern: "tiles".into(), xf: Default::default() }, Paint::None, 1.0),
);
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, path).unwrap();
assert_eq!(plot(&d, &opts(&d)).pens, [[0, 0, 255]]);
}
#[test]
fn art_is_cut_at_the_artboard_and_by_clipping_masks() {
let d = doc_with(|d| {
let across = stroked(d, line((-50.0, 50.0), (250.0, 50.0)), Color::BLACK);
let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::default());
let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![across, group]
});
let t = text(&plot(&d, &opts(&d)));
assert_eq!(t, "IN;PU;SP1;PU0,706;PD2822,706;PU282,988;PD564,988;PU;SP0;");
}
#[test]
fn type_is_drawn_as_its_outlines() {
let d = doc_with(|d| {
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Ol", style))))]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TEXT_OUTLINES), "{:?}", out.warnings);
// A pen-up move to each contour (the O's two and the l's one), and the two around the pen.
let t = text(&out);
assert!(t.matches("PU").count() >= 5, "{t}");
}
#[test]
fn an_empty_or_zero_length_plot_has_no_moves() {
let empty = Document::new(200.0, 100.0);
assert_eq!(text(&plot(&empty, &opts(&empty))), "IN;PU;SP0;");
let d =
doc_with(|d| vec![stroked(d, line((10.0, 10.0), (10.0, 10.0)), Color::BLACK), stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::BLACK)]);
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
let bad = HpglOptions { region: Rect::new(0.0, 0.0, 0.0, 10.0), ..HpglOptions::default() };
assert_eq!(export(&d, &bad).err().as_deref(), Some("the export region is empty"));
let nan = HpglOptions { region: Rect::new(0.0, 0.0, f64::NAN, 10.0), ..HpglOptions::default() };
assert!(export(&d, &nan).is_err());
}
/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed.
#[test]
fn bad_options_are_errors() {
let d = three();
for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] {
let o = HpglOptions { tolerance, ..opts(&d) };
assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}");
}
for tolerance in [0.01, 10.0] {
assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}");
}
let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) };
assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens"));
assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok());
}
#[test]
fn art_past_the_plotter_range_is_cut_off() {
let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() });
assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings);
assert_eq!(text(&out), format!("IN;PU;SP1;PU0,706;PD{MAX_COORD},706;PU;SP0;"));
// Art past the range but off the region asked for would be cut off anyway: no warning.
let d = doc_with(|d| vec![stroked(d, line((-1e9, 50.0), (10.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;");
}
/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none.
#[test]
fn hpgl_past_16_bit_coordinates_warns() {
let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0);
// Past 32,767 from the bottom-left corner, within the range from the center.
let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() });
assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]);
assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;");
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;");
// Past −32,768 and 32,767 from the center.
let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() });
assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]);
assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;");
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn a_plot_stops_at_its_point_budget() {
let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect());
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
assert!(text(&out).matches("PU").count() <= MAX_POINTS / 5 + 3);
}
/// The points a plain plot moves through.
fn moves(t: &str) -> usize {
t.split_terminator(';')
.filter(|i| i.starts_with("PU") || i.starts_with("PD"))
.map(|i| i[2..].split(',').filter(|v| !v.is_empty()).count() / 2)
.sum()
}
#[test]
fn one_path_longer_than_the_budget_is_cut_at_it() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((1.0, 1.0));
for i in 0..MAX_POINTS + 10 {
bp.line_to((if i % 2 == 0 { 2.0 } else { 1.0 }, 1.0 + (i % 50) as f64));
}
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
let n = moves(&text(&out));
assert!(n > 2 && n <= MAX_POINTS, "{n}");
}
/// A path whose flattening stops at the budget while its moves so far lie off the artboard: the
/// rest, over the artboard, is left out with a warning.
#[test]
fn a_path_cut_short_by_the_budget_warns() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((-50.0, 10.0));
for i in 0..MAX_POINTS * 4 + 10 {
bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0));
}
bp.line_to((50.0, 50.0));
bp.line_to((60.0, 50.0));
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
}
/// A filled path cut short by the budget is not closed: the move from where it stopped back to its
/// start is not on its outline. Only its first move, along y = 50 off the artboard's left edge,
/// is drawn.
#[test]
fn a_filled_path_cut_short_stays_open() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((10.0, 50.0));
bp.line_to((-40.0, 50.0));
for i in 0..MAX_POINTS * 4 + 10 {
bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0));
}
vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD0,706;PU;SP0;");
}
/// A blend's steps that its bent spine turns onto the region plotted are drawn, although its keys
/// and spine lie off it.
#[test]
fn blend_steps_a_spine_turns_onto_the_region_are_drawn() {
let d = doc_with(|d| {
let keys =
vec![stroked(d, line((-400.0, -200.0), (200.0, -200.0)), Color::BLACK), stroked(d, line((-560.0, -60.0), (40.0, -60.0)), Color::BLACK)];
let mut spine = BezPath::new();
spine.move_to((-100.0, -200.0));
spine.quad_to((-100.0, -60.0), (-260.0, -60.0));
let spec = vectorcraft_doc::BlendSpec {
spacing: vectorcraft_doc::BlendSpacing::Steps(20),
spine: Some(PathData::from_bezpath(&spine)),
orientation: vectorcraft_doc::BlendOrientation::AlignToPath,
..Default::default()
};
vec![Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })]
});
// The keys and spine lie above y = -55; the steps turned along the spine cross this area.
let region = Rect::new(-300.0, 0.0, -200.0, 50.0);
let t = text(&plot(&d, &HpglOptions { region, ..HpglOptions::default() }));
assert!(t.contains("PD"), "{t}");
}
/// A closed path that starts on the artboard, leaves it and comes back is one line, with no
/// pen lift where it started.
#[test]
fn a_closed_path_cut_at_the_artboard_is_one_line() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((150.0, 10.0));
bp.line_to((250.0, 10.0));
bp.line_to((250.0, 50.0));
bp.line_to((150.0, 50.0));
bp.close_path();
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let t = text(&plot(&d, &opts(&d)));
assert_eq!(t, "IN;PU;SP1;PU2822,706;PD2117,706,2117,1270,2822,1270;PU;SP0;");
}
/// A clipping path that paints is drawn once: where its fill paints (before the art it clips),
/// or where its stroke paints (after it).
#[test]
fn a_clipping_path_is_drawn_where_it_paints() {
let clip_doc = |fill: Paint, stroke: Paint| {
doc_with(|d| {
let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(fill, stroke, 1.0));
let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
})
};
let red = Paint::solid(Color::rgb8(255, 0, 0));
let filled = clip_doc(red.clone(), Paint::None);
assert_eq!(plot(&filled, &opts(&filled)).pens, [[255, 0, 0], [0, 0, 0]]);
let stroked = clip_doc(Paint::None, red.clone());
assert_eq!(plot(&stroked, &opts(&stroked)).pens, [[0, 0, 0], [255, 0, 0]]);
let both = clip_doc(Paint::solid(Color::rgb8(0, 0, 255)), red);
let out = plot(&both, &opts(&both));
assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]], "drawn once, in its stroke's color");
// Two pen changes, a move to each of the two paths, and the last pen up.
assert_eq!(text(&out).matches("PU").count(), 5, "{}", text(&out));
// A stroke at 0% opacity isn't drawn: the clipping path is drawn in its fill, below.
let faded = doc_with(|d| {
let mut clip = Node::path(
d.alloc_id(),
rect(20.0, 20.0, 40.0, 40.0),
Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0),
);
for item in &mut clip.appearance.items {
if let AppearanceItem::Stroke(s) = item {
s.opacity = 0.0;
}
}
let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]);
// An open clipping path with a fill and a stroke is drawn closed, as other filled paths are.
let mut open = BezPath::new();
open.move_to((20.0, 20.0));
open.line_to((40.0, 20.0));
open.line_to((40.0, 40.0));
let open = PathData::from_bezpath(&open);
let open_doc = doc_with(|d| {
let clip = Node::path(d.alloc_id(), open, Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0));
let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
let t = text(&plot(&open_doc, &opts(&open_doc)));
assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}");
}
/// A symbol `art` and an instance of it at its place.
fn symbol_doc(art: Node, instance: impl FnOnce(&mut Node)) -> Document {
let mut d = doc_with(|d| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
instance(&mut n);
vec![n]
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art) });
d
}
/// Symbols nested more than 8 deep are left out with a warning.
#[test]
fn symbols_nested_too_deep_are_left_out_with_a_warning() {
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s9".into(), xf: Affine::IDENTITY })]);
let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s0".into(), art: Arc::new(art) });
for i in 1..10 {
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("s{}", i - 1), xf: Affine::IDENTITY });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("s{i}"), art: Arc::new(inner) });
}
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_DEEP), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP0;");
}
/// A symbol instance stained by its fill draws in the stained color.
#[test]
fn a_stained_symbol_instance_takes_its_stain() {
let mut d = Document::new(200.0, 100.0);
let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
let d = symbol_doc(art, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 1.0).into());
assert_eq!(plot(&d, &opts(&d)).pens, [[255, 0, 0]]);
}
/// The geometry effects in a symbol's art apply: a line moved 30 pt right by a Transform effect.
#[test]
fn a_symbol_draws_its_art_with_its_effects() {
let mut d = Document::new(200.0, 100.0);
let mut art = stroked(&mut d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK);
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 30.0}),
visible: true,
});
let d = symbol_doc(art, |_| {});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP1;PU564,1270;PD706,1270;PU;SP0;");
}
/// Art an effect moves onto the artboard from off it is drawn, for an instance on its own and
/// one in a group: the bounds that skip art off the artboard don't measure symbol effects.
#[test]
fn symbol_art_an_effect_moves_onto_the_artboard_is_drawn() {
let mut d = Document::new(200.0, 100.0);
let mut art = stroked(&mut d, line((-30.0, 10.0), (-20.0, 10.0)), Color::BLACK);
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 40.0}),
visible: true,
});
let alone = symbol_doc(art.clone(), |_| {});
assert_eq!(text(&plot(&alone, &opts(&alone))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
let mut grouped = doc_with(|d| {
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
vec![Node::group(d.alloc_id(), vec![Arc::new(n)])]
});
grouped.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art.clone()) });
assert_eq!(text(&plot(&grouped, &opts(&grouped))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
// Other art off the artboard is still skipped: a blend there doesn't spend the live art's
// limit (1,000 objects in tests) before the blend on the artboard.
let mut d = doc_with(|d| {
let off =
vec![stroked(d, line((-500.0, 10.0), (-500.0, 20.0)), Color::BLACK), stroked(d, line((-400.0, 10.0), (-400.0, 20.0)), Color::BLACK)];
let on = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((30.0, 10.0), (30.0, 20.0)), Color::BLACK)];
vec![blend_of(d, off, 1_200), blend_of(d, on, 1)]
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "unused".into(), art: Arc::new(art) });
let out = plot(&d, &opts(&d));
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 3, "{}", text(&out));
}
/// A rectangle with `per_side` straight moves along each side.
fn fine_rect(x0: f64, y0: f64, x1: f64, y1: f64, per_side: u32) -> PathData {
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
let mut bp = BezPath::new();
bp.move_to(corners[0]);
for (k, a) in corners.iter().enumerate() {
let b = corners[(k + 1) % 4];
for i in 1..=per_side {
let t = f64::from(i) / f64::from(per_side);
bp.line_to((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));
}
}
bp.close_path();
PathData::from_bezpath(&bp)
}
/// Nested clipping masks of 6,000 points each pass the 10,000 points masks may hold together
/// in tests: the inner one is left out with a warning, and the line is cut by the outer one only.
#[test]
fn masks_past_their_point_budget_are_left_out() {
let d = doc_with(|d| {
let clip_group = |d: &mut Document, mask: PathData, art: Node| {
let mask = Node::path(d.alloc_id(), mask, Appearance::basic(Paint::None, Paint::None, 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
group
};
let across = Node::path(d.alloc_id(), line((0.0, 50.0), (200.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let inner = clip_group(d, fine_rect(50.0, 30.0, 150.0, 70.0, 1500), across);
vec![clip_group(d, fine_rect(10.0, 10.0, 190.0, 90.0, 1500), inner)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == MASK_POINTS), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD2681,706;PU;SP0;");
}
/// A line through the 500 teeth of a comb-shaped mask crosses its edges about 1,000 times, and
/// each piece between crossings is tested against all 2,002 edges: that spends the clipping
/// budget, so the line is drawn whole with a warning.
#[test]
fn a_mask_crossed_many_times_spends_the_clipping_budget() {
let d = doc_with(|d| {
let mut pts = vec![];
for i in 0..500 {
let x = f64::from(i) * 0.4;
pts.extend([(x, 20.0), (x + 0.2, 20.0), (x + 0.2, 50.0), (x + 0.4, 50.0)]);
}
pts.extend([(200.0, 80.0), (0.0, 80.0)]);
let mut bp = BezPath::new();
bp.move_to(pts[0]);
pts[1..].iter().for_each(|&p| bp.line_to(p));
bp.close_path();
let comb = Node::path(d.alloc_id(), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::None, 1.0));
let clipped = stroked(d, line((0.5, 35.0), (199.5, 35.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(comb), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == CLIP_WORK), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out));
}
#[test]
fn more_colors_than_pens_share_the_nearest_pen() {
let d = doc_with(|d| (0..300).map(|i| stroked(d, rect(1.0, 1.0, 5.0, 5.0), Color::rgb8((i % 256) as u8, (i / 256) as u8, 7))).collect());
let out = plot(&d, &opts(&d));
assert_eq!(out.pens.len(), MAX_PENS);
assert!(out.warnings.iter().any(|w| w == MANY_COLORS), "{:?}", out.warnings);
}
#[test]
fn sorting_draws_the_nearest_path_next() {
let d = doc_with(|d| {
vec![
stroked(d, line((150.0, 90.0), (150.0, 80.0)), Color::BLACK),
stroked(d, line((10.0, 80.0), (10.0, 90.0)), Color::BLACK),
stroked(d, line((80.0, 90.0), (80.0, 80.0)), Color::BLACK),
]
});
let unsorted = text(&plot(&d, &opts(&d)));
assert_eq!(unsorted, "IN;PU;SP1;PU2117,141;PD2117,282;PU141,282;PD141,141;PU1129,141;PD1129,282;PU;SP0;");
// From the origin: the left line drawn backwards from its lower end, then the middle and the
// right one from the end the pen is nearest.
let sorted = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) }));
assert_eq!(sorted, "IN;PU;SP1;PU141,141;PD141,282;PU1129,282;PD1129,141;PU2117,141;PD2117,282;PU;SP0;");
}
/// A closed path is as near as its nearest point, and starts there.
#[test]
fn sorting_measures_a_closed_path_by_its_nearest_point() {
let d = doc_with(|d| vec![stroked(d, rect(40.0, 60.0, 50.0, 70.0), Color::BLACK), stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK)]);
let t = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) }));
// The large square's bottom-left corner is nearer the origin than any point of the small one.
assert_eq!(t, "IN;PU;SP1;PU141,141;PD141,1270,2681,1270,2681,141,141,141;PU564,423;PD564,564,706,564,706,423,564,423;PU;SP0;");
}
#[test]
fn sorting_stops_at_its_budget_and_keeps_every_path() {
let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect());
let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) });
assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 600);
}
#[test]
fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() {
let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() });
assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP0;");
}
/// The reference guide's example of an encoded number: 10,525 is written as the bytes 121, 71
/// and 196 (https://support.hp.com/sg-en/document/c01121263).
#[test]
fn encoded_numbers_follow_the_reference_guide() {
let mut out = vec![];
pe_number(&mut out, 10_525);
assert_eq!(out, [121, 71, 196]);
out.clear();
pe_number(&mut out, -1);
pe_number(&mut out, 0);
// The sign in the low bit: -1 is 3.
assert_eq!(out, [194, 191]);
}
#[test]
fn hpgl2_writes_the_palette_and_encoded_polylines() {
let out = plot(&three(), &HpglOptions { version: Version::Hpgl2, ..opts(&three()) });
let b = &out.bytes;
assert!(b.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;PC3,0,0,255;PU;SP1;PE<="), "{:?}", String::from_utf8_lossy(b));
assert!(b.ends_with(b";PU;SP0;"));
assert_eq!(b.windows(4).filter(|w| w == b";PE<").count(), 3, "one encoded polyline per pen");
// Encoded moves take fewer bytes than the same moves written as text.
let plain = plot(&three(), &opts(&three()));
assert!(b.len() < plain.bytes.len(), "{} against {}", b.len(), plain.bytes.len());
}
/// A move longer than 8,388,607 units is written absolute (`=`), so every number stays in the
/// range: here from about −5,000,000 to +5,000,000 units around a centered origin.
#[test]
fn hpgl2_writes_a_move_past_the_range_absolute() {
let d = doc_with(|d| {
vec![
stroked(d, line((-354_000.0, 50.0), (-353_990.0, 50.0)), Color::BLACK),
stroked(d, line((353_990.0, 50.0), (354_000.0, 50.0)), Color::BLACK),
]
});
let o = HpglOptions {
version: Version::Hpgl2,
origin: Origin::Center,
region: Rect::new(-400_000.0, 0.0, 400_000.0, 100.0),
..HpglOptions::default()
};
let out = plot(&d, &o);
assert_eq!(out.bytes.iter().filter(|b| **b == b'=').count(), 2, "{:?}", String::from_utf8_lossy(&out.bytes));
let back = super::super::import(&out.bytes).unwrap();
let mut xs = vec![];
back.document.walk(|n| {
if let Some(p) = n.path_data() {
xs.extend(p.to_bezpath().elements().iter().filter_map(|e| if let PathEl::MoveTo(p) = e { Some(p.x) } else { None }));
}
});
assert_eq!(xs.len(), 2);
assert!((xs[1] - xs[0] - 707_990.0).abs() < 0.2, "{xs:?}");
}
/// The points of every path `doc` holds, in paint order.
fn polylines(doc: &Document) -> Vec<(Vec<Point>, Option<Paint>)> {
let mut out = vec![];
doc.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
let bp = path.to_bezpath();
let pts = bp.elements().iter().filter_map(|e| match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => Some(*p),
_ => None,
});
let stroke = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.paint.clone()) } else { None });
out.push((pts.collect(), stroke));
}
});
out
}
/// Each version's plot read back: the same lines, within the tolerance and half a plotter unit,
/// and with HP-GL/2 the same colors.
#[test]
fn plots_read_back_as_the_same_lines() {
let d = three();
for version in Version::ALL {
let out = plot(&d, &HpglOptions { version, ..opts(&d) });
let back = super::super::import(&out.bytes).unwrap();
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
let lines = polylines(&back.document);
assert_eq!(lines.len(), 3, "{version:?}");
// The reader's artboard is round what the art paints: line the two up by the square.
let shift = lines[0].0[0] - Point::new(10.0, 10.0);
// Two roundings to whole plotter units (1/1016 in) apart at most.
let near = |p: Point, q: Point| (p - shift).distance(q) <= 0.11;
let square = [(10.0, 10.0), (60.0, 10.0), (60.0, 40.0), (10.0, 40.0), (10.0, 10.0)].map(Point::from);
assert!(lines[0].0.iter().zip(square).all(|(p, q)| near(*p, q)) && lines[0].0.len() == 5, "{:?}", lines[0].0);
assert!(near(lines[1].0[0], Point::new(70.0, 20.0)) && near(lines[1].0[1], Point::new(150.0, 80.0)), "{:?}", lines[1].0);
// The ellipse's every point lies on it, within the tolerance and the rounding.
let ellipse = kurbo::Ellipse::new((100.0, 50.0), (30.0, 20.0), 0.0);
for p in &lines[2].0 {
let q = p.to_vec2() - shift - kurbo::Vec2::new(100.0, 50.0);
let r = (q.x / 30.0).hypot(q.y / 20.0);
assert!((r - 1.0).abs() * 20.0 <= DEFAULT_TOLERANCE + 0.1, "{p:?} off the ellipse");
}
assert!(lines[2].0.len() > 16 && ellipse.area() > 0.0);
if version == Version::Hpgl2 {
let colors: Vec<Option<Paint>> = lines.iter().map(|l| l.1.clone()).collect();
assert_eq!(
colors,
[Paint::solid(Color::rgb8(0, 0, 0)), Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::rgb8(0, 0, 255))].map(Some)
);
}
}
}
+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);
+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,
@@ -413,6 +419,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,
+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,
+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}");
}
}
+3
View File
@@ -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
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",
+119
View File
@@ -0,0 +1,119 @@
//! 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",
},
];
/// 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)?;
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)
}
+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"
]
+111 -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,111 @@ 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"] {
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"),
];
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;");
}
/// 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}");
}
@@ -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
+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();
File diff suppressed because one or more lines are too long
+9 -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)),
@@ -530,6 +532,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
@@ -1338,7 +1338,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;
@@ -1358,6 +1359,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");
+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(())
+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
@@ -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"}));
+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();
+62
View File
@@ -2571,6 +2571,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"})),
+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() {
+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,
+26 -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
};
+1 -1
View File
@@ -54,7 +54,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled};
pub use shape::spacing_scale;
pub use shape::{line_start_space, spacing_scale};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
+56 -13
View File
@@ -39,7 +39,9 @@ pub(crate) struct SGlyph {
pub dy: f64,
/// Right to left: the run's manual kerning, which moves the glyph right along the baseline,
/// outside its Character Rotation; with whole-point advances, the difference the kerning makes
/// to the cluster's rounded advance. 0 otherwise.
/// to the cluster's rounded advance. Left to right and in vertical type: the space before a
/// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the
/// same way. 0 otherwise.
pub kern_shift: f64,
/// Outline scale (font units -> points).
pub sx: f64,
@@ -522,6 +524,37 @@ pub(crate) fn shape_runs(
}
}
/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of
/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its
/// cluster takes the space in its advance, as manual kerning takes the space after a character,
/// and left to right (and in vertical type) its glyphs are drawn that much later on the line
/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on
/// that side.
fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 {
if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}']))
{
return 0.0;
}
Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0)
}
/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets
/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it
/// starts a line by itself, else 0. For writers that place a line's first character themselves.
pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 {
let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase);
space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical))
}
/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small
/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size.
fn glyph_size(st: &CharStyle, small: bool) -> f64 {
let full = st.size.max(0.0);
let (pos_scale, _) = st.position.scale_shift(full);
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
full * pos_scale * small_scale
}
/// Line segments of a glyph outline in font units, cached for the current shaping pass.
type Profile = Vec<(f64, f64, f64, f64)>;
type CachedProfile = (Profile, (f64, f64));
@@ -817,8 +850,11 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that
/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the
/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there.
/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space
/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it
/// from its first character, so it doesn't split a ligature either.
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() };
if a == b {
return true;
}
@@ -836,9 +872,10 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
/// run's, see [`shape_segment`]), the OpenType features (each run's apply to its own characters,
/// as features over a range of the one buffer) and the tracking, as long as the tracking keeps or
/// drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]),
/// the OpenType features (each run's apply to its own characters, as features over a range of the
/// one buffer) and the tracking, as long as the tracking keeps or drops standard ligatures alike
/// ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
let CharStyle {
@@ -850,6 +887,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
leading: _,
tracking,
kerning: _,
kerning_before: _,
optical_kerning: _,
baseline_shift: _,
h_scale,
@@ -950,8 +988,9 @@ fn shaping_script(c: char) -> Script {
///
/// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its
/// vertical centre on the middle of the cap height raised by the inline's shift (see
/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual
/// kerning, which right to left in horizontal type also moves the art right by the value, as it
/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual
/// kerning and the space before it at a line's start ([`space_before`]). Right to left in
/// horizontal type the manual kerning also moves the art right by the value, as it
/// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the
/// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the
/// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same
@@ -985,7 +1024,8 @@ fn inline_glyph(
let spacing_em = size * spacing_scale(st, vertical);
let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0);
let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0);
let adv = width + tracking + manual_kern;
let before = space_before(text, range.start, st, spacing_em);
let adv = width + tracking + manual_kern + before;
SGlyph {
face: face.clone(),
gid: 0,
@@ -996,7 +1036,7 @@ fn inline_glyph(
tracking: if adv.is_finite() { tracking } else { 0.0 },
dx: 0.0,
dy: 0.0,
kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 },
kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before },
// Not used to draw (the art has its own transform), but they make the glyph's em box
// its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a
// character of its run's size, whatever its scale (see `layout::glyph_em`).
@@ -1098,9 +1138,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins);
let full = st.size.max(0.0);
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
let (pos_scale, pos_shift) = st.position.scale_shift(full);
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
let size = full * pos_scale * small_scale;
let (_, pos_shift) = st.position.scale_shift(full);
let size = glyph_size(st, small);
let k = size / face.upem;
let km = full / face.upem;
let hs = st.h_scale / 100.0;
@@ -1448,15 +1487,19 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
adv = 0.0;
} else {
let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0);
let before = space_before(text, cl, gst, spacing_em);
// Manual kerning spaces a character from the next one in reading order. Right to left,
// that one is drawn to its left: the cluster's glyphs move right along the baseline by
// the value, so that the space its last glyph's advance gains is at the cluster's left.
// The space before a line's first character moves them the other way.
if level.is_rtl() && !feats.vertical {
kern_shift = manual_kern;
} else {
kern_shift = before;
}
if last_in_cluster {
tracked = gst.tracking / 1000.0 * spacing_em;
adv += tracked + manual_kern;
adv += tracked + manual_kern + before;
}
}
out.push(SGlyph {
+1
View File
@@ -20,6 +20,7 @@ mod inline;
mod joiners;
mod justify;
mod kept_layouts;
mod kerning_before;
mod ligature_carets;
mod path_joins;
mod ruby;
+32
View File
@@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
b.stroke_dash = a.stroke_dash.clone();
b.stroke_align = a.stroke_align;
b.style_name = a.style_name.clone();
b.kerning_before = a.kerning_before;
*a == b
}
@@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
field!(leading, Some(60.0), false);
field!(tracking, 20.0, false);
field!(kerning, Some(0.0), false);
field!(kerning_before, 250.0, true);
field!(optical_kerning, true, false);
field!(baseline_shift, 1.0, false);
field!(h_scale, 90.0, false);
@@ -312,6 +314,36 @@ fn harakat_ranges_normalise_their_range() {
/// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone
/// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character;
/// everything else stays at the cluster start (conservative).
/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret
/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to
/// left the space is past the letter's right side, so every glyph stays where it was and the line
/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in
/// every environment, and with an installed Arabic face when there is one.
#[test]
fn a_space_before_the_first_letter_keeps_its_coloured_harakat() {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1);
check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig"));
if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") {
check_spaced_harakat(FontDb::global(), &face.family);
}
}
fn check_spaced_harakat(db: &FontDb, family: &str) {
const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8
let coloured = text_with_harakat(WORD, family, Some(red()));
let mut spaced = coloured.clone();
crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0);
let (a, b) = (layout(db, &coloured), layout(db, &spaced));
let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::<Vec<_>>();
assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were");
assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart");
assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour");
// 250/1000 of the 48 pt em.
let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b));
}
#[test]
fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() {
// بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and
+136
View File
@@ -0,0 +1,136 @@
//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111):
//! the first character of the text, of a paragraph or after a forced line break moves along its line
//! by the value, the rest of the line follows, and the value does nothing anywhere else.
use crate::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, Justify};
fn db() -> &'static FontDb {
FontDb::global()
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
/// Where glyph `byte` is drawn along a horizontal line.
fn drawn_x(l: &TextLayout, byte: usize) -> f64 {
l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap()
}
/// `t` with `kerning_before` set on the character at `byte`.
fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject {
let mut t = t.clone();
let end = edit::next_char(&t.plain_text(), byte);
edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value);
t
}
/// How far each of `bytes` moved along the line from `a` to `b`.
fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec<f64> {
let (la, lb) = (layout(db(), a), layout(db(), b));
bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect()
}
fn near(values: &[f64], expected: &[f64]) -> bool {
values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6)
}
#[test]
fn the_first_character_moves_along_its_line_and_the_rest_follow() {
let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
// 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back.
let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]);
assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}");
let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]);
assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}");
// The line is that much longer, so a centred line moves its first character half as far.
let mut centred = plain.clone();
centred.para.justify = Justify::Center;
let half = moves(&centred, &spaced(&centred, 0, 250.0), &[0, 2]);
assert!(near(&half, &[12.5, 12.5]), "{half:?}");
}
#[test]
fn it_does_nothing_on_a_character_inside_a_line() {
let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]);
assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}");
// Nor on the first character of a line that wrapped in area type: the line break moves.
let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left);
let wrapped = layout(db(), &area);
let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line;
assert_eq!(second_line, 1, "the second HOH wraps");
let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]);
assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}");
}
#[test]
fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() {
let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0));
let moved = moves(&paragraphs, &spaced(&paragraphs, 3, 250.0), &[0, 1, 3, 4]);
assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}");
let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0));
let after = "HO\u{2028}".len();
let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]);
assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
// On a line break it stays: an empty line has nothing to move.
let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0));
let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]);
assert!(near(&moved, &[0.0, 0.0]), "{moved:?}");
}
#[test]
fn it_takes_the_em_of_the_character_as_manual_kerning_does() {
// Horizontal Scale 50%: half the space, as kerning after a character takes it.
let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) });
let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]);
assert!(near(&moved, &[12.5, 12.5]), "{moved:?}");
}
#[test]
fn right_to_left_the_space_is_at_the_right_of_the_first_character() {
// The first character in reading order is drawn rightmost; the space before it is on its right,
// so the two keep their distance, and the line is 25 pt longer.
let plain = TextObject::point(Point::ZERO, "אב", style(100.0));
let kerned = spaced(&plain, 0, 250.0);
let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left");
let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2);
assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b));
let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b));
}
#[test]
fn in_vertical_type_the_first_character_moves_down_its_column() {
// An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt)
// down the column, and stay in it.
for text in ["§§", "ab"] {
let mut plain = TextObject::point(Point::ZERO, text, style(20.0));
plain.xf = Affine::IDENTITY;
plain.vertical = true;
let kerned = spaced(&plain, 0, 250.0);
let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
for i in 0..2 {
let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box());
assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
}
}
}
#[test]
fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() {
// Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one
// glyph, and moves.
let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) };
let plain = TextObject::point(Point::ZERO, "fit", serif);
let glyphs = |t: &TextObject| layout(db(), t).glyphs.len();
assert_eq!(glyphs(&plain), 2, "the font joins fi");
let kerned = spaced(&plain, 0, 250.0);
assert_eq!(glyphs(&kerned), 2, "still joined");
let moved = moves(&plain, &kerned, &[0, 2]);
assert!(near(&moved, &[25.0, 25.0]), "{moved:?}");
assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9);
}
+2
View File
@@ -5,6 +5,7 @@ pub mod cleanup;
pub mod color;
pub mod compounds;
pub mod contour;
pub mod cutting;
pub mod document;
pub mod enclosing_lasso;
pub mod image;
@@ -25,6 +26,7 @@ pub fn plugin_menu() -> Vec<Item> {
Item::Sub("Color Effects", color::menu()),
Item::Sub("Compound Shapes", compounds::menu()),
Item::Sub("Contour", contour::menu()),
Item::Sub("Cutting Tools", cutting::menu()),
Item::Sub("Document Tools", document::menu()),
Item::Sub("Enclosing Lasso", enclosing_lasso::menu()),
Item::Sub("Image Tools", image::menu()),
+20
View File
@@ -0,0 +1,20 @@
//! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil
//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options
//! of `addon.cutting.bridges`.
use serde_json::json;
use crate::menus::Item;
/// Stencil Bridges' command.
pub const BRIDGES: &str = "addon.cutting.bridges";
/// Stencil Bridges › Direction: what the command's `direction` takes, by name.
pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")];
/// Object › Plug-ins › Cutting Tools.
pub fn menu() -> Vec<Item> {
vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))]
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,54 @@
use serde_json::json;
use vectorcraft_engine::Session;
use crate::tests_labels::painted_text;
use crate::{VectorcraftApp, menus};
/// Object › Plug-ins › Cutting Tools › Stencil Bridges… opens the command dialog on a 2 pt width, one
/// bridge per island, vertical bridges and Keep Original on; the direction is a list of its three
/// ways; OK cuts the bridges as one undo step, and a message shows when there are no islands.
#[test]
fn stencil_bridges_opens_its_dialog_and_runs_on_ok() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let outer = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
let inner = app.run("shape.rectangle", json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [outer, inner]})).unwrap();
let o = app.run("object.pathfinder.minusFront", json!({})).unwrap()["ids"][0].clone();
app.run("select.set", json!({"ids": [o]})).unwrap();
let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let n = steps(&app);
let open = |app: &mut VectorcraftApp| {
let entries = menus::menu_entries(app);
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Stencil Bridges…").expect("listed");
assert!(item.enabled);
let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
menus::invoke(app, &cmd, p);
};
open(&mut app);
let d = app.ui.dialog.as_ref().expect("a dialog opens");
assert_eq!((d.str("__command"), d.str("__label")), (super::BRIDGES.to_string(), "Stencil Bridges".to_string()));
assert_eq!((d.f64("width", 0.0), d.f64("count", 0.0), d.str("direction"), d.bool("keepOriginal")), (2.0, 1.0, "vertical".to_string(), true));
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for s in ["Stencil Bridges", "Width:", "Count:", "Direction:", "Vertical", "Keep Original"] {
assert!(shown.contains(s), "{s} in {shown}");
}
// Each way the list offers is one the command takes.
for (_, way) in super::BRIDGE_DIRECTIONS {
assert!(app.session.execute(super::BRIDGES, &json!({"direction": way, "width": 1000})).is_ok(), "{way}");
}
assert_eq!(steps(&app), n, "no bridge fits that wide: nothing changed");
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(steps(&app), n + 1, "one undo step");
let layers = &app.session.doc().unwrap().doc.layers;
assert!(layers.iter().any(|l| l.name.as_deref() == Some("Stencil Original") && !l.visible), "the original is on a hidden layer");
// Run again on the result, which has no islands left: a message, and nothing changes.
open(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(steps(&app), n + 1);
assert!(app.ui.status.contains("no islands"), "{}", app.ui.status);
}
+3
View File
@@ -878,6 +878,9 @@ mod tests_eps;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_hpgl;
#[cfg(test)]
mod tests_tiff_bmp_tga;
+3
View File
@@ -106,6 +106,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
"text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"],
"type.pathOptions" => &["spacing"],
"object.pathfinder.trap" => &["thickness"],
crate::addons::cutting::BRIDGES => &["width"],
_ => &[],
}
}
@@ -119,6 +120,7 @@ fn order(command: &str) -> &'static [&'static str] {
"addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"],
crate::addons::typetools::RELEASE => &["mode", "keepOriginal"],
crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"],
crate::addons::cutting::BRIDGES => &["width", "count", "direction", "keepOriginal"],
_ => &[],
}
}
@@ -129,6 +131,7 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
(crate::addons::typetools::RELEASE, "mode") => Some(crate::addons::typetools::RELEASE_MODES),
(crate::addons::cutting::BRIDGES, "direction") => Some(crate::addons::cutting::BRIDGE_DIRECTIONS),
("text.areaOptions", "fit") => Some(AREA_FIT),
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
+28
View File
@@ -0,0 +1,28 @@
//! Export As → HP-GL, drawn headlessly: listed with the other formats, written straight away (no
//! options dialog), one plot per artboard with Use Artboards.
use serde_json::json;
use super::tests_export::{app, frame, kind, names, set};
use super::*;
#[test]
fn export_as_lists_hpgl_and_writes_plots() {
let (mut app, written) = app(2);
app.run("file.exportAs", json!({"format": "hpgl"})).unwrap();
assert_eq!(kind(&app), Some("exportAs"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "hpgl");
frame(&mut app);
let r = confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "no options dialog follows");
assert_eq!(r["path"], "/out/Untitled-1.plt");
assert!(written.borrow()[0].1.starts_with(b"IN;"));
assert!(written.borrow()[0].1.ends_with(b"PU;SP0;"));
// One plot per artboard.
app.run("file.exportAs", json!({"format": "hpgl"})).unwrap();
set(&mut app, "useArtboards", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let n = names(&written);
assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.plt", "/out/Untitled-1-Artboard-2.plt"]);
}
+20
View File
@@ -2304,6 +2304,7 @@ font-technology Variable Променливи
Options: Опции:
Options… Опции…
Organic Fades Органични преливания
Holographic Холографски
or more spaces with a tab или повече интервала с табулация
Order: Ред:
Ordinals Поредни номера
@@ -4420,6 +4421,7 @@ axis Both И двете
@msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0}
@msg not an EMF or WMF file не е EMF или WMF файл
@msg not an HP-GL file не е HP-GL файл
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` трябва да бъде масив от 1 до 255 цвята във формат #rrggbb
@msg not found не е открито
@msg not in pattern editing mode не е в режим на редактиране на мостра
@msg finish editing the pattern first първо завършете редактирането на мострата
@@ -6065,6 +6067,17 @@ axis Y Axis Y ос
Charge Wave Вълна на заряд
Spark Trail Следа от искри
Calligraphic Nibs Калиграфски писци
Bristle Brushes Четки с косъм
Fine Liner Тънък контур
Pointed Flat Заострена плоска
Stubby Round Къса кръгла
Dry Scrub Сухо търкане
Soft Dome Мек купол
Filbert Плоска овална
Angled Round Скосена кръгла
Angled Flat Скосена плоска
Fan Blender Ветрило за смесване
Grass Fan Ветрило за трева
Pattern Borders Бордюри с мостри
Scatter Shapes Разсеяни фигури
Save Brush Library… Запази библиотека с четки…
@@ -6665,3 +6678,10 @@ breakText Glyphs Глифи
# Deferred plug-in effects
Refresh Deferred Effects Обновяване на отложените ефекти
Deferred Effects Отложени ефекти
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Инструменти за рязане
Stencil Bridges… Мостчета на шаблона…
Stencil Bridges Мостчета на шаблона
Shortest Crossing Най-късо пресичане
Direction Посока
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -842,6 +842,7 @@
Sky Gradients Gradijenti neba
Neutral Gradients Neutralni gradijenti
Organic Fades Organski prelazi
Holographic Holografski
Duotone Gradients Duoton gradijenti
Radial Glows Radijalni sjajevi
Save Swatch Library… Sačuvaj biblioteku uzoraka boja…
@@ -4811,6 +4812,7 @@ imageTrace Outline Obris
@msg not a valid plug-in module: {0} nije važeći modul dodatka: {0}
@msg not an EMF or WMF file nije EMF ni WMF datoteka
@msg not an HP-GL file nije HP-GL datoteka
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` mora biti niz od 1 do 255 boja #rrggbb
@msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`)
@msg not an export presets file: {e} nije datoteka predložaka izvoza: {e}
@msg not found nije pronađeno
@@ -6116,6 +6118,17 @@ axis Y Axis Y osa
Charge Wave Talas naboja
Spark Trail Trag iskri
Calligraphic Nibs Kaligrafski vrhovi
Bristle Brushes Četkice s čekinjama
Fine Liner Tanka linija
Pointed Flat Šiljata ravna
Stubby Round Kratka okrugla
Dry Scrub Suho trljanje
Soft Dome Meka kupola
Filbert Ovalna ravna
Angled Round Kosa okrugla
Angled Flat Kosa ravna
Fan Blender Lepeza za stapanje
Grass Fan Lepeza za travu
Pattern Borders Ivice od uzoraka
Scatter Shapes Raspršeni oblici
Save Brush Library… Sačuvaj biblioteku četkica…
@@ -6717,3 +6730,10 @@ shape Open Otvoren
# Deferred plug-in effects
Refresh Deferred Effects Osvježi odgođene efekte
Deferred Effects Odgođeni efekti
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Alati za rezanje
Stencil Bridges… Mostići šablona…
Stencil Bridges Mostići šablona
Shortest Crossing Najkraći prijelaz
Direction Smjer
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -608,6 +608,17 @@ antiAlias Sharp Nítid
Calligraphic Brush Pinzell cal·ligràfic
Calligraphic Brush Options Opcions del pinzell cal·ligràfic
Calligraphic Nibs Plomes cal·ligràfiques
Bristle Brushes Pinzells de cerres
Fine Liner Traç fi
Pointed Flat Pla punxegut
Stubby Round Rodó curt
Dry Scrub Fregat sec
Soft Dome Cúpula suau
Filbert Llengua de gat
Angled Round Rodó bisellat
Angled Flat Pla bisellat
Fan Blender Ventall difuminador
Grass Fan Ventall d'herba
Can't export “{name}”. No es pot exportar “{name}”.
Can't open “{name}”. No es pot obrir “{name}”.
Can't print these settings: see the Summary No es poden imprimir aquests paràmetres: consulta el resum
@@ -2940,6 +2951,7 @@ digits Malayalam Malaiàlam
Options: Opcions:
Options… Opcions…
Organic Fades Esvaïments orgànics
Holographic Hologràfics
or more spaces with a tab o més espais amb un tabulador
Orange Taronja
Order: Ordre:
@@ -5664,6 +5676,7 @@ digits Malayalam Malaiàlam
@msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`)
@msg not an EMF or WMF file no és un fitxer EMF o WMF
@msg not an HP-GL file no és un fitxer HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb
@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf
@msg not found no s’ha trobat
@msg not in pattern editing mode no esteu en mode d’edició de motius
@@ -6955,3 +6968,15 @@ breakText Glyphs Glifs
Deferred Effects Efectes ajornats
@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Eines de tall
Stencil Bridges… Ponts de plantilla…
Stencil Bridges Ponts de plantilla
Shortest Crossing Travessia més curta
Direction Direcció
@msg The selected art has no islands to bridge La il·lustració seleccionada no té illes per unir
@msg No bridge of this width fits across the selected art Cap pont d’aquesta amplada no cap a la il·lustració seleccionada
@msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració
@msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -851,6 +851,7 @@
Sky Gradients Přechody oblohy
Neutral Gradients Neutrální přechody
Organic Fades Organické přechody
Holographic Holografické
Duotone Gradients Dvoutónové přechody
Radial Glows Kruhové záře
Save Swatch Library… Uložit knihovnu vzorníku…
@@ -4897,6 +4898,7 @@ imageTrace Outline Obrys
@msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0}
@msg not an EMF or WMF file nejde o soubor EMF ani WMF
@msg not an HP-GL file nejde o soubor HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb
@msg not found nenalezeno
@msg not in pattern editing mode není aktivní režim úprav vzorku
@msg finish editing the pattern first nejprve dokončete úpravy vzorku
@@ -6324,6 +6326,17 @@ axis Y Axis osa Y
Charge Wave Nabíjecí vlna
Spark Trail Jiskrová stopa
Calligraphic Nibs Kaligrafická pera
Bristle Brushes Štětinové štětce
Fine Liner Jemná linka
Pointed Flat Špičatý plochý
Stubby Round Krátký kulatý
Dry Scrub Suché drhnutí
Soft Dome Měkká kopule
Filbert Plochý oválný
Angled Round Šikmý kulatý
Angled Flat Šikmý plochý
Fan Blender Vějíř na prolínání
Grass Fan Vějíř na trávu
Pattern Borders Vzorkové okraje
Scatter Shapes Rozptýlené tvary
Save Brush Library… Uložit knihovnu štětců…
@@ -6974,3 +6987,15 @@ breakText Glyphs Glyfy
Deferred Effects Odložené efekty
@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Nástroje pro řezání
Stencil Bridges… Můstky šablony…
Stencil Bridges Můstky šablony
Shortest Crossing Nejkratší přechod
Direction Směr
@msg The selected art has no islands to bridge Vybraná kresba nemá žádné ostrůvky k přemostění
@msg No bridge of this width fits across the selected art Přes vybranou kresbu se nevejde žádný můstek této šířky
@msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby
@msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -788,6 +788,7 @@
Sky Gradients Himmelgradienter
Neutral Gradients Neutrale gradienter
Organic Fades Organiske toninger
Holographic Holografiske
Duotone Gradients Duotonegradienter
Radial Glows Radiale gløder
Save Swatch Library… Gem bibliotek med farveprøver…
@@ -4521,6 +4522,7 @@ imageTrace Outline Kontur
@msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0}
@msg not an EMF or WMF file ikke en EMF- eller WMF-fil
@msg not an HP-GL file ikke en HP-GL-fil
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` skal være et array af 1 til 255 #rrggbb-farver
@msg not found ikke fundet
@msg not in pattern editing mode ikke i mønsterredigeringstilstand
@msg finish editing the pattern first afslut først redigeringen af mønsteret
@@ -6080,6 +6082,17 @@ axis Y Axis Y-akse
Charge Wave Opladningsbølge
Spark Trail Gnistsvær
Calligraphic Nibs Kalligrafiske penne
Bristle Brushes Børstepensler
Fine Liner Fin streg
Pointed Flat Spids flad
Stubby Round Kort rund
Dry Scrub Tør skrubning
Soft Dome Blød kuppel
Filbert Oval flad
Angled Round Skrå rund
Angled Flat Skrå flad
Fan Blender Blendevifte
Grass Fan Græsvifte
Pattern Borders Mønsterborter
Scatter Shapes Spredte former
Save Brush Library… Gem penselbibliotek…
@@ -6679,3 +6692,10 @@ breakText Glyphs Glyffer
# Deferred plug-in effects
Refresh Deferred Effects Opdatér udskudte effekter
Deferred Effects Udskudte effekter
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Skæreværktøjer
Stencil Bridges… Stencilbroer…
Stencil Bridges Stencilbroer
Shortest Crossing Korteste krydsning
Direction Retning
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -2340,6 +2340,7 @@ file-filter Templates Vorlagen
Options: Optionen:
Options… Optionen…
Organic Fades Organische Verläufe
Holographic Holografisch
or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator
Order: Reihenfolge:
Ordinals Ordinalzahlen
@@ -4514,6 +4515,7 @@ theme Appearance Erscheinungsbild
@msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0}
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
@msg not an HP-GL file keine HP-GL-Datei
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein
@msg not found nicht gefunden
@msg not in pattern editing mode nicht im Musterbearbeitungsmodus
@msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters
@@ -5915,6 +5917,17 @@ axis Y Axis Y-Achse
Charge Wave Ladewelle
Spark Trail Funkenspur
Calligraphic Nibs Kalligrafie-Federn
Bristle Brushes Borstenpinsel-Set
Fine Liner Feiner Liner
Pointed Flat Spitzer Flachpinsel
Stubby Round Kurzer Rundpinsel
Dry Scrub Trockenes Schrubben
Soft Dome Weiche Kuppel
Filbert Katzenzunge
Angled Round Schräger Rundpinsel
Angled Flat Schräger Flachpinsel
Fan Blender Fächer zum Verblenden
Grass Fan Grasfächer
Pattern Borders Musterbordüren
Scatter Shapes Streuformen
Save Brush Library… Pinsel-Bibliothek speichern…
@@ -6569,3 +6582,15 @@ breakText Glyphs Glyphen
Deferred Effects Zurückgestellte Effekte
@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Schneidwerkzeuge
Stencil Bridges… Schablonenstege…
Stencil Bridges Schablonenstege
Shortest Crossing Kürzeste Querung
Direction Richtung
@msg The selected art has no islands to bridge Das ausgewählte Bildmaterial hat keine Inseln zum Verbinden
@msg No bridge of this width fits across the selected art Kein Steg dieser Breite passt über das ausgewählte Bildmaterial
@msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden
@msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -1863,6 +1863,7 @@ font-technology Variable Μεταβλητές
Options… Επιλογές…
Ordinals Τακτικοί αριθμοί
Organic Fades Οργανικές διαβαθμίσεις
Holographic Ολογραφικά
Orientation Προσανατολισμός
Orientation: Προσανατολισμός:
Original Πρωτότυπο
@@ -4544,6 +4545,7 @@ digits Malayalam Μαλαγιάλαμ
@msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0}
@msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF
@msg not an HP-GL file δεν είναι αρχείο HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb
@msg not found δεν βρέθηκε
@msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου
@msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου
@@ -6275,6 +6277,17 @@ axis Y Axis Άξονας Y
Charge Wave Κύμα φόρτισης
Spark Trail Ίχνος σπινθήρων
Calligraphic Nibs Καλλιγραφικές πένες
Bristle Brushes Πινέλα με τρίχες
Fine Liner Λεπτή γραμμή
Pointed Flat Μυτερό πλακέ
Stubby Round Κοντό στρογγυλό
Dry Scrub Ξηρό τρίψιμο
Soft Dome Μαλακός θόλος
Filbert Γλώσσα γάτας
Angled Round Λοξό στρογγυλό
Angled Flat Λοξό πλακέ
Fan Blender Βεντάλια ανάμειξης
Grass Fan Βεντάλια για γρασίδι
Pattern Borders Περιθώρια μοτίβου
Scatter Shapes Σχήματα διασποράς
Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων…
@@ -6926,3 +6939,15 @@ breakText Glyphs Γλύφοι
Deferred Effects Εφέ σε αναμονή
@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Εργαλεία κοπής
Stencil Bridges… Γέφυρες στένσιλ…
Stencil Bridges Γέφυρες στένσιλ
Shortest Crossing Συντομότερη διέλευση
Direction Κατεύθυνση
@msg The selected art has no islands to bridge Το επιλεγμένο γραφικό δεν έχει νησίδες για γεφύρωση
@msg No bridge of this width fits across the selected art Καμία γέφυρα αυτού του πλάτους δεν χωράει στο επιλεγμένο γραφικό
@msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό
@msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it is too large.
+25
View File
@@ -2378,6 +2378,7 @@ font-technology Variable Variables
Options: Opciones:
Options… Opciones…
Organic Fades Desvanecidos orgánicos
Holographic Holográficos
or more spaces with a tab o más espacios por una tabulación
Order: Orden:
Ordinals Ordinales
@@ -4589,6 +4590,7 @@ axis Both Ambos
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
@msg not an EMF or WMF file no es un archivo EMF ni WMF
@msg not an HP-GL file no es un archivo HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb
@msg not found no se encontró
@msg not in pattern editing mode no está en el modo de edición de motivos
@msg finish editing the pattern first termina primero la edición del motivo
@@ -6266,6 +6268,17 @@ axis Y Axis Eje Y
Charge Wave Onda de carga
Spark Trail Estela de chispas
Calligraphic Nibs Plumillas caligráficas
Bristle Brushes Pinceles de cerdas
Fine Liner Delineador fino
Pointed Flat Plano puntiagudo
Stubby Round Redondo corto
Dry Scrub Frotado seco
Soft Dome Cúpula suave
Filbert Lengua de gato
Angled Round Redondo biselado
Angled Flat Plano biselado
Fan Blender Abanico difuminador
Grass Fan Abanico de hierba
Pattern Borders Bordes de motivo
Scatter Shapes Formas de dispersión
Save Brush Library… Guardar biblioteca de pinceles…
@@ -6917,3 +6930,15 @@ breakText Glyphs Glifos
Deferred Effects Efectos aplazados
@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Herramientas de corte
Stencil Bridges… Puentes de plantilla…
Stencil Bridges Puentes de plantilla
Shortest Crossing Cruce más corto
Direction Dirección
@msg The selected art has no islands to bridge La ilustración seleccionada no tiene islas que unir
@msg No bridge of this width fits across the selected art Ningún puente de este ancho cabe en la ilustración seleccionada
@msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración
@msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -842,6 +842,7 @@
Sky Gradients Taivasliukuvärit
Neutral Gradients Neutraalit liukuvärit
Organic Fades Orgaaniset häivytykset
Holographic Holografiset
Duotone Gradients Kaksisävyliukuvärit
Radial Glows Säteittäiset hehkut
Save Swatch Library… Tallenna värimallikirjasto…
@@ -4805,6 +4806,7 @@ imageTrace Outline Ääriviiva
@msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0}
@msg not an EMF or WMF file ei EMF- tai WMF-tiedosto
@msg not an HP-GL file ei HP-GL-tiedosto
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko
@msg not found ei löytynyt
@msg not in pattern editing mode ei kuvion muokkaustilassa
@msg finish editing the pattern first lopeta ensin kuvion muokkaus
@@ -6354,6 +6356,17 @@ axis Y Axis Y-akseli
Charge Wave Latausaalto
Spark Trail Kipinävanne
Calligraphic Nibs Kalligrafiakärjet
Bristle Brushes Harjassiveltimet
Fine Liner Ohut viiva
Pointed Flat Suippo litteä
Stubby Round Lyhyt pyöreä
Dry Scrub Kuiva hankaus
Soft Dome Pehmeä kupu
Filbert Kissankieli
Angled Round Vino pyöreä
Angled Flat Vino litteä
Fan Blender Sekoitusviuhka
Grass Fan Ruohoviuhka
Pattern Borders Kuvioreunukset
Scatter Shapes Sirontamuodot
Save Brush Library… Tallenna sivellinkirjasto…
@@ -7009,3 +7022,15 @@ breakText Glyphs Merkit
Deferred Effects Lykätyt tehosteet
@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Leikkaustyökalut
Stencil Bridges… Sapluunan sillat…
Stencil Bridges Sapluunan sillat
Shortest Crossing Lyhin ylitys
Direction Suunta
@msg The selected art has no islands to bridge Valitussa grafiikassa ei ole yhdistettäviä saarekkeita
@msg No bridge of this width fits across the selected art Näin leveä silta ei mahdu valitun grafiikan poikki
@msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen
@msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -2370,6 +2370,7 @@ font-technology Variable Variables
Options: Options :
Options… Options…
Organic Fades Fondus organiques
Holographic Holographiques
or more spaces with a tab espaces ou plus par une tabulation
Order: Ordre :
Ordinals Ordinaux
@@ -4578,6 +4579,7 @@ axis Both Les deux
@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0}
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
@msg not an HP-GL file ce n’est pas un fichier HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb
@msg not found introuvable
@msg not in pattern editing mode pas en mode d’édition de motif
@msg finish editing the pattern first terminez d’abord la modification du motif
@@ -6319,6 +6321,17 @@ axis Y Axis Axe Y
Charge Wave Vague de charge
Spark Trail Traînée d'étincelles
Calligraphic Nibs Plumes calligraphiques
Bristle Brushes Pinceaux à soies
Fine Liner Traceur fin
Pointed Flat Plat pointu
Stubby Round Rond court
Dry Scrub Frottis sec
Soft Dome Dôme doux
Filbert Langue de chat
Angled Round Rond biseauté
Angled Flat Plat biseauté
Fan Blender Éventail estompeur
Grass Fan Éventail à herbe
Pattern Borders Bordures de motif
Scatter Shapes Formes de dispersion
Save Brush Library… Enregistrer la bibliothèque de formes…
@@ -6969,3 +6982,15 @@ breakText Glyphs Glyphes
Deferred Effects Effets différés
@msg no plug-in effect is deferred aucun effet de plug-in n’est différé
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Outils de découpe
Stencil Bridges… Ponts de pochoir…
Stencil Bridges Ponts de pochoir
Shortest Crossing Traversée la plus courte
Direction Direction
@msg The selected art has no islands to bridge L’illustration sélectionnée n’a aucun îlot à relier
@msg No bridge of this width fits across the selected art Aucun pont de cette largeur ne tient en travers de l’illustration sélectionnée
@msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration
@msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -691,6 +691,17 @@ antiAlias Sharp शार्प
Calligraphic Brush कैलिग्राफिक ब्रश
Calligraphic Brush Options कैलिग्राफिक ब्रश ऑप्शन्स
Calligraphic Nibs कैलिग्राफिक निब्स
Bristle Brushes ब्रिसल ब्रश संग्रह
Fine Liner बारीक लाइनर
Pointed Flat नुकीला फ्लैट
Stubby Round छोटा गोल
Dry Scrub सूखी रगड़
Soft Dome मुलायम गुंबद
Filbert फिल्बर्ट
Angled Round तिरछा गोल
Angled Flat तिरछा फ्लैट
Fan Blender मिश्रण पंखा
Grass Fan घास पंखा
Can't export “{name}”. "{name}" निर्यात नहीं कर सकते।
Can't open “{name}”. "{name}" नहीं खोल सकते हैं।
Can't print these settings: see the Summary इन सेटिंग्स को मुद्रित नहीं कर सकते: सारांश देखें
@@ -3196,6 +3207,7 @@ antiAlias Sharp शार्प
Options: ऑप्शन्स:
Options… ऑप्शन्स…
Organic Fades ऑर्गेनिक फ़ेड
Holographic होलोग्राफ़िक
or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान
Orange नारंगी
Order: आदेश:
@@ -7154,6 +7166,7 @@ antiAlias Sharp शार्प
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
@msg not an HP-GL file HP-GL फ़ाइल नहीं है
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए
@msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`)
@msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e}
@msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें)
@@ -8407,3 +8420,15 @@ theme Appearance अपीयरेंस
Deferred Effects स्थगित इफ़ेक्ट्स
@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है।
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools कटिंग टूल्स
Stencil Bridges… स्टेंसिल ब्रिज…
Stencil Bridges स्टेंसिल ब्रिज
Shortest Crossing सबसे छोटा क्रॉसिंग
Direction दिशा
@msg The selected art has no islands to bridge चुने गए आर्टवर्क में ब्रिज से जोड़ने के लिए कोई आइलैंड नहीं है
@msg No bridge of this width fits across the selected art इस चौड़ाई का कोई ब्रिज चुने गए आर्टवर्क के पार नहीं आता
@msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके
@msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए
Can't render this file because it is too large.
+25
View File
@@ -3196,6 +3196,7 @@ mark-style Japanese Japán
Radial Blur… Körkörös elmosás…
Radial Glows Körkörös ragyogások
Organic Fades Organikus áttűnések
Holographic Holografikus
Radial Gradient Körkörös színátmenet
Conic Gradient Kúpos színátmenet
Diamond Gradient Rombusz színátmenet
@@ -5274,6 +5275,7 @@ imageTrace Outline Körvonal
@msg not a blend nem összemosás
@msg not an EMF or WMF file nem EMF- vagy WMF-fájl
@msg not an HP-GL file nem HP-GL-fájl
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell
@msg not found nem található
@msg not in pattern editing mode nem mintázatszerkesztő módban vagy
@msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését
@@ -6307,6 +6309,17 @@ axis Y Axis Y tengely
Charge Wave Töltéshullám
Spark Trail Szikracsík
Calligraphic Nibs Kalligrafikus tollhegyek
Bristle Brushes Sörteecsetek
Fine Liner Finom kontúr
Pointed Flat Hegyes lapos
Stubby Round Rövid kerek
Dry Scrub Száraz dörzsölés
Soft Dome Lágy kupola
Filbert Macskanyelv
Angled Round Ferde kerek
Angled Flat Ferde lapos
Fan Blender Elmosó legyező
Grass Fan Fűlegyező
Pattern Borders Mintázatszegélyek
Scatter Shapes Szórt alakzatok
Save Brush Library… Ecsetkönyvtár mentése…
@@ -6958,3 +6971,15 @@ breakText Glyphs Glifák
Deferred Effects Elhalasztott effektusok
@msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Vágóeszközök
Stencil Bridges… Sablonhidak…
Stencil Bridges Sablonhidak
Shortest Crossing Legrövidebb átkelés
Direction Irány
@msg The selected art has no islands to bridge A kijelölt grafikán nincsenek összekötendő szigetek
@msg No bridge of this width fits across the selected art Ilyen széles híd nem fér el a kijelölt grafikán
@msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel
@msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -788,6 +788,7 @@
Sky Gradients Gradien langit
Neutral Gradients Gradien netral
Organic Fades Pudar organik
Holographic Holografik
Duotone Gradients Gradien duotone
Radial Glows Pendar radial
Save Swatch Library… Simpan pustaka sampel warna…
@@ -4521,6 +4522,7 @@ imageTrace Outline Kontur
@msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0}
@msg not an EMF or WMF file bukan berkas EMF atau WMF
@msg not an HP-GL file bukan berkas HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` harus berupa array berisi 1 sampai 255 warna #rrggbb
@msg not found tidak ditemukan
@msg not in pattern editing mode tidak dalam mode pengeditan pola
@msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu
@@ -6080,6 +6082,17 @@ axis Y Axis Sumbu Y
Charge Wave Gelombang Muatan
Spark Trail Jejak Percikan
Calligraphic Nibs Pena kaligrafi
Bristle Brushes Kuas-kuas bristle
Fine Liner Garis halus
Pointed Flat Pipih runcing
Stubby Round Bulat pendek
Dry Scrub Gosok kering
Soft Dome Kubah lembut
Filbert Pipih oval
Angled Round Bulat miring
Angled Flat Pipih miring
Fan Blender Kipas pembaur
Grass Fan Kipas rumput
Pattern Borders Bingkai pola
Scatter Shapes Bentuk sebar
Save Brush Library… Simpan pustaka kuas…
@@ -6679,3 +6692,10 @@ breakText Glyphs Glif
# Deferred plug-in effects
Refresh Deferred Effects Segarkan efek yang ditunda
Deferred Effects Efek yang ditunda
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Alat pemotongan
Stencil Bridges… Jembatan Stensil…
Stencil Bridges Jembatan Stensil
Shortest Crossing Persilangan terpendek
Direction Arah
Can't render this file because it contains an unexpected character in line 2394 and column 5.
+25
View File
@@ -2416,6 +2416,7 @@ file-filter Templates Modelli
Options: Opzioni:
Options… Opzioni…
Organic Fades Dissolvenze organiche
Holographic Olografici
or more spaces with a tab o più spazi con una tabulazione
Order: Ordine:
Ordinals Ordinali
@@ -4640,6 +4641,7 @@ axis Both Entrambi
@msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0}
@msg not an EMF or WMF file non è un file EMF o WMF
@msg not an HP-GL file non è un file HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb
@msg not found non trovato
@msg not in pattern editing mode non sei in modalità modifica motivo
@msg finish editing the pattern first termina prima la modifica del motivo
@@ -5949,6 +5951,17 @@ axis Y Axis Asse Y
Charge Wave Onda di carica
Spark Trail Scia di scintille
Calligraphic Nibs Pennini calligrafici
Bristle Brushes Pennelli a setole
Fine Liner Tratto fine
Pointed Flat Piatto appuntito
Stubby Round Tondo corto
Dry Scrub Strofinato secco
Soft Dome Cupola morbida
Filbert Lingua di gatto
Angled Round Tondo obliquo
Angled Flat Piatto obliquo
Fan Blender Ventaglio sfumino
Grass Fan Ventaglio per erba
Pattern Borders Bordi a motivo
Scatter Shapes Forme a dispersione
Save Brush Library… Salva libreria pennelli…
@@ -6603,3 +6616,15 @@ breakText Glyphs Glifi
Deferred Effects Effetti rimandati
@msg no plug-in effect is deferred nessun effetto plug-in è rimandato
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Strumenti di taglio
Stencil Bridges… Ponti per stencil…
Stencil Bridges Ponti per stencil
Shortest Crossing Attraversamento più breve
Direction Direzione
@msg The selected art has no islands to bridge La grafica selezionata non ha isole da collegare
@msg No bridge of this width fits across the selected art Nessun ponte di questa larghezza entra nella grafica selezionata
@msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica
@msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -874,6 +874,7 @@ digits Malayalam マラヤーラム数字
Sky Gradients 空のグラデーション
Neutral Gradients ニュートラルグラデーション
Organic Fades オーガニックフェード
Holographic ホログラフィック
Duotone Gradients ダブルトーングラデーション
Radial Glows 円形グロー
Save Swatch Library… スウォッチライブラリを保存…
@@ -4710,6 +4711,7 @@ swatch Bright Violet ビビッドバイオレット
@msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません
@msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません
@msg not an HP-GL file HP-GL ファイルではありません
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください
@msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません
@msg the EMF header is damaged EMF ヘッダーが破損しています
@msg damaged 破損しています
@@ -6014,6 +6016,17 @@ axis Y Axis Y 軸
Charge Wave チャージウェーブ
Spark Trail スパークトレイル
Calligraphic Nibs カリグラフィペン先
Bristle Brushes 剛毛ブラシ集
Fine Liner 細線ライナー
Pointed Flat 先細平筆
Stubby Round 短丸筆
Dry Scrub ドライスクラブ
Soft Dome ソフトドーム
Filbert フィルバート
Angled Round 斜め丸筆
Angled Flat 斜め平筆
Fan Blender ぼかし扇筆
Grass Fan 草描き扇筆
Pattern Borders パターンボーダー
Scatter Shapes 散布シェイプ
Save Brush Library… ブラシライブラリを保存…
@@ -6668,3 +6681,15 @@ breakText Glyphs 字形
Deferred Effects 保留中の効果
@msg no plug-in effect is deferred 保留中のプラグイン効果はありません
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools カッティングツール
Stencil Bridges… ステンシルブリッジ…
Stencil Bridges ステンシルブリッジ
Shortest Crossing 最短の横断
Direction 方向
@msg The selected art has no islands to bridge 選択したアートワークにはブリッジでつなぐアイランドがありません
@msg No bridge of this width fits across the selected art この幅のブリッジは選択したアートワークに収まりません
@msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした
@msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -748,6 +748,7 @@
Sky Gradients ცის გრადიენტები
Neutral Gradients ნეიტრალური გრადიენტები
Organic Fades ორგანული გაქრობები
Holographic ჰოლოგრაფიული
Duotone Gradients ორტონიანი გრადიენტები
Radial Glows რადიალური ნათებები
Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა…
@@ -4349,6 +4350,7 @@ font-technology Variable ცვლადი
@msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0}
@msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი
@msg not an HP-GL file ეს არ არის HP-GL ფაილი
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი
@msg not found ვერ მოიძებნა
@msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული
@msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება
@@ -6275,6 +6277,17 @@ axis Y Axis Y ღერძი
Charge Wave მუხტის ტალღა
Spark Trail ნაპერწკლის კვალი
Calligraphic Nibs კალიგრაფიული წვერები
Bristle Brushes ჯაგრის ფუნჯები
Fine Liner წვრილი ხაზი
Pointed Flat წაწვეტებული ბრტყელი
Stubby Round მოკლე მრგვალი
Dry Scrub მშრალი ხეხვა
Soft Dome რბილი გუმბათი
Filbert ოვალური ბრტყელი
Angled Round დახრილი მრგვალი
Angled Flat დახრილი ბრტყელი
Fan Blender შერევის მარაო
Grass Fan ბალახის მარაო
Pattern Borders პატერნის ბორდიურები
Scatter Shapes გაბნეული ფიგურები
Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა…
@@ -7088,3 +7101,15 @@ font-script Japanese იაპონური
Deferred Effects გადადებული ეფექტები
@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools ჭრის ხელსაწყოები
Stencil Bridges… ტრაფარეტის ხიდები…
Stencil Bridges ტრაფარეტის ხიდები
Shortest Crossing უმოკლესი გადაკვეთა
Direction მიმართულება
@msg The selected art has no islands to bridge მონიშნულ ნახატს არ აქვს დასაკავშირებელი კუნძულები
@msg No bridge of this width fits across the selected art ამ სიგანის ხიდი მონიშნულ ნახატზე არ ეტევა
@msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს
@msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე
Can't render this file because it is too large.
+25
View File
@@ -867,6 +867,7 @@ digits Malayalam 말라얄람어
Sky Gradients 하늘 그레이디언트
Neutral Gradients 중간색 그레이디언트
Organic Fades 유기적 페이드
Holographic 홀로그래픽
Duotone Gradients 듀오톤 그레이디언트
Radial Glows 방사형 광선
Save Swatch Library… 견본 라이브러리 저장…
@@ -4637,6 +4638,7 @@ theme Appearance 외관
@msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다
@msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다
@msg not an HP-GL file HP-GL 파일이 아닙니다
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다
@msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다
@msg the EMF header is damaged EMF 헤더가 손상되었습니다
@msg damaged 손상됨
@@ -5910,6 +5912,17 @@ axis Y Axis Y축
Charge Wave 차지 웨이브
Spark Trail 스파크 트레일
Calligraphic Nibs 서예 펜촉
Bristle Brushes 강모 브러시 모음
Fine Liner 가는 라이너
Pointed Flat 뾰족한 평붓
Stubby Round 짧은 둥근 붓
Dry Scrub 마른 문지르기
Soft Dome 부드러운 돔
Filbert 필버트
Angled Round 사선 둥근 붓
Angled Flat 사선 평붓
Fan Blender 블렌딩 부채붓
Grass Fan 잔디 부채붓
Pattern Borders 패턴 테두리
Scatter Shapes 흩뿌리기 모양
Save Brush Library… 브러시 라이브러리 저장…
@@ -6564,3 +6577,15 @@ breakText Glyphs 글리프
Deferred Effects 보류된 효과
@msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools 커팅 도구
Stencil Bridges… 스텐실 브리지…
Stencil Bridges 스텐실 브리지
Shortest Crossing 최단 가로지르기
Direction 방향
@msg The selected art has no islands to bridge 선택한 아트워크에 연결할 섬이 없습니다
@msg No bridge of this width fits across the selected art 이 너비의 브리지가 선택한 아트워크에 들어가지 않습니다
@msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다
@msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+20
View File
@@ -802,6 +802,7 @@
Sky Gradients Luchtverlopen
Neutral Gradients Neutrale verlopen
Organic Fades Organische vervagingen
Holographic Holografisch
Duotone Gradients Duotoonverlopen
Radial Glows Radiale gloed
Save Swatch Library… Staalenbibliotheek opslaan…
@@ -4533,6 +4534,7 @@ imageTrace Outline Contour
@msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0}
@msg not an EMF or WMF file geen EMF- of WMF-bestand
@msg not an HP-GL file geen HP-GL-bestand
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` moet een array van 1 tot 255 #rrggbb-kleuren zijn
@msg not found niet gevonden
@msg not in pattern editing mode niet in patroonbewerkingsmodus
@msg finish editing the pattern first rond eerst het bewerken van het patroon af
@@ -6131,6 +6133,17 @@ axis Y Axis Y-as
Charge Wave Laadgolf
Spark Trail Vonkenspoor
Calligraphic Nibs Kalligrafische penpunten
Bristle Brushes Borstelpenselen
Fine Liner Fijne liner
Pointed Flat Spitse platte
Stubby Round Korte ronde
Dry Scrub Droog schrobben
Soft Dome Zachte koepel
Filbert Kattentong
Angled Round Schuine ronde
Angled Flat Schuine platte
Fan Blender Vervloeiwaaier
Grass Fan Graswaaier
Pattern Borders Patroonranden
Scatter Shapes Strooivormen
Save Brush Library… Penseelbibliotheek opslaan…
@@ -6730,3 +6743,10 @@ breakText Glyphs Glyphs
# Deferred plug-in effects
Refresh Deferred Effects Uitgestelde effecten vernieuwen
Deferred Effects Uitgestelde effecten
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Snijgereedschap
Stencil Bridges… Sjabloonbruggen…
Stencil Bridges Sjabloonbruggen
Shortest Crossing Kortste oversteek
Direction Richting
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -798,6 +798,7 @@
Sky Gradients Gradienty nieba
Neutral Gradients Gradienty neutralne
Organic Fades Organiczne zanikania
Holographic Holograficzne
Duotone Gradients Gradienty dwutonowe
Radial Glows Blaski promieniste
Save Swatch Library… Zapisz bibliotekę próbek…
@@ -4722,6 +4723,7 @@ imageTrace Outline Kontur
@msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0}
@msg not an EMF or WMF file to nie jest plik EMF ani WMF
@msg not an HP-GL file to nie jest plik HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb
@msg not found nie znaleziono
@msg not in pattern editing mode nie jest w trybie edycji wzorka
@msg finish editing the pattern first najpierw zakończ edycję wzorka
@@ -6178,6 +6180,17 @@ axis Y Axis Oś Y
Charge Wave Fala ładunku
Spark Trail Ślad iskier
Calligraphic Nibs Stalówki kaligraficzne
Bristle Brushes Pędzle włosiankowe
Fine Liner Cienka linia
Pointed Flat Szpiczasty płaski
Stubby Round Krótki okrągły
Dry Scrub Suche szorowanie
Soft Dome Miękka kopuła
Filbert Koci język
Angled Round Skośny okrągły
Angled Flat Skośny płaski
Fan Blender Wachlarz do mieszania
Grass Fan Wachlarz do trawy
Pattern Borders Obramowania wzorków
Scatter Shapes Kształty rozproszone
Save Brush Library… Zapisz bibliotekę pędzli…
@@ -6830,3 +6843,15 @@ breakText Glyphs Glify
Deferred Effects Odroczone efekty
@msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Narzędzia do cięcia
Stencil Bridges… Mostki szablonu…
Stencil Bridges Mostki szablonu
Shortest Crossing Najkrótsze przejście
Direction Kierunek
@msg The selected art has no islands to bridge Zaznaczona grafika nie ma wysp do połączenia
@msg No bridge of this width fits across the selected art Żaden mostek o tej szerokości nie mieści się w zaznaczonej grafice
@msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki
@msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+25
View File
@@ -1975,6 +1975,7 @@ antiAlias Sharp Nítido
Order: Ordem:
Ordinals Ordinais
Organic Fades Esmaecimentos Orgânicos
Holographic Holográficos
Orientation Orientação
Orientation: Orientação:
Original Original
@@ -4127,6 +4128,7 @@ axis Both Ambos
@msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0}
@msg not an EMF or WMF file não é um arquivo EMF ou WMF
@msg not an HP-GL file não é um arquivo HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb
@msg not found não encontrado
@msg not in pattern editing mode não está no modo de edição de padrão
@msg finish editing the pattern first termine de editar o padrão primeiro
@@ -6239,6 +6241,17 @@ axis Y Axis Eixo Y
Charge Wave Onda de carga
Spark Trail Rastro de faíscas
Calligraphic Nibs Penas Caligráficas
Bristle Brushes Pincéis de Cerdas
Fine Liner Traço Fino
Pointed Flat Chato Pontudo
Stubby Round Redondo Curto
Dry Scrub Esfregado Seco
Soft Dome Cúpula Suave
Filbert Língua de Gato
Angled Round Redondo Chanfrado
Angled Flat Chato Chanfrado
Fan Blender Leque Esfumador
Grass Fan Leque de Grama
Pattern Borders Bordas de Padrão
Scatter Shapes Formas de Dispersão
Save Brush Library… Salvar Biblioteca de Pincéis…
@@ -7047,3 +7060,15 @@ swatch Yellow Green Verde-amarelado
Deferred Effects Efeitos Adiados
@msg no plug-in effect is deferred nenhum efeito de plug-in está adiado
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Ferramentas de Corte
Stencil Bridges… Pontes de Estêncil…
Stencil Bridges Pontes de Estêncil
Shortest Crossing Travessia mais curta
Direction Direção
@msg The selected art has no islands to bridge A arte selecionada não tem ilhas para unir
@msg No bridge of this width fits across the selected art Nenhuma ponte desta largura cabe na arte selecionada
@msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte
@msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+25
View File
@@ -2403,6 +2403,7 @@ file-filter Templates Шаблоны
Options: Параметры:
Options… Параметры…
Organic Fades Органические затухания
Holographic Голографические
or more spaces with a tab или больше пробелов на табуляцию
Order: Порядок:
Ordinals Порядковые числительные
@@ -4626,6 +4627,7 @@ axis Both Оба
@msg not a valid plug-in module: {0} не является допустимым модулем: {0}
@msg not an EMF or WMF file не является файлом EMF или WMF
@msg not an HP-GL file не является файлом HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb
@msg not found не найдено
@msg not in pattern editing mode не в режиме редактирования узора
@msg finish editing the pattern first сначала завершите редактирование узора
@@ -5935,6 +5937,17 @@ axis Y Axis Ось Y
Charge Wave Волна заряда
Spark Trail Искра-след
Calligraphic Nibs Каллиграфические перья
Bristle Brushes Щетинные кисти
Fine Liner Тонкий лайнер
Pointed Flat Заострённая плоская
Stubby Round Короткая круглая
Dry Scrub Сухое растирание
Soft Dome Мягкий купол
Filbert Кошачий язык
Angled Round Скошенная круглая
Angled Flat Скошенная плоская
Fan Blender Веер для растушёвки
Grass Fan Веер для травы
Pattern Borders Узорные каймы
Scatter Shapes Рассеянные фигуры
Save Brush Library… Сохранить библиотеку кистей…
@@ -7063,3 +7076,15 @@ shape Open Открытый
Deferred Effects Отложенные эффекты
@msg no plug-in effect is deferred нет отложенных эффектов модулей
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Инструменты резки
Stencil Bridges… Мостики трафарета…
Stencil Bridges Мостики трафарета
Shortest Crossing Кратчайшее пересечение
Direction Направление
@msg The selected art has no islands to bridge В выделенной графике нет островков для соединения
@msg No bridge of this width fits across the selected art Мостик такой ширины не помещается поперёк выделенной графики
@msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики
@msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it is too large.
+25
View File
@@ -2317,6 +2317,7 @@ font-technology Variable Variabla
Options: Alternativ:
Options… Alternativ…
Organic Fades Organiska toningar
Holographic Holografiska
or more spaces with a tab eller fler blanksteg med en tabb
Order: Ordning:
Ordinals Ordningstal
@@ -4492,6 +4493,7 @@ axis Both Båda
@msg not a valid plug-in module: {0} inte ett giltigt plugin: {0}
@msg not an EMF or WMF file inte en EMF- eller WMF-fil
@msg not an HP-GL file inte en HP-GL-fil
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb
@msg not found hittades inte
@msg not in pattern editing mode inte i läget för mönsterredigering
@msg finish editing the pattern first slutför redigeringen av mönstret först
@@ -6341,6 +6343,17 @@ axis Y Axis Y-axel
Charge Wave Laddningsvåg
Spark Trail Gnistsvår
Calligraphic Nibs Kalligrafiska pennspetsar
Bristle Brushes Borstpenslar
Fine Liner Fin linje
Pointed Flat Spetsig platt
Stubby Round Kort rund
Dry Scrub Torr skrubbning
Soft Dome Mjuk kupol
Filbert Oval platt
Angled Round Sned rund
Angled Flat Sned platt
Fan Blender Blandande solfjäder
Grass Fan Grässolfjäder
Pattern Borders Mönsterbårder
Scatter Shapes Spridningsformer
Save Brush Library… Spara penselbibliotek…
@@ -8452,3 +8465,15 @@ digits Hindi Hindi
Deferred Effects Uppskjutna effekter
@msg no plug-in effect is deferred ingen plugineffekt är uppskjuten
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Skärverktyg
Stencil Bridges… Schablonbryggor…
Stencil Bridges Schablonbryggor
Shortest Crossing Kortaste korsning
Direction Riktning
@msg The selected art has no islands to bridge Den markerade teckningen har inga öar att förbinda
@msg No bridge of this width fits across the selected art Ingen brygga med den här bredden får plats över den markerade teckningen
@msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen
@msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 5 and column 25.
+25
View File
@@ -2189,6 +2189,7 @@ shape Open Açık
Options: Seçenekler:
Options… Seçenekler…
Organic Fades Organik Solmalar
Holographic Holografik
or more spaces with a tab veya sekmeli daha fazla alan
Order: Sipariş:
Ordinals Sıra Sayıları
@@ -4347,6 +4348,7 @@ axis Both Her İkisi
@msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0}
@msg not an EMF or WMF file EMF veya WMF dosyası değil
@msg not an HP-GL file HP-GL dosyası değil
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır
@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor
@msg not found bulunamadı
@msg not in pattern editing mode desen düzenleme modunda değil
@@ -6280,6 +6282,17 @@ axis Y Axis Y ekseni
Charge Wave Şarj Dalgası
Spark Trail Kıvılcım İzi
Calligraphic Nibs Kaligrafi Uçları
Bristle Brushes Kıl Fırçalar
Fine Liner İnce Çizgi
Pointed Flat Sivri Düz
Stubby Round Kısa Yuvarlak
Dry Scrub Kuru Ovma
Soft Dome Yumuşak Kubbe
Filbert Kedi Dili
Angled Round Eğik Yuvarlak
Angled Flat Eğik Düz
Fan Blender Harmanlama Yelpazesi
Grass Fan Çim Yelpazesi
Pattern Borders Desen Kenarlıkları
Scatter Shapes Dağılım Şekilleri
Save Brush Library… Fırça Kitaplığını Kaydet…
@@ -6930,3 +6943,15 @@ breakText Glyphs Glifler
Deferred Effects Ertelenen Efektler
@msg no plug-in effect is deferred ertelenen eklenti efekti yok
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Kesim Araçları
Stencil Bridges… Şablon Köprüleri…
Stencil Bridges Şablon Köprüleri
Shortest Crossing En kısa geçiş
Direction Yön
@msg The selected art has no islands to bridge Seçili çizimde köprüyle bağlanacak ada yok
@msg No bridge of this width fits across the selected art Bu genişlikte bir köprü seçili çizime sığmıyor
@msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı
@msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+25
View File
@@ -2408,6 +2408,7 @@ file-filter Templates Шаблони
Options: Параметри:
Options… Параметри…
Organic Fades Органічні згасання
Holographic Голографічні
or more spaces with a tab або більше прогалин на табуляцію
Order: Порядок:
Ordinals Порядкові числівники
@@ -4624,6 +4625,7 @@ axis Both Обидві
@msg not a valid plug-in module: {0} не є допустимим модулем: {0}
@msg not an EMF or WMF file не є файлом EMF або WMF
@msg not an HP-GL file не є файлом HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb
@msg not found не знайдено
@msg not in pattern editing mode не в режимі редагування візерунка
@msg finish editing the pattern first спочатку завершіть редагування візерунка
@@ -5929,6 +5931,17 @@ axis Y Axis Вісь Y
Charge Wave Хвиля заряду
Spark Trail Слід іскор
Calligraphic Nibs Каліграфічні пера
Bristle Brushes Щетинні пензлі
Fine Liner Тонкий лайнер
Pointed Flat Загострений плаский
Stubby Round Короткий круглий
Dry Scrub Сухе розтирання
Soft Dome М'який купол
Filbert Котячий язик
Angled Round Скошений круглий
Angled Flat Скошений плаский
Fan Blender Віяло для розтушовування
Grass Fan Віяло для трави
Pattern Borders Візерункові облямівки
Scatter Shapes Розсіяні фігури
Save Brush Library… Зберегти бібліотеку пензлів…
@@ -6583,3 +6596,15 @@ breakText Glyphs Гліфи
Deferred Effects Відкладені ефекти
@msg no plug-in effect is deferred немає відкладених ефектів модулів
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Інструменти різання
Stencil Bridges… Містки трафарету…
Stencil Bridges Містки трафарету
Shortest Crossing Найкоротший перетин
Direction Напрямок
@msg The selected art has no islands to bridge У виділеній графіці немає острівців для з’єднання
@msg No bridge of this width fits across the selected art Місток такої ширини не вміщується впоперек виділеної графіки
@msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки
@msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 182 and column 5.
+25
View File
@@ -2985,6 +2985,7 @@ mark-style Japanese Kiểu Nhật
Radial Dividers: Đường chia hướng tâm:
Radial Glows Ánh sáng hướng tâm
Organic Fades Mờ dần tự nhiên
Holographic Ảnh ba chiều
Radial Gradient Chuyển sắc hướng tâm
Conic Gradient Chuyển sắc hình nón
Diamond Gradient Chuyển sắc hình thoi
@@ -4937,6 +4938,7 @@ theme Appearance Giao diện
@msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được.
@msg not an EMF or WMF file không phải tệp EMF hoặc WMF
@msg not an HP-GL file không phải tệp HP-GL
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb
@msg not found không tìm thấy
@msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết
@msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước
@@ -5930,6 +5932,17 @@ axis Y Axis Trục Y
Charge Wave Sóng nạp
Spark Trail Vệt tia lửa
Calligraphic Nibs Ngòi bút thư pháp
Bristle Brushes Bộ cọ lông
Fine Liner Nét mảnh
Pointed Flat Cọ dẹt nhọn
Stubby Round Cọ tròn ngắn
Dry Scrub Chà khô
Soft Dome Vòm mềm
Filbert Cọ lưỡi mèo
Angled Round Cọ tròn xiên
Angled Flat Cọ dẹt xiên
Fan Blender Cọ quạt hòa trộn
Grass Fan Cọ quạt vẽ cỏ
Pattern Borders Viền họa tiết
Scatter Shapes Hình phân tán
Save Brush Library… Lưu thư viện cọ…
@@ -6584,3 +6597,15 @@ breakText Glyphs Glyph
Deferred Effects Hiệu ứng bị hoãn
@msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng.
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools Công cụ cắt
Stencil Bridges… Cầu nối khuôn tô…
Stencil Bridges Cầu nối khuôn tô
Shortest Crossing Đường cắt ngắn nhất
Direction Hướng
@msg The selected art has no islands to bridge Hình vẽ đã chọn không có đảo nào cần nối
@msg No bridge of this width fits across the selected art Không có cầu nối nào rộng như vậy vừa với hình vẽ đã chọn
@msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ
@msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND}
Can't render this file because it contains an unexpected character in line 204 and column 5.
+25
View File
@@ -2275,6 +2275,7 @@ file-filter Templates 模板
Options: 选项:
Options… 选项…
Organic Fades 自然渐隐
Holographic 全息
or more spaces with a tab 或多个空格加一个制表符
Order: 顺序:
Ordinals 序数字
@@ -4408,6 +4409,7 @@ imageTrace Outline 轮廓
@msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图
@msg not an EMF or WMF file 不是 EMF 或 WMF 文件
@msg not an HP-GL file 不是 HP-GL 文件
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组
@msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取
@msg the EMF header is damaged EMF 文件头已损坏
@msg damaged 已损坏
@@ -6162,6 +6164,17 @@ axis Y Axis Y 轴
Charge Wave 充能波
Spark Trail 火花尾迹
Calligraphic Nibs 书法笔尖
Bristle Brushes 鬃毛画笔集
Fine Liner 细勾线笔
Pointed Flat 尖头平笔
Stubby Round 短圆笔
Dry Scrub 干擦
Soft Dome 柔和圆顶
Filbert 榛形笔
Angled Round 斜圆笔
Angled Flat 斜平笔
Fan Blender 混色扇形笔
Grass Fan 画草扇形笔
Pattern Borders 图案边框
Scatter Shapes 散布形状
Save Brush Library… 保存画笔库…
@@ -6812,3 +6825,15 @@ breakText Glyphs 字形
Deferred Effects 延迟的效果
@msg no plug-in effect is deferred 没有延迟的增效工具效果
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。
# Cutting Tools add-on (Object › Plug-ins › Cutting Tools).
Cutting Tools 切割工具
Stencil Bridges… 镂空版连接桥…
Stencil Bridges 镂空版连接桥
Shortest Crossing 最短跨越
Direction 方向
@msg The selected art has no islands to bridge 所选图稿没有需要连接的孤岛
@msg No bridge of this width fits across the selected art 此宽度的连接桥无法放入所选图稿
@msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域
@msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数
Can't render this file because it contains an unexpected character in line 2 and column 73.

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