Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 71174477cc Release prep: 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:41 -04:00
BFlat 8716fc8fbc Merge pull request #1374 from storytold/fix/1358-trace-panel-width
Part of #1358: the Image Trace section no longer widens the Properties panel
2026-10-12 16:42:01 +07:00
bflatastic 268a4452f9 Part of #1358: the Image Trace section no longer widens the Properties panel
Its View row asked for the row's whole width for its menu before its label was laid out, so
the row ran past the panel, the panel's content grew and the start of every label was cut off.
2026-10-12 16:32:43 +07:00
BFlat f97b24b388 Merge pull request #1371 from diegozpy/i18n/es-missing-rows
i18n(es): add 371 rows other catalogs already had
2026-10-12 16:09:44 +07:00
Diego Ospina a747daa346 i18n(es): add 371 rows other catalogs already had
Document presets, shortcut editor, print/PDF options, arrowheads,
harmony rules, Save for Web, graphic styles, file filters.

Models: Opus 5.5, Sol 6.1, DeepSeek V4 Flash
2026-10-12 16:07:18 +07:00
BFlat 2d619baaba Merge pull request #1363 from AyberkA0/fix/toolbar-icon-centering
Toolbar: centre the tool buttons inside the panel's border
2026-10-12 16:05:02 +07:00
BFlat f45ffdeb96 Merge pull request #1369 from callmeBalloch/fix/affinity-rectangle-corners
Part of #666: Affinity rectangles keep per-corner radii and straight corners
2026-10-12 16:04:52 +07:00
BFlat d6f1c9862d Merge pull request #1360 from dotCooCoo/feat/pdf-export-huge-text-offsets
PDF export: art beyond 1,000,000,000 pt is left out, so dev and test builds no longer abort
2026-10-12 16:03:44 +07:00
Ayberk A. 5a8682e45d Toolbar: centre the tool buttons inside the panel's border
The buttons were placed (WIDTH - 36) / 2 from the panel's content edge, but
the docked panel's content is narrower than WIDTH: the 1.5 pt frame stroke on
both sides and the 1 pt separator line egui reserves on the right leave 44 pt.
So the single column sat 6.5 pt from the left border and 1.5 pt from the right
(2.5 pt right of centre), and the double column's second button ran over the
right border. Centre each row on the panel's inside instead, and widen the
double column to 80 pt so its two buttons fit with 2 pt either side.
2026-10-12 15:41:47 +07:00
callmeBallochandClaude Opus 5.5 52e9b25371 Part of #666: Affinity rectangles keep per-corner radii and straight corners
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. patchy-shapes.af now
matches its embedded thumbnail (mean difference 1.91 -> 0.24) with no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:41:46 +07:00
dotCooCoo 5d5e7a1dd2 PDF export: art beyond 1,000,000,000 pt is left out, so dev and test builds no longer abort
In builds with overflow checks (`cargo run`, `cargo test` and
`cargo xtask ci`), PDF export aborted the process when a number it
wrote rounded to -2,147,483,648 as an f32. pdf-writer 0.15 writes
-2,147,483,648.0 as an integer, and `Limits::register_int` overflows
when it takes the absolute value. krilla 0.8.2's `Surface` then fails
an assertion in its `Drop` while that panic unwinds.
`svg_hostile_markup_never_panics` in `import_fuzz` hit it with type
shifted 2,147,483,649 pt down. Paths, gradients, dash offsets, images
and artboards that far out hit it too, and so does a group around a
stroke wide enough to reach that far. Release builds have no overflow
checks.

PDF export now leaves out art whose coordinates or transform entries
lie beyond 1,000,000,000 in its own space or in the content stream it
is drawn into, and glyph lines whose spacing lies beyond 1,000,000,000
thousandths of an em. That covers paths and clipping paths, glyph
lines, images, gradients, strokes whose width, caps or miter spikes
reach that far, and the art and marks of a page whose artboard lies
that far, which is written blank. A dash offset beyond the bound is
written as its remainder after whole periods of the pattern.

The type's transform is now pushed inside the form that marks a fill
or stroke of type to overprint. The exporter checks what that form
holds through the type's transform, as it checks the same fill or
stroke without overprint.
2026-10-12 15:41:45 +07:00
BFlat 42b355c21c Merge pull request #1364 from storytold/fix/1264-ai-guides
Part of #1264: an image on %_ lines of a .ai file's editing data is read as an image
2026-10-12 15:40:11 +07:00
BFlat a48ed5e253 Merge pull request #1192 from FredApps/fix/polar-grid-click-dialog-20261011
Add a Polar Grid canvas-click dialog
2026-10-12 15:39:37 +07:00
15 changed files with 840 additions and 118 deletions

No files matched your search

Generated
+27 -27
View File
@@ -4490,7 +4490,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4524,7 +4524,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4535,7 +4535,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4547,7 +4547,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4563,7 +4563,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4584,7 +4584,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4596,7 +4596,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4628,7 +4628,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4663,7 +4663,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4683,7 +4683,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"proptest",
@@ -4697,7 +4697,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4709,7 +4709,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -4728,7 +4728,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4742,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4754,7 +4754,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"aes",
"flate2",
@@ -4783,7 +4783,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
@@ -4797,7 +4797,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"gif",
@@ -4822,7 +4822,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4846,7 +4846,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4863,7 +4863,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4883,14 +4883,14 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4905,7 +4905,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4918,7 +4918,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"craft-ui",
"egui",
@@ -4951,7 +4951,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -6061,7 +6061,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-4
View File
@@ -25,10 +25,6 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
+2 -2
View File
@@ -14,7 +14,7 @@ Affinity files.
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
@@ -33,7 +33,7 @@ Everything else is reported in the import warnings, one line per kind with a cou
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round, stars with rounded points, several fills or strokes on one object (the
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
+60 -35
View File
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
@@ -165,22 +181,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+36
View File
@@ -166,3 +166,39 @@ fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// 10×20 box: relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
read(vec![shape(100, b"ShNR", fields)], 100)
};
let unlocked = corners(Some(false));
assert!(unlocked.warnings.is_empty(), "{:?}", unlocked.warnings);
let sub = &path(&unlocked).subpaths[0];
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let locked = corners(None);
let sub = &path(&locked).subpaths[0];
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
@@ -20,7 +20,7 @@ patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2,
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
+1
View File
@@ -23,6 +23,7 @@ const FILES: &[(&str, f64)] = &[
("patchy-group.af", 0.25), // 0.00
("patchy-transform.af", 1.0), // 0.19
("patchy-vector.af", 1.0), // 0.12
("patchy-shapes.af", 1.0), // 0.24: per-corner radii, a straight corner
("patchy-lazy-placed.af", 1.0), // 0.50: upstream byte-derived lazy-image regression
("patchy-embedded-jpeg.af", 1.0), // 0.50
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
+9
View File
@@ -485,6 +485,15 @@ fn mutate_text(src: &str, cut: usize, edits: &[(usize, char)]) -> String {
chars.into_iter().collect()
}
/// Type shifted two billion points down exports to PDF: an input of
/// `svg_hostile_markup_never_panics`, cut down to its type.
#[test]
fn svg_type_shifted_two_billion_points_exports_to_pdf() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><text baseline-shift="-2147483649">f</text></svg>"#;
let d = vectorcraft_svg::import(svg).unwrap();
assert!(vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default()).is_ok());
}
proptest! {
#![proptest_config(config())]
+139 -40
View File
@@ -14,7 +14,7 @@ use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::text::{GlyphId, KrillaGlyph};
use krilla::text::{Glyph, GlyphId, KrillaGlyph};
use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
@@ -229,8 +229,10 @@ impl Writer {
if sheet.negative {
cover(&mut s, paper, 255, 1.0);
}
// A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank.
let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO);
s.push_transform(&xf(sheet.view));
let window = sheet.window.and_then(|r| to_path(&r.to_path(0.1)));
let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1)));
if let Some(clip) = &window {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
@@ -239,7 +241,7 @@ impl Writer {
s.push_transform(&xf(sheet.place));
}
let r = sheet.area;
let clip = sheet.clip.then(|| to_path(&r.to_path(0.1))).flatten();
let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten();
if let Some(clip) = &clip {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
@@ -248,15 +250,17 @@ impl Writer {
// at the top of a page is put in a non-isolated group of its own, whose blending space
// `cmyk_blending` rewrites.
let doc = ex.doc;
let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency();
let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency();
if page_group {
s.push_isolated();
} else if cmyk_page_group {
let all = constant_mask(&mut s, r, 1.0);
s.push_mask(all);
}
ex.children(&mut s, &doc.layers, r);
if fits {
ex.children(&mut s, &doc.layers, r);
}
if page_group || cmyk_page_group {
s.pop();
}
@@ -266,7 +270,7 @@ impl Writer {
if placed {
s.pop();
}
if let Some(art) = sheet.marks {
if fits && let Some(art) = sheet.marks {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let knockout = std::mem::replace(&mut ex.knockout, false);
@@ -525,12 +529,43 @@ struct GlyphLine {
/// Most pixels along a side of a freeform gradient's image (its colour field is smooth).
const MAX_FIELD_PX: f64 = 512.0;
/// The largest magnitude a coordinate, length or transform entry may have in the content stream it
/// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left
/// out. A PDF/A-2 file may hold only integers from -2,147,483,648 to 2,147,483,647 (ISO 19005-2,
/// 6.1.13). The PDF writer writes a whole number in the `i32` range as an integer, and in builds
/// with overflow checks it panics on -2,147,483,648.
const MAX_COORD: f64 = 1e9;
fn xf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
fn to_path(bp: &BezPath) -> Option<Path> {
/// The current transform of surface `s` in the content stream it draws into, without that stream's
/// root transform (a page's y flip).
fn ctm(s: &Surface) -> Affine {
let t = s.ctm();
Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from))
}
/// Are `values` finite and within [`MAX_COORD`]?
fn in_range(values: &[f64]) -> bool {
values.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
}
/// Can art spanning `b`, in the space `space` maps into the one `s` draws in, be written: `b`, the
/// transform of `s` followed by `space`, and `b` through that transform all within [`MAX_COORD`]?
fn writable(s: &Surface, space: Affine, b: Rect) -> bool {
let t = ctm(s) * space;
let to = t.transform_rect_bbox(b);
in_range(&t.as_coeffs()) && in_range(&[b.x0, b.y0, b.x1, b.y1, to.x0, to.y0, to.x1, to.y1])
}
/// `bp` as a path to draw on `s`; `None` when it is empty or reaches beyond [`MAX_COORD`] there.
fn to_path(s: &Surface, bp: &BezPath) -> Option<Path> {
if !writable(s, Affine::IDENTITY, bp.control_box()) {
return None;
}
let mut pb = PathBuilder::new();
for el in bp.elements() {
match *el {
@@ -597,7 +632,7 @@ fn color(c: &Color) -> krilla::color::Color {
/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask
/// backdrops.
fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) {
let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return };
let Some(p) = to_path(s, &page.inflate(1.0, 1.0).to_path(0.1)) else { return };
s.set_stroke(None);
s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
@@ -718,14 +753,15 @@ impl Exporter<'_> {
}
}
/// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer).
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
self.paint_in(p, bounds, Affine::IDENTITY)
/// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the
/// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint.
fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
self.paint_in(s, p, bounds, Affine::IDENTITY)
}
/// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds`
/// is) to the one drawn in.
fn paint_in(&mut self, p: &Paint, bounds: Rect, space: Affine) -> Option<krilla::paint::Paint> {
fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option<krilla::paint::Paint> {
match p {
Paint::None => None,
Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()),
@@ -761,6 +797,9 @@ impl Exporter<'_> {
}
match g.gradient.kind {
GradientKind::Linear => {
if !writable(s, space, Rect::from_points(geom.start, geom.end)) {
return None;
}
let (s, mut e) = (geom.start, geom.end);
if s.distance(e) < 1e-9 {
e = s + Vec2::new(1.0, 0.0);
@@ -785,6 +824,10 @@ impl Exporter<'_> {
let (cx, cy) = (geom.start.x as f32, geom.start.y as f32);
// An off-centre focal point: a two-point radial shading from it.
let f = t.inverse() * geom.focal_point();
let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f);
if !writable(s, space * t, circles) {
return None;
}
Some(
RadialGradient {
fx: f.x as f32,
@@ -968,7 +1011,7 @@ impl Exporter<'_> {
// The region every output clips to; nothing to clip by hides the clipped art. The
// clipping path's fill paints behind the clipped art and its stroke over it, unclipped.
if let Some((clip, rest)) = children.split_first()
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r)))
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r)))
{
let paint = clip.clip_paint();
s.push_clip_path(&p, &rule(r));
@@ -1020,7 +1063,7 @@ impl Exporter<'_> {
}
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
let Some(path) = to_path(bp) else { return };
let Some(path) = to_path(s, bp) else { return };
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
@@ -1033,7 +1076,7 @@ impl Exporter<'_> {
if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
return;
}
let Some(paint) = ex.paint(&fl.paint, bounds) else { return };
let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
@@ -1166,14 +1209,18 @@ impl Exporter<'_> {
// The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as
// Outline Stroke makes it, painted with the pattern's tiles or the freeform's image.
let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath();
if let Some(clip) = to_path(&region) {
if let Some(clip) = to_path(s, &region) {
let area = region.bounding_box();
self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area);
}
return;
}
let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return };
let w = stroke::for_writer(bp, st);
// A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out.
if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) {
return;
}
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
@@ -1189,7 +1236,7 @@ impl Exporter<'_> {
// (miter spikes included) plus the path, even-odd.
let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(&outside) {
if let Some(p) = to_path(s, &outside) {
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
pushes += 1;
}
@@ -1203,7 +1250,11 @@ impl Exporter<'_> {
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
StrokeDash { array: pat, offset: d.offset as f32 }
// An offset beyond [`MAX_COORD`] is written as its remainder after whole
// periods of the pattern.
let period: f64 = pat.iter().copied().map(f64::from).sum();
let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) };
StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } }
});
s.set_fill(None);
s.set_stroke(Some(Stroke {
@@ -1230,7 +1281,7 @@ impl Exporter<'_> {
// A gradient along or across the stroke: slices clipped to its outlines, under
// the stroke's opacity as a group.
if let Some(ws) = stroke::written_slices(bp, r, st, outlines)
&& let Some(clip) = to_path(&ws.clip)
&& let Some(clip) = to_path(s, &ws.clip)
{
self.warn("gradients along or across strokes are exported as slices of linear gradients");
if st.opacity < 1.0 {
@@ -1241,7 +1292,7 @@ impl Exporter<'_> {
s.set_stroke(None);
for (shape, paint) in &ws.slices {
let b = shape.bounding_box();
if let (Some(paint), Some(p)) = (self.paint(paint, b), to_path(shape)) {
if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) {
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
}
@@ -1260,8 +1311,10 @@ impl Exporter<'_> {
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
for p in outlines.iter().filter_map(to_path) {
s.draw_path(&p);
for outline in outlines {
if let Some(p) = to_path(s, outline) {
s.draw_path(&p);
}
}
s.set_fill(None);
}
@@ -1315,14 +1368,16 @@ impl Exporter<'_> {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(&all).map(|p| (all, p))
to_path(s, &all).map(|p| (all, p))
});
let all = all.flatten();
if let Some((bp, path)) = &all {
self.text_items(s, below, bp, path, page, tb);
}
s.push_transform(&xf(t.xf));
// Real text: the characters' fills as text in embedded fonts.
// Real text: the characters' fills as text in embedded fonts. The type's transform is
// pushed for each fill and stroke of the characters, inside its form when the fill or
// stroke overprints.
let text_space = xf(t.xf);
let plain = (!self.outline_text).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
@@ -1338,6 +1393,7 @@ impl Exporter<'_> {
bp.extend(run_bars.iter());
let fill = &run.style.fill;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
s.push_transform(&text_space);
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => {
@@ -1348,26 +1404,31 @@ impl Exporter<'_> {
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
if let Some(path) = to_path(&outlines)
if let Some(path) = to_path(s, &outlines)
&& !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
&& let Some(paint) = ex.paint(fill, layout.bounds)
&& let Some(paint) = ex.paint(s, fill, layout.bounds)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
s.pop();
});
if run.style.has_stroke()
&& let Some(path) = to_path(&bp)
{
s.push_transform(&text_space);
let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten();
s.pop();
if let Some(path) = path {
// Character strokes are drawn in text space, with their cap, join and dashes.
let st = run.style.stroke_layer();
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds));
self.overprinting(s, st.overprint, &st.paint, |ex, s| {
s.push_transform(&text_space);
ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds);
s.pop();
});
}
}
s.set_fill(None);
s.set_stroke(None);
s.pop();
if self.inline_depth < MAX_INLINE_DEPTH {
self.inline_depth += 1;
for ig in &layout.inlines {
@@ -1393,7 +1454,7 @@ impl Exporter<'_> {
match item {
AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
&& let Some(paint) = ex.paint(&fl.paint, tb)
&& let Some(paint) = ex.paint(s, &fl.paint, tb)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
@@ -1468,9 +1529,9 @@ impl Exporter<'_> {
}
/// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where
/// `bounds` is).
/// `bounds` is). A line that would be written with a pen position, size or spacing (in
/// thousandths of an em) beyond [`MAX_COORD`] is left out.
fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) {
let Some(paint) = self.paint_in(fill, bounds, line.frame.inverse()) else { return };
let size = line.size;
let glyphs: Vec<KrillaGlyph> = line
.glyphs
@@ -1481,10 +1542,18 @@ impl Exporter<'_> {
KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None)
})
.collect();
let pens =
line.glyphs.iter().fold(Rect::new(0.0, -size, 0.0, size), |r, (_, x, advance, _)| r.union_pt((*x, 0.0)).union_pt((x + advance, 0.0)));
let spacing: Vec<f64> = glyphs.iter().map(|g| f64::from(g.x_advance(1.0)) * 1000.0).collect();
if !(writable(s, line.frame, pens) && in_range(&spacing)) {
return;
}
s.push_transform(&xf(line.frame));
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false);
if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false);
}
s.pop();
}
@@ -1558,6 +1627,10 @@ impl Exporter<'_> {
}
fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) {
// An image placed or sized beyond [`MAX_COORD`] is left out.
if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) {
return;
}
// The size it is placed at: its pixel grid's sides through its transform.
let [a, b, c, d, ..] = im.xf.as_coeffs();
let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d));
@@ -1586,3 +1659,29 @@ fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image>
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writable_bounds_values_the_transform_and_values_through_it() {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap()));
let mut s = page.surface();
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
assert!(writable(&s, Affine::IDENTITY, r));
assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0)));
assert!(!writable(&s, Affine::IDENTITY, Rect::new(f64::NAN, 0.0, 10.0, 10.0)));
assert!(!writable(&s, Affine::translate((0.0, 2e9)), r));
assert!(!writable(&s, Affine::scale(2e9), Rect::ZERO));
// `writable` also checks the surface's own transform: with the surface moved to
// x = MAX_COORD, a point at the origin fits and a 10 by 10 box does not.
s.push_transform(&xf(Affine::translate((MAX_COORD, 0.0))));
assert!(writable(&s, Affine::IDENTITY, Rect::ZERO));
assert!(!writable(&s, Affine::IDENTITY, r));
s.pop();
s.finish();
page.finish();
}
}
+74 -1
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject,
};
@@ -268,6 +268,79 @@ fn objects_outside_artboard_are_culled() {
assert_eq!(leaves(&roundtrip(&d)).len(), 1);
}
/// A line from (10, `y`) to (90, `y`).
fn line(y: f64) -> PathData {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((10.0, y));
bp.line_to((90.0, y));
PathData::from_bezpath(&bp)
}
/// Art that the PDF would hold a value beyond a billion points for is left out: a path that far,
/// a gradient placed that far, a stroke so wide that its group's box reaches that far, and an
/// image placed that far. Each would be written with -2,147,483,648, which the PDF writer panics
/// on in builds with overflow checks.
#[test]
fn art_reaching_beyond_a_billion_points_is_left_out() {
let far = -2_147_483_649.0;
let shaded = |kind, start, end| {
let mut n = rect_node(Rect::new(60.0, 10.0, 90.0, 50.0), Color::BLACK);
let geom = GradientGeom { start, end, aspect: 1.0, focal: None };
let g = GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient { kind, ..Gradient::default() }) };
n.appearance.set_fill(Paint::Gradient(Box::new(g)));
n
};
let mut wide = Node::path(NodeId(0), line(0.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4_294_967_296.0));
wide.opacity = 0.5;
let image = ImageObject {
key: "img1".into(),
width: 4,
height: 2,
xf: Affine::new([5.5e8, 0.0, 0.0, 10.0, far, 60.0]),
link: None,
placement: Default::default(),
};
for (what, n) in [
("path", rect_node(Rect::new(far, 60.0, 90.0, 90.0), Color::BLACK)),
("linear gradient", shaded(GradientKind::Linear, Point::new(far, 0.0), Point::new(90.0, 0.0))),
("radial gradient", shaded(GradientKind::Radial, Point::new(far, 30.0), Point::new(far + 100.0, 30.0))),
("stroke", wide),
("image", Node::new(NodeId(0), NodeKind::Image(Box::new(image)))),
] {
let mut d = doc(100.0, 100.0);
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
add(&mut d, n);
assert_eq!(leaves(&roundtrip(&d)).len(), 1, "{what}");
}
}
/// A dash offset beyond a billion points is written as its remainder after whole periods of the
/// pattern: -2,147,483,649 (-2,147,483,648 as an f32, which the PDF writer panics on in builds
/// with overflow checks) is 1 in a pattern of 5 and 5.
#[test]
fn dash_offsets_beyond_a_billion_points_wrap_into_their_pattern() {
let mut d = doc(100.0, 100.0);
let mut n = Node::path(NodeId(0), line(50.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
n.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![5.0, 5.0], offset: -2_147_483_649.0, align_corners: false });
add(&mut d, n);
assert!(uncompressed(&d).contains("[5 5]1 d"));
}
/// The page of an artboard beyond a billion points is left blank: what is on it would be placed
/// through -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
#[test]
fn artboards_beyond_a_billion_points_export_blank_pages() {
let mut d = doc(100.0, 100.0);
d.artboards[0].rect = Rect::new(2_147_483_649.0, 0.0, 2_147_483_749.0, 100.0);
let mut n = rect_node(Rect::new(2_147_483_659.0, 10.0, 2_147_483_699.0, 50.0), Color::BLACK);
n.opacity = 0.5;
add(&mut d, n);
let bytes = export(&d, &PdfOptions::default()).unwrap();
assert_eq!(page_count(&bytes), 1);
assert!(leaves(&import(&bytes).unwrap()).is_empty());
}
#[test]
fn opacity_and_blend_modes() {
let mut d = doc(200.0, 200.0);
+52
View File
@@ -110,6 +110,58 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// A run shifted two billion points off its baseline is left out, as real text or outlines, and
/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted
/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
#[test]
fn a_run_shifted_two_billion_points_is_left_out() {
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
t.runs = vec![
TextRun { text: "in".into(), style: style(12.0), inline: None },
TextRun { text: "out".into(), style: CharStyle { baseline_shift: -2_147_483_649.0, ..style(12.0) }, inline: None },
];
let mut d = doc(vec![t]);
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)), ["in"]);
let b = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
assert!(b.y0 > 0.0 && b.y1 < 50.0, "{b:?}");
}
/// An overprinting run is written, or left out, as the same run is without overprint: the form
/// marked to overprint the run holds the type's transform. For type moved 2,147,483,689 points down
/// with its run shifted back onto a page 50 points high, a form placed through that transform would
/// sit at y = -2,147,483,648, which the PDF writer panics on in builds with overflow checks. For
/// type scaled 3 times, a form without that transform would hold a run shifted 500,000,000 points
/// up, and the export would write the run 1,500,000,000 points up.
#[test]
fn overprinting_runs_are_written_or_left_out_as_other_runs_are() {
let moved = |overprint_fill| {
let back = CharStyle { baseline_shift: 2_147_483_649.0, overprint_fill, ..style(12.0) };
TextObject::point(Point::new(5.0, 2_147_483_689.0), "moved", back)
};
let scaled = |overprint_fill| {
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
t.xf *= Affine::scale(3.0);
let up = CharStyle { baseline_shift: 500_000_000.0, overprint_fill, ..style(12.0) };
t.runs = vec![TextRun { text: "in".into(), style: style(12.0), inline: None }, TextRun { text: "out".into(), style: up, inline: None }];
t
};
let written = |mut t: TextObject| {
// The export culls type by the laid-out bounds that the engine keeps in `cached_bounds`.
t.cached_bounds = Some(vectorcraft_text::layout(FontDb::global(), &t).bounds);
let mut d = doc(vec![t]);
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
pdf(&d, false).bytes
};
let text_of = |bytes: &[u8]| texts(&import_as(bytes, TextAs::Text));
let (on, off) = (written(moved(true)), written(moved(false)));
assert!(String::from_utf8_lossy(&on).contains("/VCop gs"), "an overprinting form");
assert_eq!(text_of(&on), ["moved"]);
assert_eq!(text_of(&off), ["moved"]);
assert_eq!(text_of(&written(scaled(true))), ["in"]);
assert_eq!(text_of(&written(scaled(false))), ["in"]);
}
/// A named instance of a variable font is real text in that instance: its embedded subset draws
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
#[test]
+374
View File
@@ -4194,3 +4194,377 @@ lighting Light Luz
@msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error)
Concentric Dividers: Divisiones concéntricas:
Radial Dividers: Divisiones radiales:
# Rows other catalogs already had: document and print presets, keyboard-shortcut editor, print and
# PDF options, arrowheads, harmony rules, Save for Web, graphic styles and file filters.
@id pathfinder.add Unir
3 x 3 Average Promedio de 3 x 3
5 x 5 Average Promedio de 5 x 5
PDF File Archivo PDF
1 Color Job Trabajo a 1 color
2 Color Job Trabajo a 2 colores
3 Color Job Trabajo a 3 colores
CMYK profile in effect Perfil CMYK en vigor
CMYK Equivalents Equivalentes CMYK
effect Soft Suave
effect Horizontal Horizontal
effect Vertical Vertical
theme Appearance Apariencia
effect Points Puntos
effect Regular Regular
Phone 393×852 Teléfono 393×852
Phone 430×932 Teléfono 430×932
Phone 360×800 Teléfono 360×800
Phone 412×915 Teléfono 412×915
Phone 375×667 Teléfono 375×667
Tablet 768×1024 Tableta 768×1024
Tablet 820×1180 Tableta 820×1180
Tablet 834×1194 Tableta 834×1194
Tablet 1024×1366 Tableta 1024×1366
Tablet 800×1280 Tableta 800×1280
Watch 184×224 Reloj 184×224
Watch 198×242 Reloj 198×242
Web 1600×900 Web 1600×900
Web 1440×900 Web 1440×900
Web 1366×768 Web 1366×768
Web 1280×800 Web 1280×800
Web 1024×768 Web 1024×768
Legal Legal
Tabloid Tabloide
A3 A3
A4 A4
A5 A5
B4 B4
B5 B5
HDTV 1080p HDTV 1080p
HDTV 720p HDTV 720p
4K UHD 4K UHD
8K UHD 8K UHD
2K Cinema Cine 2K
4K Cinema Cine 4K
Greeting Card Tarjeta de felicitación
Square 8 × 8 in Cuadrado de 8 × 8 pulgadas
Poster 18 × 24 in Póster de 18 × 24 pulgadas
Poster 24 × 36 in Póster de 24 × 36 pulgadas
Logo 1000×1000 Logotipo 1000×1000
Business Card 3.5 × 2 in Tarjeta de presentación de 3.5 × 2 pulgadas
Business Card 85 × 55 mm Tarjeta de presentación de 85 × 55 mm
Letterhead Letter Membrete Carta
Letterhead A4 Membrete A4
Envelope DL Sobre DL
Envelope #10 Sobre #10
Social Portrait Post 1080×1350 Publicación vertical para redes 1080×1350
Social Landscape Post 1200×630 Publicación horizontal para redes 1200×630
Social Story 1080×1920 Historia para redes 1080×1920
Social Tall Post 1000×1500 Publicación alta para redes 1000×1500
Profile Cover 1500×500 Portada de perfil 1500×500
Page Cover 1640×624 Portada de página 1640×624
Video Banner 2560×1440 Banner de video 2560×1440
Video Thumbnail 1280×720 Miniatura de video 1280×720
Keyboard Shortcuts Métodos abreviados de teclado
Set: Conjunto:
Reset to Defaults Restablecer valores predeterminados
Tool Herramienta
Command Comando
Shortcut Método abreviado
Menu Commands Comandos de menú
Press keys… Presiona las teclas…
Use Default Usar predeterminado
Go to Conflict Ir al conflicto
Remove the shortcut Quitar el método abreviado
Click, then press the new shortcut (Esc cancels) Haz clic y presiona el nuevo método abreviado (Esc cancela)
All shortcuts reset to defaults. Se restablecieron todos los métodos abreviados a los valores predeterminados.
Imported. Press OK to keep the imported set. Importado. Presiona Aceptar para conservar el conjunto importado.
⚠ {chord} was already used by {names} — it has been removed there. Atención: {chord} ya estaba en uso por {names}; se quitó de allí.
VectorCraft Defaults Valores predeterminados de VectorCraft
@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} método abreviado está asignado más de una vez|{n} métodos abreviados están asignados más de una vez
Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). «Simular» imprime las sobreimpresiones tal como las muestra la vista previa de sobreimpresión; las separaciones siempre las conservan. Un preajuste acopla la transparencia antes de imprimir (los archivos PostScript siempre la acoplan).
Preserve Conservar
Simulate Simular
Portrait (flipped) Vertical (invertida)
Landscape (flipped) Horizontal (invertida)
Visible & Printable Layers Capas visibles e imprimibles
Visible Layers Capas visibles
All Layers Todas las capas
Do Not Scale No escalar
Fit to Page Ajustar a la página
Custom Scale Escala personalizada
Tile Imageable Areas Mosaico de áreas imprimibles
Composite Compuesto
Separations Separaciones
Up (right reading) Arriba (lectura correcta)
Down (right reading) Abajo (lectura correcta)
Positive Positivo
Negative Negativo
Subset Subconjunto
Complete Completa
[Custom] [Personalizado]
[Default] [Predeterminado]
No downsampling Sin reducción de resolución
Subsample Submuestreo
Bicubic Bicúbica
Minimum Mínima
Run Length Compresión por longitud de secuencia
Roman Romano
No conversion Sin conversión
Convert to destination Convertir al destino
Convert to destination (preserve numbers) Convertir al destino (conservar valores)
Don't include profiles No incluir perfiles
Include all profiles Incluir todos los perfiles
Include destination profile Incluir perfil de destino
Include tagged source profiles Incluir perfiles de origen etiquetados
Low resolution (150 ppi) Baja resolución (150 ppp)
High resolution Alta resolución
Inserting, deleting and rotating pages Insertar, eliminar y rotar páginas
Filling in form fields and signing Rellenar campos de formulario y firmar
Commenting, filling in form fields and signing Comentar, rellenar campos de formulario y firmar
Any except extracting pages Cualquiera excepto extraer páginas
Bounding Box Cuadro delimitador
Mobile Móvil
Film & Video Cine y video
Art & Illustration Arte e ilustración
Branding Identidad de marca
Social Redes sociales
Triangle Triángulo
Triangle (open) Triángulo (abierto)
Circle (open) Círculo (abierto)
Square (open) Cuadrado (abierto)
Bar Barra
Arrow Flecha
Arrow (open) Flecha (abierta)
Barbed Con púas
Half Arrow (left) Media flecha (izquierda)
Half Arrow (right) Media flecha (derecha)
Concave Cóncava
Double Bar Doble barra
Feather Emplumado
Dot on Bar Punto sobre barra
Chevron Galón
Double Arrow Doble flecha
Cross Cruz
Plus Signo más
Hexagon Hexágono
Hexagon (open) Hexágono (abierto)
Tag Etiqueta
Tag (open) Etiqueta (abierta)
Half Circle Semicírculo
Drop Gota
Slash Barra inclinada
Double Slash Doble barra inclinada
Diamond (open) Rombo (abierto)
Reverse Triangle Triángulo invertido
Swallowtail Cola de golondrina
Bracket Corchete
Fork Horquilla
Leaf Hoja
Kite Cometa
Triangle to Bar Triángulo a barra
Oval Óvalo
Complementary Complementario
Complementary 2 Complementario 2
Split Complementary Complementario dividido
Left Complement Complemento izquierdo
Right Complement Complemento derecho
Analogous Análogo
Analogous 2 Análogo 2
Monochromatic Monocromático
Shades Sombras
Triad Tríada
Triad 2 Tríada 2
Triad 3 Tríada 3
Tetrad Tétrada
Tetrad 2 Tétrada 2
Tetrad 3 Tétrada 3
Compound Compuesto
Compound 2 Compuesto 2
High Contrast Alto contraste
High Contrast 2 Alto contraste 2
High Contrast 3 Alto contraste 3
Pentagram Pentagrama
Perceptual Perceptual
Relative Colorimetric Colorimétrico relativo
Absolute Colorimetric Colorimétrico absoluto
Exact Exacto
Preserve Tints Conservar matices
Scale Tints Escalar matices
Linear Lineal
Freeform Libre
8 Colors 8 colores
16 Colors 16 colores
256 Colors 256 colores
True Colors Colores verdaderos
Preserve Appearance Conservar apariencia
Maximize Editability Maximizar editabilidad
Clip Group Grupo de recorte
Guide Guía
Symbol Símbolo
Envelope Envolvente
Mesh Malla
Placed Document Documento colocado
Radial Repeat Repetición radial
Grid Repeat Repetición de cuadrícula
Mirror Repeat Repetición reflejada
Auto Size Tamaño automático
Shrink Text to Fit Reducir texto para que quepa
Ascent Ascendente
Cap Height Altura de mayúsculas
x Height Altura de la x
Justify Justificar
First Baseline Primera línea de base
Vertical Align Alineación vertical
Save Selection Guardar selección
Twirl Molinete
Pucker Fruncir
Bloat Engordar
Scallop Festonear
Wrinkle Arrugar
Unknown Desconocido
Copyrighted Con copyright
Public Domain Dominio público
None (Color Preview) Ninguno (vista previa de color)
Rasterized Complex Regions Regiones complejas rasterizadas
Transparent Objects Objetos transparentes
All Affected Objects Todos los objetos afectados
Expanded Patterns Motivos expandidos
Outlined Strokes Trazos contorneados
Outlined Text Texto contorneado
All Rasterized Regions Todas las regiones rasterizadas
High Resolution Alta resolución
Medium Resolution Resolución media
Low Resolution Baja resolución
Copyright and Contact Info Copyright e información de contacto
All Slices Todos los sectores
Selected Slices Sectores seleccionados
No Slices Sin sectores
Images Only Solo imágenes
HTML and Images HTML e imágenes
Selective Selectivo
Adaptive Adaptativo
Black & White Blanco y negro
No Dither Sin tramado
Diffusion Difusión
Unsorted Sin ordenar
Sort by Hue Ordenar por tono
Sort by Luminance Ordenar por luminancia
Sort by Popularity Ordenar por popularidad
Baseline (Standard) Secuencial (estándar)
Baseline Optimized Secuencial optimizado
Art Optimized Optimizado para ilustración
Type Optimized Optimizado para texto
Language Level 2 Nivel de lenguaje 2
Language Level 3 Nivel de lenguaje 3
TIFF (Black & White) TIFF (blanco y negro)
TIFF (8-bit Color) TIFF (color de 8 bits)
1 bit 1 bit
4 bit 4 bits
8 bit 8 bits
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
16 bits/pixel 16 bits/píxel
24 bits/pixel 24 bits/píxel
32 bits/pixel 32 bits/píxel
DWG (use DXF) DWG (usa DXF)
file-filter All readable files Todos los archivos legibles
file-filter All placeable files Todos los archivos que se pueden colocar
file-filter Swatch libraries Bibliotecas de muestras
file-filter Flattener presets Preajustes del acoplador
file-filter PDF presets Preajustes de PDF
file-filter Print presets Preajustes de impresión
file-filter SVG Compressed SVG comprimido
file-filter PDF-compatible .ai .ai compatible con PDF
file-filter VectorCraft Template Plantilla de VectorCraft
file-filter Templates Plantillas
diagnostics render renderizado
diagnostics ui interfaz
Preset {number} Preajuste {number}
PRs PR
@{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} líneas (Δ {delta}), +{binary_added} / −{binary_deleted} recursos binarios, {first} – {last}
@plural {n} PR|{n} PRs {n} PR|{n} PR
@plural {n} commit|{n} commits {n} commit|{n} commits
gradient-kind Radial Radial
mark-style Japanese Japonesas
2-Up 2 vistas
2up 2 vistas
3 Colors 3 colores
4-Up 4 vistas
4up 4 vistas
6 Colors 6 colores
A6 A6
Alt+Click Alt+clic
Alt+Drag Alt+arrastrar
Anchors Anclas
Area Type Texto de área
Basic Básico
Black Outline Contorno negro
Brown Marrón
Convert to Global Convertir en global
Cool Fríos
CRLF (Windows) CRLF (Windows)
Current Colors Colores actuales
Default Graphic Style Estilo gráfico predeterminado
Direct Selection Selección directa
Distance: Distancia:
GIF, 128 colours GIF, 128 colores
Gray Gris
Grey Gris
Group Selection Selección de grupos
Hand Mano
Harmony Rules Reglas de armonía
Heavy Ink Tinta densa
High Fidelity Photo Foto de alta fidelidad
Image Trace (Make and Expand) Calco de imagen (crear y expandir)
Joins: Uniones:
Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Mantiene el documento editable: VectorCraft vuelve a abrir el PDF sin perder nada. Para archivos que volverás a editar.
Lab Spot Colors Tintas planas Lab
Lasso Lazo
Length: Longitud:
LF (macOS, Linux) LF (macOS, Linux)
Line Art Arte lineal
Low Fidelity Photo Foto de baja fidelidad
Make Text Wrap Crear ceñido de texto
Measure Medición
Monochrome Monocromo
Muted Apagados
of de
Orange Naranja
Pattern Swatches Muestras de motivo
PDF 1.7 PDF 1.7
Pen Pluma
Pink Rosa
Pixel Píxel
Points: Puntos:
Purple Púrpura
Release Image Trace Soltar calco de imagen
Release Text Wrap Soltar ceñido de texto
RGB RGB
Rotate View Rotar vista
Scissors Tijeras
Shades of Gray Tonos de gris
Shear: Inclinar:
Shift+Click Shift+clic
Shift+Drag Shift+arrastrar
Silhouettes Siluetas
Sketched Art Arte esbozado
Spectrum Espectro
sRGB IEC61966-2.1 sRGB IEC61966-2.1
Sunshine Soleado
SVG 1.1 SVG 1.1
Technical Drawing Dibujo técnico
Text Displayed in Cell Texto mostrado en la celda
Touch Type Retocar texto
UTF-16 (Unicode) UTF-16 (Unicode)
Variation Variación
VectorCraft Default Predeterminado de VectorCraft
VectorCraft Generic CMYK (SWOP-like) CMYK genérico de VectorCraft (similar a SWOP)
VectorCraft Swatches (.vcswatches) Muestras de VectorCraft (.vcswatches)
Vertical Area Type Texto de área vertical
Vertical Type Texto vertical
Vertical Type on a Path Texto vertical en trazado
Vivid Vivos
Warm Cálidos
WebP WebP
Working RGB RGB de trabajo
X: X:
Y: Y:
@id file.close Cerrar archivo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+27 -1
View File
@@ -114,7 +114,8 @@ fn set_view(app: &mut VectorcraftApp, view: TraceView) {
/// The selected Image Trace object's View row (Control bar, Properties).
pub(crate) fn view_row(app: &mut VectorcraftApp, ui: &mut Ui, current: TraceView, width: f32) {
widgets::dim_label(ui, tl!("View:"));
if let Some(v) = view_dropdown(ui, current, width) {
// No wider than what the label leaves of the row (#1358).
if let Some(v) = view_dropdown(ui, current, width.min(ui.available_width())) {
set_view(app, v);
}
}
@@ -419,6 +420,31 @@ mod tests {
app
}
/// #1358: in Properties the View row fits the panel, so the panel doesn't widen and clip its
/// labels (the row took the panel's whole width for its menu, beside its label).
#[test]
fn the_view_row_fits_the_properties_panel() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// Where the panel's content ends after a few frames.
let right = |app: &mut VectorcraftApp| {
let mut right = 0.0;
for _ in 0..3 {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
crate::panels::properties::show(app, ui);
right = ui.min_rect().right();
});
out.textures_delta.clear();
}
right
};
let traced = right(&mut app);
app.run("select.none", json!({})).unwrap();
assert_eq!(traced, right(&mut app));
}
#[test]
fn advanced_is_a_section_that_stays_as_it_was_left() {
let mut app = traced_app();
+37 -6
View File
@@ -95,7 +95,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The user's choice alone: a window too short for the tools scrolls them.
let cols = if app.ui.toolbar_double { 2 } else { 1 };
let w = if cols == 2 { 76.0 } else { WIDTH };
// Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they
// overran the right border).
let w = if cols == 2 { 80.0 } else { WIDTH };
// Where the panel docks: along the window's left edge, under the bars.
let edge = ui.available_rect_before_wrap();
ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));
@@ -199,11 +201,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
// Centred on the panel's inside (`bounds`: within its border and, docked, the
// separator line egui reserves on its right), not on `WIDTH`, which sat them 2½ pt
// right of centre docked; nor on this row, which the scroll area starts ½ pt in.
ui.add_space((bounds.center().x - cols as f32 * 18.0 - ui.cursor().min.x).max(0.0));
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
@@ -862,6 +863,36 @@ pub(crate) mod tests {
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// The tool buttons sit in the middle of the panel, between its border and the separator
/// line, docked and floating, in one or two columns (docked, they sat 2½ pt right of centre).
#[test]
fn the_tool_buttons_are_centred_in_the_panel() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
for (double, floating) in [(false, None), (true, None), (false, Some([100.0, 100.0])), (true, Some([100.0, 100.0]))] {
app.run("window.toolbarColumns", json!({ "double": double })).unwrap();
app.ui.toolbar_pos = floating;
frame(&mut app, &ctx, 0.0, vec![]);
let tools = frame(&mut app, &ctx, 0.1, vec![]);
// The title bar spans the panel's inside.
let hdr = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.rect.height() == 14.0 && w.rect.width() > 40.0)
.map(|w| w.rect)
.next()
});
let hdr = hdr.unwrap();
let first = tools[0];
let row_right = tools.iter().filter(|r| r.top() == first.top()).map(|r| r.right()).fold(first.right(), f32::max);
assert_eq!(row_right - hdr.center().x, hdr.center().x - first.left(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}");
assert!(first.left() >= hdr.left() && row_right <= hdr.right(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}");
}
}
/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a
/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel.
#[test]