Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71174477cc | ||
|
|
8716fc8fbc | ||
|
|
268a4452f9 | ||
|
|
f97b24b388 | ||
|
|
a747daa346 | ||
|
|
2d619baaba | ||
|
|
f45ffdeb96 | ||
|
|
d6f1c9862d | ||
|
|
5a8682e45d | ||
|
|
52e9b25371 | ||
|
|
5d5e7a1dd2 | ||
|
|
42b355c21c | ||
|
|
a48ed5e253 | ||
|
|
aca313f10a | ||
|
|
62bf8c862c | ||
|
|
1dc61af4d1 | ||
|
|
3c3c249bc0 | ||
|
|
1909827299 | ||
|
|
cbea134e9c | ||
|
|
8115d58c3f | ||
|
|
617b78b35f | ||
|
|
5a09dfec81 | ||
|
|
6944497076 | ||
|
|
82db346d52 | ||
|
|
84336342f3 | ||
|
|
23ec8d9e67 | ||
|
|
29ecab8c48 | ||
|
|
932c127323 | ||
|
|
44685d8798 | ||
|
|
c85570c8b2 | ||
|
|
18e538ec94 | ||
|
|
6ab08dd407 | ||
|
|
0d244440a9 | ||
|
|
55e0459e21 | ||
|
|
9c41d78787 | ||
|
|
bdbcf8e1c5 | ||
|
|
7046c52520 | ||
|
|
8a7bf7920b | ||
|
|
43c988f099 | ||
|
|
4f280f0e1a | ||
|
|
b29e271b4a | ||
|
|
ae061eacfb | ||
|
|
497172bc97 | ||
|
|
307a5a6298 | ||
|
|
1e76e389d2 | ||
|
|
a99b1200ff | ||
|
|
88e9381de6 | ||
|
|
3428a224bb | ||
|
|
563ff62aa0 | ||
|
|
21c580a934 | ||
|
|
976d1bb428 | ||
|
|
96a783e716 | ||
|
|
8cb5fa0607 | ||
|
|
e74c2f113c | ||
|
|
5432821480 |
No files matched your search
@@ -0,0 +1,35 @@
|
||||
name: Desktop printing
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "apps/vectorcraft/Cargo.toml"
|
||||
- "apps/vectorcraft/src/printing.rs"
|
||||
- "apps/vectorcraft/src/printing/**"
|
||||
- "crates/ui-egui/src/print.rs"
|
||||
- "crates/ui-egui/src/tests_printps.rs"
|
||||
- ".github/workflows/print.yml"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: desktop-print
|
||||
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
|
||||
- run: cargo test --locked -p vectorcraft
|
||||
- name: Check pending submission and queue refusal feedback
|
||||
run: cargo test --locked -p vectorcraft-ui-egui tests_printps
|
||||
@@ -1,11 +1,22 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
|
||||
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "apps/vectorcraft/Cargo.toml"
|
||||
- "apps/vectorcraft/src/printing.rs"
|
||||
- "apps/vectorcraft/src/printing/**"
|
||||
- "crates/ui-egui/src/print.rs"
|
||||
- "crates/ui-egui/src/tests_printps.rs"
|
||||
- "packaging/windows/windows7.ps1"
|
||||
- ".github/workflows/windows7.yml"
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
Generated
+36
-27
@@ -806,6 +806,14 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -4482,11 +4490,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
@@ -4515,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4526,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4538,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4554,7 +4563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4575,7 +4584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4587,7 +4596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4604,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4619,7 +4628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4654,7 +4663,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4674,7 +4683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4688,7 +4697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4700,7 +4709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4719,7 +4728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4733,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4745,7 +4754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4774,7 +4783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4788,7 +4797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4813,7 +4822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4837,7 +4846,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4854,7 +4863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4874,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",
|
||||
@@ -4896,7 +4905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4909,7 +4918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4942,7 +4951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6052,7 +6061,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-1
@@ -4,13 +4,14 @@ 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"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -217,7 +217,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
+4
-3
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
@@ -106,7 +106,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | none | 0% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
|
||||
@@ -34,6 +34,10 @@ webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
|
||||
craft-print = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
|
||||
|
||||
@@ -1,127 +1,54 @@
|
||||
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
|
||||
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
|
||||
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
|
||||
//! the PDF viewer.
|
||||
|
||||
use std::path::PathBuf;
|
||||
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
|
||||
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
|
||||
//! Windows.Data.Pdf is unavailable there.
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
use std::process::Command;
|
||||
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
|
||||
/// The system's printing.
|
||||
pub struct SystemPrint;
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
mod windows7;
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
use windows7 as system;
|
||||
|
||||
pub struct SystemPrint;
|
||||
impl PrintService for SystemPrint {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
system::printers()
|
||||
}
|
||||
|
||||
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let r = system::print(job, &file);
|
||||
let result = system::print(job);
|
||||
let to = job.printer.unwrap_or("the default printer");
|
||||
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
|
||||
result.map(|note| format!("Sent “{}” to {to}{note}", job.title))
|
||||
}
|
||||
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
|
||||
Some(std::sync::Arc::new(|job| SystemPrint.print(job)))
|
||||
}
|
||||
|
||||
fn has_setup(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
|
||||
system::setup(printer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_names(text: &str) -> Vec<String> {
|
||||
let mut names: Vec<String> = vec![];
|
||||
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
|
||||
if !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
|
||||
/// default destination").
|
||||
#[cfg(any(test, not(windows)))]
|
||||
fn lpstat_default(text: &str) -> Option<String> {
|
||||
let line = text.lines().find(|l| l.contains(':'))?;
|
||||
let name = line.rsplit_once(':')?.1.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
#[cfg(any(test, windows))]
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
#[cfg(any(test, windows))]
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
|
||||
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
|
||||
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
|
||||
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
|
||||
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
let mut c = Command::new("lp");
|
||||
if let Some(p) = job.printer {
|
||||
c.args(["-d", p]);
|
||||
}
|
||||
c.args(["-t", job.title]).arg(file);
|
||||
let r = output(&mut c);
|
||||
// `lp` has spooled its own copy (or failed).
|
||||
let _ = std::fs::remove_file(file);
|
||||
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
|
||||
pub fn print(job: &PrintJob) -> Result<String, String> {
|
||||
// Copies, layout, marks and colour management are already in the PDF. Preserve its
|
||||
// physical sheet size and the queue's duplex default without printing copies twice.
|
||||
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
|
||||
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn setup(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = printer;
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = printer;
|
||||
@@ -131,92 +58,13 @@ mod system {
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
if Command::new("system-config-printer").spawn().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// CUPS' own pages.
|
||||
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
|
||||
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod system {
|
||||
use super::*;
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new("powershell");
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let mut command = {
|
||||
let mut command = Command::new("explorer");
|
||||
command.arg("ms-settings:printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lpstat_lists_names_and_the_default() {
|
||||
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
|
||||
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
|
||||
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
|
||||
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
|
||||
assert_eq!(lpstat_default("no system default destination\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_lines_become_printers() {
|
||||
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
|
||||
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use vectorcraft_ui_egui::print::{PrintJob, Printer};
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
|
||||
// truncate an existing path, and remove incomplete files or refused launches immediately.
|
||||
use std::io::Write as _;
|
||||
let mut file =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
|
||||
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(format!("can't write {}: {e}", path.display()));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new(
|
||||
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe"),
|
||||
);
|
||||
c.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let result = submit(job, &file);
|
||||
if result.is_err() {
|
||||
// No viewer accepted the launch, so no asynchronous reader needs this document.
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn powershell_lines_and_quotes_are_preserved() {
|
||||
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
|
||||
}
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf_for_the_async_viewer() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -85,3 +81,8 @@ sync_github_name = false
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.aguo890]
|
||||
real_name = "Aaron Guo"
|
||||
display_name = "Aaron Guo"
|
||||
sync_github_name = false
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)"]
|
||||
|
||||
@@ -473,7 +473,14 @@ fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
curve::nurbs(degree, &knots, &ctrl, weights)
|
||||
.map(|mut path| {
|
||||
if flags & 1 != 0 {
|
||||
path.close_path();
|
||||
}
|
||||
path
|
||||
})
|
||||
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
|
||||
@@ -270,6 +270,53 @@ fn nurbs_splines_stay_within_a_hundredth() {
|
||||
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_point_splines_honor_closed_flags_without_changing_their_curves() {
|
||||
for returns in [false, true] {
|
||||
for rational in [false, true] {
|
||||
let mut original_curves = None;
|
||||
for closed in [false, true] {
|
||||
let flags = 8 | if rational { 4 } else { 0 } | i32::from(closed);
|
||||
let mut e = vec![(0, "SPLINE".to_string()), (70, flags.to_string()), (71, "3".into()), (72, "11".into()), (73, "7".into())];
|
||||
for k in [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2] {
|
||||
e.push((40, k.to_string()));
|
||||
}
|
||||
let end = if returns { (20.0, 20.0) } else { (50.0, 25.0) };
|
||||
for ((x, y), weight) in [(20.0, 20.0), (80.0, 0.0), (80.0, 80.0), (20.0, 80.0), (0.0, 80.0), (0.0, 20.0), end]
|
||||
.into_iter()
|
||||
.zip([1.0, 0.7, 0.7, 1.0, 0.9, 0.9, 1.0])
|
||||
{
|
||||
e.extend([(10, x.to_string()), (20, y.to_string()), (30, "0".into())]);
|
||||
if rational {
|
||||
e.push((41, weight.to_string()));
|
||||
}
|
||||
}
|
||||
let e: Vec<_> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [n] = &leaves(&doc)[..] else { panic!("one spline") };
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert_eq!(path.subpaths[0].closed, closed, "flags {flags}, returns {returns}");
|
||||
let curves: Vec<_> = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).copied().collect();
|
||||
assert!(!curves.is_empty());
|
||||
if let Some(original) = &original_curves {
|
||||
assert_eq!(&curves, original, "closure preserves the rational/non-rational curves");
|
||||
} else {
|
||||
original_curves = Some(curves);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for flags in [8, 9] {
|
||||
let flags_text = flags.to_string();
|
||||
let e =
|
||||
[(0, "SPLINE"), (70, flags_text.as_str()), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [n] = &leaves(&doc)[..] else { panic!("one fit-point spline") };
|
||||
assert_eq!(n.path_data().unwrap().subpaths[0].closed, flags & 1 != 0, "fit-point fallback stays closed/open");
|
||||
}
|
||||
}
|
||||
|
||||
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
|
||||
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
|
||||
vec![
|
||||
|
||||
@@ -175,6 +175,60 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
let make_doc = |keep_closure: bool| {
|
||||
doc_with(|d| {
|
||||
cases
|
||||
.iter()
|
||||
.map(|&(closed, returns)| {
|
||||
let start = Point::new(20.0, 20.0);
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to(start);
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
if keep_closure {
|
||||
b.close_path();
|
||||
} else if !returns {
|
||||
// The open reference has the same final straight segment.
|
||||
b.line_to(start);
|
||||
}
|
||||
}
|
||||
path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 0.0)), 2.0)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let closed = make_doc(true);
|
||||
let open = make_doc(false);
|
||||
for version in DxfVersion::ALL {
|
||||
let out = export(&closed, &DxfOptions { version, ..opts(&closed) }).unwrap();
|
||||
let actual = entities(&out);
|
||||
if version == DxfVersion::R12 {
|
||||
let polys: Vec<_> = actual.iter().filter(|e| kind(e) == "POLYLINE").collect();
|
||||
assert_eq!(polys.len(), cases.len());
|
||||
for (poly, &(closed, _)) in polys.iter().zip(&cases) {
|
||||
assert_eq!(get(poly, 70), Some(if closed { "1" } else { "0" }));
|
||||
}
|
||||
assert!(!actual.iter().any(|e| kind(e) == "SPLINE"));
|
||||
continue;
|
||||
}
|
||||
let reference = entities(&export(&open, &DxfOptions { version, ..opts(&open) }).unwrap());
|
||||
assert_eq!(actual.len(), cases.len());
|
||||
for ((spline, reference), &(closed, _)) in actual.iter().zip(&reference).zip(&cases) {
|
||||
assert_eq!(kind(spline), "SPLINE");
|
||||
assert_eq!(get(spline, 70), Some(if closed { "9" } else { "8" }), "{version:?}");
|
||||
let mut normalized = spline.clone();
|
||||
normalized.iter_mut().filter(|p| p.0 == 70).for_each(|p| p.1 = "8".into());
|
||||
assert_eq!(&normalized, reference, "all other entity properties, knots and controls: {version:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_map_to_the_index_palette_or_true_colour() {
|
||||
let d = shapes_doc();
|
||||
|
||||
@@ -551,6 +551,9 @@ pub struct Document {
|
||||
pub template: bool,
|
||||
#[serde(default)]
|
||||
pub units: Unit,
|
||||
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub ruler_origin: Point,
|
||||
#[serde(default)]
|
||||
pub color_mode: ColorMode,
|
||||
pub artboards: Vec<Artboard>,
|
||||
@@ -706,6 +709,7 @@ impl Document {
|
||||
title: "Untitled-1".into(),
|
||||
template: false,
|
||||
units: Unit::Points,
|
||||
ruler_origin: Point::ZERO,
|
||||
color_mode: mode,
|
||||
artboards: vec![Artboard {
|
||||
id: 1,
|
||||
|
||||
+13
-135
@@ -1,6 +1,6 @@
|
||||
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -165,9 +165,17 @@ impl Selection {
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
/// selected.
|
||||
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
||||
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
self.remove(o);
|
||||
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
|
||||
if uneditable.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.objects.retain(|o| !uneditable.contains(o));
|
||||
self.anchors.retain(|id, _| !uneditable.contains(id));
|
||||
self.segments.retain(|id, _| !uneditable.contains(id));
|
||||
if self.key.is_some_and(|k| uneditable.contains(&k)) {
|
||||
self.key = None;
|
||||
}
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
@@ -208,134 +216,4 @@ impl Selection {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_ops() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.add(NodeId(1));
|
||||
s.toggle(NodeId(2));
|
||||
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
|
||||
s.toggle(NodeId(1));
|
||||
assert_eq!(s.objects, vec![NodeId(2)]);
|
||||
s.clear();
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_are_selected_on_their_own() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
// Shift-adding an object keeps them; a new selection doesn't.
|
||||
s.add(NodeId(1));
|
||||
assert_eq!(s.guides, vec![2, 0]);
|
||||
s.set([NodeId(1)]);
|
||||
assert!(s.guides.is_empty());
|
||||
// Toggled, a guide joins (or leaves) the selected objects.
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
|
||||
assert!(d.guide_shown(&g) && d.guide_editable(&g));
|
||||
d.node_mut(layer).unwrap().locked = true;
|
||||
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
|
||||
d.node_mut(layer).unwrap().visible = false;
|
||||
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
|
||||
// On no layer, or a layer that's gone: always there.
|
||||
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
|
||||
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
|
||||
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
|
||||
d.guides.push(g);
|
||||
assert_eq!(d.guides_on(layer).count(), 1);
|
||||
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
|
||||
d.node_mut(layer).unwrap().visible = true;
|
||||
d.node_mut(layer).unwrap().locked = false;
|
||||
d.guides.push(crate::Guide::new(false, 5.0));
|
||||
d.guides.push(gone);
|
||||
assert_eq!(d.adopt_guides(), 2);
|
||||
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
|
||||
assert_eq!(d.adopt_guides(), 0, "once");
|
||||
// A guide saved before guides had layers reads as one on no layer.
|
||||
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
|
||||
assert_eq!(old.layer, None);
|
||||
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(2), NodeId(3)]);
|
||||
s.target = Some(NodeId(1));
|
||||
assert_eq!(s.subjects(), &[NodeId(1)]);
|
||||
s.add(NodeId(4));
|
||||
assert_eq!(s.target, None);
|
||||
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
|
||||
s.target = Some(NodeId(1));
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.target, None);
|
||||
s.target = Some(NodeId(1));
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(1), NodeId(2)]);
|
||||
s.key = Some(NodeId(2));
|
||||
s.remove(NodeId(1));
|
||||
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.key, None, "the key object itself left");
|
||||
// Pruning drops objects the document no longer has and clears the key once no other
|
||||
// selected object lies outside it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for _ in 0..4 {
|
||||
let id = d.alloc_id();
|
||||
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
|
||||
}
|
||||
s.set(ids.iter().copied());
|
||||
s.key = Some(ids[3]);
|
||||
d.remove(ids[0]).unwrap();
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
|
||||
s.toggle(ids[1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
|
||||
s.toggle(ids[2]);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
|
||||
// A group key selected with only its own member is cleared too; another selected object
|
||||
// outside the group keeps it.
|
||||
let member = d.alloc_id();
|
||||
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
|
||||
s.set([member, ids[3]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, None, "a group and its own member");
|
||||
s.set([member, ids[3], ids[2]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::Node;
|
||||
|
||||
#[test]
|
||||
fn basic_ops() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.add(NodeId(1));
|
||||
s.toggle(NodeId(2));
|
||||
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
|
||||
s.toggle(NodeId(1));
|
||||
assert_eq!(s.objects, vec![NodeId(2)]);
|
||||
s.clear();
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_are_selected_on_their_own() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
// Shift-adding an object keeps them; a new selection doesn't.
|
||||
s.add(NodeId(1));
|
||||
assert_eq!(s.guides, vec![2, 0]);
|
||||
s.set([NodeId(1)]);
|
||||
assert!(s.guides.is_empty());
|
||||
// Toggled, a guide joins (or leaves) the selected objects.
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
|
||||
assert!(d.guide_shown(&g) && d.guide_editable(&g));
|
||||
d.node_mut(layer).unwrap().locked = true;
|
||||
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
|
||||
d.node_mut(layer).unwrap().visible = false;
|
||||
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
|
||||
// On no layer, or a layer that's gone: always there.
|
||||
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
|
||||
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
|
||||
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
|
||||
d.guides.push(g);
|
||||
assert_eq!(d.guides_on(layer).count(), 1);
|
||||
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
|
||||
d.node_mut(layer).unwrap().visible = true;
|
||||
d.node_mut(layer).unwrap().locked = false;
|
||||
d.guides.push(crate::Guide::new(false, 5.0));
|
||||
d.guides.push(gone);
|
||||
assert_eq!(d.adopt_guides(), 2);
|
||||
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
|
||||
assert_eq!(d.adopt_guides(), 0, "once");
|
||||
// A guide saved before guides had layers reads as one on no layer.
|
||||
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
|
||||
assert_eq!(old.layer, None);
|
||||
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(2), NodeId(3)]);
|
||||
s.target = Some(NodeId(1));
|
||||
assert_eq!(s.subjects(), &[NodeId(1)]);
|
||||
s.add(NodeId(4));
|
||||
assert_eq!(s.target, None);
|
||||
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
|
||||
s.target = Some(NodeId(1));
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.target, None);
|
||||
s.target = Some(NodeId(1));
|
||||
s.set([NodeId(3)]);
|
||||
assert_eq!(s.target, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_object_needs_two_or_more_selected_objects() {
|
||||
let mut s = Selection::default();
|
||||
s.set([NodeId(1), NodeId(2)]);
|
||||
s.key = Some(NodeId(2));
|
||||
s.remove(NodeId(1));
|
||||
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
|
||||
s.remove(NodeId(2));
|
||||
assert_eq!(s.key, None, "the key object itself left");
|
||||
// Pruning drops objects the document no longer has and clears the key once no other
|
||||
// selected object lies outside it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for _ in 0..4 {
|
||||
let id = d.alloc_id();
|
||||
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
|
||||
}
|
||||
s.set(ids.iter().copied());
|
||||
s.key = Some(ids[3]);
|
||||
d.remove(ids[0]).unwrap();
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
|
||||
s.toggle(ids[1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
|
||||
s.toggle(ids[2]);
|
||||
s.prune(&d);
|
||||
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
|
||||
// A group key selected with only its own member is cleared too; another selected object
|
||||
// outside the group keeps it.
|
||||
let member = d.alloc_id();
|
||||
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
|
||||
s.set([member, ids[3]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, None, "a group and its own member");
|
||||
s.set([member, ids[3], ids[2]]);
|
||||
s.key = Some(ids[3]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
|
||||
}
|
||||
|
||||
fn below(r: &mut u64, n: u64) -> u64 {
|
||||
*r ^= *r << 13;
|
||||
*r ^= *r >> 7;
|
||||
*r ^= *r << 17;
|
||||
*r % n.max(1)
|
||||
}
|
||||
|
||||
fn random_group(d: &mut Document, r: &mut u64, depth: u64, ids: &mut Vec<NodeId>) -> Arc<Node> {
|
||||
let id = d.alloc_id();
|
||||
ids.push(id);
|
||||
let count = if depth == 0 { 0 } else { 1 + below(r, 4) };
|
||||
let children = (0..count)
|
||||
.map(|_| {
|
||||
let depth = below(r, depth);
|
||||
random_group(d, r, depth, ids)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(id, children);
|
||||
group.locked = below(r, 6) == 0;
|
||||
group.visible = below(r, 6) != 0;
|
||||
Arc::new(group)
|
||||
}
|
||||
|
||||
fn random_document(r: &mut u64) -> (Document, Vec<NodeId>) {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut ids = vec![];
|
||||
for _ in 0..3 {
|
||||
let mut layer = Node::layer(d.alloc_id(), "L", crate::LayerColor::Preset(0));
|
||||
let children = (0..1 + below(r, 4)).map(|_| random_group(&mut d, r, 3, &mut ids)).collect();
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
layer.locked = below(r, 6) == 0;
|
||||
layer.visible = below(r, 6) != 0;
|
||||
d.layers.push(Arc::new(layer));
|
||||
}
|
||||
(d, ids)
|
||||
}
|
||||
|
||||
fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
let missing = (below(r, 2) == 0).then_some(NodeId(u64::MAX));
|
||||
s.set(ids.iter().copied().filter(|_| below(r, 2) == 0).chain(missing));
|
||||
let k = below(r, s.objects.len() as u64) as usize;
|
||||
s.objects.rotate_left(k);
|
||||
for &o in &s.objects {
|
||||
match below(r, 3) {
|
||||
0 => {
|
||||
s.anchors.insert(o, BTreeSet::from([(0, 0)]));
|
||||
}
|
||||
1 => {
|
||||
s.anchors.insert(o, BTreeSet::from([(0, 1)]));
|
||||
s.segments.insert(o, BTreeSet::from([(0, 0)]));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied();
|
||||
s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0);
|
||||
s.slices = ids.iter().copied().take(below(r, 3) as usize).collect();
|
||||
s.guides = vec![1, 0];
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_uneditable_matches_removing_one_at_a_time() {
|
||||
let (mut unchanged, mut partial) = (0, 0);
|
||||
for seed in 1..400u64 {
|
||||
let mut r = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
|
||||
let (d, mut ids) = random_document(&mut r);
|
||||
if seed % 4 == 0 {
|
||||
ids.retain(|id| d.is_editable(*id));
|
||||
}
|
||||
let s = random_selection(&mut r, &ids);
|
||||
let mut expected = s.clone();
|
||||
for o in s.objects.iter().copied().filter(|o| !d.is_editable(*o)) {
|
||||
expected.remove(o);
|
||||
}
|
||||
let mut actual = s.clone();
|
||||
actual.deselect_uneditable(&d);
|
||||
assert_eq!(actual, expected, "seed {seed}");
|
||||
unchanged += usize::from(actual == s);
|
||||
partial += usize::from(!actual.objects.is_empty() && actual.objects.len() < s.objects.len());
|
||||
}
|
||||
assert!(unchanged > 10 && partial > 100, "{unchanged} {partial}");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
||||
use vectorcraft_geom::{Affine, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::*;
|
||||
@@ -37,6 +37,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
|
||||
cmd!(
|
||||
"document.setRulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
|
||||
has_doc,
|
||||
set_ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"document.resetRulerOrigin",
|
||||
"Reset Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
|
||||
has_doc,
|
||||
reset_ruler_origin
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -180,6 +199,35 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.setRulerOrigin";
|
||||
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
change_ruler_origin(s, origin, "Ruler Origin")
|
||||
}
|
||||
|
||||
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.resetRulerOrigin";
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
|
||||
};
|
||||
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
|
||||
}
|
||||
|
||||
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
|
||||
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
|
||||
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
|
||||
}
|
||||
if s.doc()?.doc.ruler_origin != origin {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = origin;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!(origin))
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
pub(crate) fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
|
||||
@@ -148,21 +148,24 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
load_with(name, bytes, &LoadOptions::default())
|
||||
}
|
||||
|
||||
/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its
|
||||
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
|
||||
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
|
||||
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
|
||||
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
|
||||
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
|
||||
/// Anything else as it was.
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return (doc, warnings);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
|
||||
// The note that the art outside the artboard is lost is the reason the layers weren't read.
|
||||
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
|
||||
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
}
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -96,7 +96,9 @@ impl LoadOptions {
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
|
||||
}
|
||||
|
||||
/// The PDF import options: `pages` resolved against the file's page count.
|
||||
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
|
||||
/// `.ai` asked for its crop box opens on its trim boxes, its editor's artboards, with the bleed
|
||||
/// round them (its crop box takes the bleed in).
|
||||
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
|
||||
let pages = match &self.pages {
|
||||
None => None,
|
||||
@@ -105,9 +107,10 @@ impl LoadOptions {
|
||||
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
|
||||
}
|
||||
};
|
||||
let is_ai = self.crop == CropTo::Crop && vectorcraft_pdf::has_illustrator_data(bytes, self.password.as_deref());
|
||||
Ok(ImportOptions {
|
||||
pages,
|
||||
crop: self.crop,
|
||||
crop: if is_ai { CropTo::Trim } else { self.crop },
|
||||
password: self.password.clone(),
|
||||
text_as: self.text_as,
|
||||
layers: self.layers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
|
||||
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
|
||||
//! opens as before, with a warning saying why.
|
||||
//! Illustrator EPS, `.ai` and PDF files open from the editing data they carry: layers, groups,
|
||||
//! names, hidden objects, artboards, colours and images as they were; what the reader doesn't read
|
||||
//! yet opens as before, with a warning saying why.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_testkit::ai;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -81,6 +82,50 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
}
|
||||
|
||||
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
|
||||
#[test]
|
||||
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
|
||||
let mut s = Session::new();
|
||||
let file = ai::pdf(&compressed(&ai::sample_data()), ai::page_pdf());
|
||||
let r = open(&mut s, "art.pdf", &file, json!({})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("pdf"), &json!([])), "{r}");
|
||||
check_sample(&s);
|
||||
// Without it: the PDF part.
|
||||
let r = open(&mut s, "art.pdf", &file, json!({"editingData": false})).unwrap();
|
||||
let layers = &s.doc().unwrap().doc.layers;
|
||||
assert_eq!(layers.len(), 1, "{r}");
|
||||
assert_ne!(names(layers), ["Art"]);
|
||||
}
|
||||
|
||||
/// Art crossing the page's edge opens whole from a PDF's editing data, without the note that the
|
||||
/// PDF's art was clipped to the page; without the editing data it is clipped, with the note.
|
||||
#[test]
|
||||
fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n", ai::rect(150.0, 20.0, 250.0, 80.0))));
|
||||
let file = ai::pdf(data.as_bytes(), "0 g 150 20 100 60 re f");
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "past.pdf", &file, json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), ["Art"]);
|
||||
assert!(doc.art_bounds().is_some_and(|b| b.x1 > 200.5), "{:?}", doc.art_bounds());
|
||||
let r = open(&mut s, "past.pdf", &file, json!({"editingData": false})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
|
||||
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
|
||||
}
|
||||
|
||||
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
|
||||
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
|
||||
#[test]
|
||||
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
|
||||
let damaged = b"%AI12_CompressedData not zlib";
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
|
||||
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
@@ -157,3 +202,43 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
|
||||
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
|
||||
/// as in the editing data.
|
||||
fn bled_ai(bleed: f64) -> Vec<u8> {
|
||||
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
|
||||
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
|
||||
let page = PdfPage {
|
||||
bleed: Some([0.0, 0.0, w, h]),
|
||||
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
|
||||
};
|
||||
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
|
||||
let bleed = 10.0 * 72.0 / 25.4;
|
||||
let file = bled_ai(bleed);
|
||||
let mut s = Session::new();
|
||||
// From the editing data, and from the PDF part alone.
|
||||
for params in [json!({}), json!({"editingData": false})] {
|
||||
let r = open(&mut s, "bled.ai", &file, params.clone()).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.artboards.len(), 1, "{params}: {r}");
|
||||
let ab = doc.artboards[0].rect;
|
||||
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{params}: {ab:?} {r}");
|
||||
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{params}: {:?}", doc.setup.bleed);
|
||||
// The square reaches into the bleed, whole.
|
||||
let art = doc.art_bounds().unwrap();
|
||||
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
|
||||
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{params}: {art:?}");
|
||||
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{params}: {art:?}");
|
||||
}
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "editingData: false reads the PDF part");
|
||||
open(&mut s, "bled.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers are read");
|
||||
}
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Collect in New Layer",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
|
||||
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer), deselecting every selected object left hidden or locked (the key object too) → {id}",
|
||||
has_doc,
|
||||
collect
|
||||
),
|
||||
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
|
||||
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. Every selected object left hidden or locked, such as one moved into a hidden layer or group, is deselected (the key object too). One undo step → {ids}",
|
||||
has_doc,
|
||||
move_rows
|
||||
),
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
|
||||
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers), deselecting every selected object left hidden or locked (the key object too)",
|
||||
has_doc,
|
||||
move_node
|
||||
),
|
||||
@@ -589,6 +589,7 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(lid)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
@@ -713,6 +714,7 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
index += 1;
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(out)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
@@ -730,7 +732,11 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !accepts(d, parent, n) {
|
||||
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
|
||||
}
|
||||
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
|
||||
s.edit("Reorder", |d, sel| {
|
||||
d.move_node(id, parent, index)?;
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge Selected",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
|
||||
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. Every selected object left hidden or locked, such as one merged into a hidden or locked layer, is deselected (the key object too). One undo step → {id}",
|
||||
has_doc,
|
||||
merge
|
||||
),
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Artwork",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
|
||||
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. Every selected object left hidden or locked, such as one flattened into a locked layer, is deselected (the key object too). One undo step → {id, discarded}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
@@ -288,6 +288,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Merge Selected", |d, sel| {
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
@@ -318,6 +319,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Nudge",
|
||||
[],
|
||||
None,
|
||||
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
|
||||
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
|
||||
has_selection_or_guides,
|
||||
nudge
|
||||
),
|
||||
@@ -337,7 +337,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return ok();
|
||||
}
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") }));
|
||||
}
|
||||
if s.doc()?.selection.is_empty() {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Anchors",
|
||||
[],
|
||||
None,
|
||||
"{dx, dy} move direct-selected anchors (whole paths if fully selected)",
|
||||
"{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)",
|
||||
has_selection,
|
||||
move_anchors
|
||||
),
|
||||
@@ -172,6 +172,10 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = st.selection.clone();
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
let _ = scale_strokes;
|
||||
// Objects selected whole (with no entry in `sel.anchors`) move as objects, and their pattern
|
||||
// tiles move with them when `patterns` (default: General › Transform Pattern Tiles) is on.
|
||||
let whole: Vec<NodeId> = sel.objects.iter().copied().filter(|id| !sel.anchors.contains_key(id)).collect();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
|
||||
s.edit("Move", |doc, _| {
|
||||
for id in &sel.objects {
|
||||
match sel.anchors.get(id) {
|
||||
@@ -185,7 +189,7 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => {
|
||||
if let Some(n) = doc.node_mut(*id) {
|
||||
n.transform(vectorcraft_geom::Affine::translate(d), false);
|
||||
n.transform(vectorcraft_geom::Affine::translate(d), sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,13 +310,21 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let label = pf_label(op);
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
// Build the stack (back → front) with a style per entry.
|
||||
// Build the stack (back → front); names are separate from paint/style identity.
|
||||
let mut styles: Vec<Style> = vec![];
|
||||
let mut shapes: Vec<po::Shape> = vec![];
|
||||
let mut push = |path: PathData, rule: FillRule, st: Style| {
|
||||
let mut names: Vec<Option<String>> = vec![];
|
||||
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
|
||||
// Filled open contours keep their implicit closing edge. A zero-area line
|
||||
// stays a cutter; normalised area also catches zero-signed-area self crossings.
|
||||
for sub in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
sub.closed = po::encloses_area(sub) || po::area(&PathData::single(sub.clone()), rule) > 0.0;
|
||||
}
|
||||
}
|
||||
let key = match styles.iter().position(|x| *x == st) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
@@ -325,6 +333,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
shapes.push(po::Shape::new(path, rule, key as u64));
|
||||
names.push(name);
|
||||
};
|
||||
for id in &roots {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -340,11 +349,12 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
// A group acts as one shape (the union of its leaves) with its top leaf's style.
|
||||
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
|
||||
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||
push(po::unite_all(&refs), FillRule::NonZero, style_of(top));
|
||||
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
|
||||
push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name);
|
||||
} else {
|
||||
for l in &lv {
|
||||
if let Some((p, r)) = node_path(l) {
|
||||
push(p, r, style_of(l));
|
||||
push(p, r, style_of(l), n.name.clone().or_else(|| l.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,7 +374,14 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let r =
|
||||
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
|
||||
let style = styles.get(r.key as usize).ok_or_else(|| EngineError::Other(format!("{label}: lost track of a shape's style")))?;
|
||||
shape_node_styled(d, r.path, style)
|
||||
let mut n = shape_node_styled(d, r.path, style);
|
||||
n.name = match op {
|
||||
// A subtraction keeps its surviving source's identity, even when unnamed.
|
||||
PathfinderOp::MinusFront => names.first().cloned().flatten(),
|
||||
PathfinderOp::MinusBack => names.last().cloned().flatten(),
|
||||
_ => names.into_iter().flatten().next(),
|
||||
};
|
||||
n
|
||||
} else {
|
||||
let mut children = vec![];
|
||||
for r in results {
|
||||
|
||||
@@ -379,7 +379,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
const C: &str = "dataset.select";
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
|
||||
let (applied, skipped) = s.edit("Apply Data Set", |d, sel| {
|
||||
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
|
||||
// One object may be driven by several variables; walk them in definition order, so two
|
||||
// text variables on the same object land the later one's value and both are counted.
|
||||
@@ -424,6 +424,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
d.variables.active_dataset = Some(name.to_string());
|
||||
// Every selected object left hidden or locked, such as one the row hid, is deselected.
|
||||
sel.deselect_uneditable(d);
|
||||
Ok((applied, skipped))
|
||||
})?;
|
||||
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
|
||||
@@ -569,7 +571,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Data Set",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{name} apply a dataset in one undo step → {applied, skipped}",
|
||||
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped}",
|
||||
has_doc,
|
||||
dataset_select
|
||||
),
|
||||
|
||||
@@ -556,10 +556,15 @@ fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
d.artboards.iter().all(|a| ok(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && {
|
||||
let paths = d.index_paths(sel.objects.iter().copied());
|
||||
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
}
|
||||
d.ruler_origin.is_finite()
|
||||
&& d.ruler_origin.x.abs() <= MAX_COORD
|
||||
&& d.ruler_origin.y.abs() <= MAX_COORD
|
||||
&& d.artboards.iter().all(|a| ok(a.rect))
|
||||
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
|
||||
&& {
|
||||
let paths = d.index_paths(sel.objects.iter().copied());
|
||||
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
@@ -172,6 +173,47 @@ fn shape_builder_tool_drives_the_command() {
|
||||
assert_eq!(layer_children(&s), 1);
|
||||
}
|
||||
|
||||
/// The first and last stop colours of a gradient paint.
|
||||
fn gradient_ends(p: &Paint) -> Option<([f32; 3], [f32; 3])> {
|
||||
let Paint::Gradient(g) = p else { return None };
|
||||
Some((g.gradient.stops.first()?.color.to_rgb(), g.gradient.stops.last()?.color.to_rgb()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_cuts_gradient_shapes_from_a_pdf_along_their_outlines() {
|
||||
// Two overlapping diamonds (x 100–300 and 200–400, y 40–160), each written as gradient-filled
|
||||
// shapes are in a PDF or a PDF-compatible .ai: the diamond as a clip, then a shading (#1304).
|
||||
let shading = |x0: u32, c0: &str, c1: &str| {
|
||||
format!(
|
||||
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{x0} 0 {} 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [{c0}] /C1 [{c1}] /N 1 >> >>",
|
||||
x0 + 200
|
||||
)
|
||||
};
|
||||
let diamond = |x0: u32| format!("q {x0} 100 m {} 160 l {} 100 l {} 40 l h W n", x0 + 100, x0 + 200, x0 + 100);
|
||||
let page = PdfPage {
|
||||
resources: format!("/Shading << /Sh0 {} /Sh1 {} >>", shading(100, "1 0 0", "1 1 0"), shading(200, "0 0 1", "0 1 1")),
|
||||
..PdfPage::new(500.0, 200.0, &format!("{} /Sh0 sh Q {} /Sh1 sh Q", diamond(100), diamond(200)))
|
||||
};
|
||||
let bytes = pdf(&[page], None);
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "diamonds.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[250, 100]]})).unwrap();
|
||||
let mut pieces: Vec<(f64, Rect, Paint)> =
|
||||
ids(&r).into_iter().map(|i| (area(&s, i), node(&s, i).geometric_bounds().unwrap(), node(&s, i).appearance.fill_paint())).collect();
|
||||
pieces.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
|
||||
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.01);
|
||||
let [(left, lb, lp), (mid, mb, mp), (right, rb, rp)] = pieces.as_slice() else { panic!("three pieces: {pieces:?}") };
|
||||
// What is left of each diamond, and the diamond where they overlap: not their bounding boxes.
|
||||
assert!((left - 9000.0).abs() < 1.0 && (mid - 3000.0).abs() < 1.0 && (right - 9000.0).abs() < 1.0, "{pieces:?}");
|
||||
assert!(near(*lb, Rect::new(100.0, 40.0, 250.0, 160.0)), "{lb:?}");
|
||||
assert!(near(*mb, Rect::new(200.0, 70.0, 300.0, 130.0)), "{mb:?}");
|
||||
assert!(near(*rb, Rect::new(250.0, 40.0, 400.0, 160.0)), "{rb:?}");
|
||||
// Each keeps its gradient; the overlap takes the front diamond's.
|
||||
let (first, second) = (Some(([1.0, 0.0, 0.0], [1.0, 1.0, 0.0])), Some(([0.0, 0.0, 1.0], [0.0, 1.0, 1.0])));
|
||||
assert_eq!((gradient_ends(lp), gradient_ends(mp), gradient_ends(rp)), (first, second, second));
|
||||
}
|
||||
|
||||
/// A red 100 × 100 square crossed by a stroke-only vertical line at x = 50, both selected.
|
||||
fn square_and_line(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
|
||||
|
||||
@@ -451,6 +451,29 @@ fn moves_are_validated() {
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
}
|
||||
|
||||
/// `node.move` into a hidden or locked layer deselects the object it moves, here the key object.
|
||||
/// Undo puts the object back and selects it again as the key object.
|
||||
#[test]
|
||||
fn node_move_into_a_hidden_or_locked_layer_deselects_the_object() {
|
||||
for prop in ["visible", "locked"] {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
let other = id(&run(&mut s, "layer.new", json!({})));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [other.0], prop: prop == "locked"}));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "select.key", json!({"id": b.0}));
|
||||
run(&mut s, "node.move", json!({"id": b.0, "parent": other.0, "index": 0}));
|
||||
assert_eq!(parent(&s, b), Some(other), "{prop}");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: b, the key object, is deselected");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(parent(&s, b), Some(layer), "{prop}");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "{prop}: undo selects b again as the key object");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
|
||||
let mut s = session();
|
||||
@@ -467,6 +490,33 @@ fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
|
||||
}
|
||||
|
||||
/// The selected art in rows dragged into a hidden layer or group is deselected, the key object
|
||||
/// included. Undo puts the rows back and selects the art again.
|
||||
#[test]
|
||||
fn rows_dragged_into_a_hidden_layer_or_group_deselect_their_art() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let hidden = id(&run(&mut s, "layer.new", json!({"visible": false})));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
|
||||
run(&mut s, "select.key", json!({"id": a.0}));
|
||||
// The sublayer, holding a and the group of b, goes into the hidden layer.
|
||||
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": hidden.0}));
|
||||
assert_eq!(parent(&s, sub), Some(hidden));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, and b are hidden");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(parent(&s, sub), Some(layer));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a, b, c], Some(a)), "undo selects them again");
|
||||
// c goes into the group once the group is hidden.
|
||||
run(&mut s, "object.setProps", json!({"ids": [g.0], "visible": false}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the hidden group");
|
||||
run(&mut s, "layer.move", json!({"ids": [c.0], "target": g.0, "place": "inside"}));
|
||||
assert_eq!(parent(&s, c), Some(g));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![a], None), "c is hidden, and a single selected object has no key object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_rows() {
|
||||
let mut s = session();
|
||||
@@ -526,6 +576,72 @@ fn collect_merge_flatten_and_reverse() {
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
}
|
||||
|
||||
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
|
||||
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
|
||||
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
|
||||
/// object included, and undo selects it again.
|
||||
#[test]
|
||||
fn collect_in_new_layer_inside_a_hidden_or_locked_layer_deselects_the_art_it_moves() {
|
||||
for prop in ["visible", "locked"] {
|
||||
let mut s = session();
|
||||
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
let top = id(&run(&mut s, "layer.new", json!({})));
|
||||
let y = rect(&mut s, 200.0);
|
||||
run(&mut s, "layer.setProps", json!({"ids": [top.0], prop: prop == "locked"}));
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
|
||||
run(&mut s, "select.key", json!({"id": x.0}));
|
||||
let col = id(&run(&mut s, "layer.collectInNew", json!({"ids": [x.0, y.0]})));
|
||||
assert_eq!((parent(&s, col), children(&s, col)), (Some(top), vec![x, y]), "{prop}");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![z], None), "{prop}: x, the key object, is deselected");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "{prop}: undo selects x again as the key object");
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Selected into a hidden or locked layer deselects the selected art it moves there, the key
|
||||
/// object included, and undo selects it again.
|
||||
#[test]
|
||||
fn merge_selected_into_a_hidden_or_locked_layer_deselects_the_art_it_moves() {
|
||||
for prop in ["visible", "locked"] {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
let into = id(&run(&mut s, "layer.new", json!({})));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [into.0], prop: prop == "locked"}));
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
|
||||
run(&mut s, "select.key", json!({"id": x.0}));
|
||||
run(&mut s, "layer.merge", json!({"ids": [x.0, into.0]}));
|
||||
assert_eq!((parent(&s, x), parent(&s, z)), (Some(into), Some(layer)), "{prop}");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![z], None), "{prop}: x, the key object, is deselected");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "{prop}: undo selects x again as the key object");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten Artwork into a locked layer deselects the selected art it moves there, the key object
|
||||
/// included, and undo selects it again.
|
||||
#[test]
|
||||
fn flatten_artwork_into_a_locked_layer_deselects_the_art_it_moves() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
|
||||
let into = id(&run(&mut s, "layer.new", json!({"locked": true})));
|
||||
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
|
||||
run(&mut s, "select.key", json!({"id": x.0}));
|
||||
run(&mut s, "layer.flatten", json!({"id": into.0}));
|
||||
assert_eq!((parent(&s, x), parent(&s, z)), (Some(into), Some(into)));
|
||||
assert!(s.doc().unwrap().doc.node(layer).is_none(), "Layer 1 was merged into the locked layer");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![], None), "x and z are locked");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "undo selects them again with the key object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_sequence_and_build() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -205,6 +205,161 @@ fn unite_with_group() {
|
||||
assert!((area_of(&s, r[0]) - (100.0 + 100.0 + 200.0 - 25.0 - 25.0)).abs() < 1.0);
|
||||
}
|
||||
|
||||
fn name_sources(s: &mut Session, a: NodeId, b: NodeId, names: [Option<&str>; 2]) {
|
||||
s.edit("Name sources", |d, _| {
|
||||
for (id, name) in [(a, names[0]), (b, names[1])] {
|
||||
d.node_mut(id).unwrap().name = name.map(str::to_owned);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn named_nodes<'a>(n: &'a vectorcraft_doc::Node, name: &str, out: &mut Vec<&'a vectorcraft_doc::Node>) {
|
||||
if n.name.as_deref() == Some(name) {
|
||||
out.push(n);
|
||||
}
|
||||
if let Some(children) = n.children() {
|
||||
for c in children {
|
||||
named_nodes(c, name, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_svg_name(s: &Session, id: NodeId, name: &str) {
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert_eq!(svg.matches(&format!("id=\"{name}\"")).count(), 1, "{svg}");
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let mut matches = vec![];
|
||||
for layer in &imported.layers {
|
||||
named_nodes(layer, name, &mut matches);
|
||||
}
|
||||
assert_eq!(matches.len(), 1, "{name}: {svg}");
|
||||
assert!(close(matches[0].geometric_bounds().unwrap(), bounds(s, id), 1e-2));
|
||||
assert_eq!(matches[0].appearance.fill_paint(), node(s, id).appearance.fill_paint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_shape_names_follow_paint_order_without_changing_output_or_undo() {
|
||||
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
let untouched = rect(&mut s, 300.0, 300.0, 30.0, 20.0);
|
||||
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
|
||||
s.edit("Source opacity and blend", |d, _| {
|
||||
d.node_mut(a).unwrap().opacity = 0.4;
|
||||
d.node_mut(b).unwrap().opacity = 0.8;
|
||||
d.node_mut(b).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo_len = s.doc().unwrap().history.undo.len();
|
||||
let cmd = format!("object.pathfinder.{op}");
|
||||
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
|
||||
let expected = if op == "minusBack" { "blade" } else { "base-plate" };
|
||||
let mut result = node(&s, id);
|
||||
assert_eq!(result.name.as_deref(), Some(expected), "{op}");
|
||||
assert_eq!(node(&s, untouched), *before.node(untouched).unwrap());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
|
||||
check_svg_name(&s, id, expected);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let after_selection = s.doc().unwrap().selection.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *after);
|
||||
assert_eq!(s.doc().unwrap().selection, after_selection);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
name_sources(&mut s, a, b, [None, None]);
|
||||
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
|
||||
result.name = None;
|
||||
assert_eq!(result, node(&s, bare_id), "names must not change geometry or appearance: {op}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_shape_names_do_not_borrow_a_cutters_name_for_an_unnamed_source() {
|
||||
for names in [[None, Some("blade")], [Some("base-plate"), None], [None, None]] {
|
||||
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
name_sources(&mut s, a, b, names);
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
let expected = match op {
|
||||
"minusFront" => names[0],
|
||||
"minusBack" => names[1],
|
||||
_ => names[0].or(names[1]),
|
||||
};
|
||||
let id = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap())[0];
|
||||
assert_eq!(node(&s, id).name.as_deref(), expected, "{op}: {names:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_shape_names_keep_group_and_compound_identity_once() {
|
||||
for (compound, count, root_name) in
|
||||
[(false, 1, Some("root-identity")), (false, 2, Some("root-identity")), (true, 2, Some("root-identity")), (false, 2, None)]
|
||||
{
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
|
||||
let mut children = vec![a.0];
|
||||
if count == 2 {
|
||||
children.push(rect(&mut s, 60.0, 0.0, 40.0, 100.0).0);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": children})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.edit("Name grouped source", |d, _| {
|
||||
d.node_mut(a).unwrap().name = Some("leaf-identity".into());
|
||||
let appearance = d.node(a).unwrap().appearance.clone();
|
||||
let n = d.node_mut(group).unwrap();
|
||||
n.name = root_name.map(str::to_owned);
|
||||
if compound {
|
||||
n.kind = NodeKind::Compound { children: n.children().unwrap().to_vec(), rule: FillRule::NonZero };
|
||||
n.appearance = appearance;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let cutter = rect(&mut s, 35.0, -10.0, 30.0, 120.0);
|
||||
name_sources(&mut s, group, cutter, [root_name, Some("blade")]);
|
||||
s.execute("select.set", &json!({"ids": [cutter.0, group.0]})).unwrap();
|
||||
let id = ids(&s.execute("object.pathfinder.minusFront", &json!({})).unwrap())[0];
|
||||
let n = node(&s, id);
|
||||
let expected = root_name.unwrap_or("leaf-identity");
|
||||
assert_eq!(n.name.as_deref(), Some(expected));
|
||||
assert!((area_of(&s, id) - 3500.0 * f64::from(count)).abs() < 1.0);
|
||||
assert!(n.children().is_none_or(|children| children.iter().all(|c| c.name.is_none())));
|
||||
check_svg_name(&s, id, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_fragment_names_do_not_change_style_merging() {
|
||||
for op in ["divide", "trim", "merge", "crop", "outline"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
set_fill(&mut s, a, Color::rgb(0.0, 0.0, 1.0));
|
||||
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
|
||||
let cmd = format!("object.pathfinder.{op}");
|
||||
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
|
||||
let result = node(&s, id);
|
||||
assert!(result.name.is_none());
|
||||
assert!(result.children().unwrap().iter().all(|c| c.name.is_none()));
|
||||
if op == "merge" {
|
||||
assert_eq!(result.children().unwrap().len(), 1);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
name_sources(&mut s, a, b, [None, None]);
|
||||
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
|
||||
assert_eq!(result, node(&s, bare_id), "{op}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_rect_grows_bounds() {
|
||||
let mut s = session();
|
||||
@@ -584,3 +739,89 @@ fn commands_reject_bad_input() {
|
||||
let _ = s.execute(c, &p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_cutting_lines_keep_appearance_unrelated_art_and_one_undo() {
|
||||
for (line_has_fill, curved) in [(false, false), (true, false), (false, true)] {
|
||||
let mut s = session();
|
||||
let base = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let cutter =
|
||||
NodeId(s.execute("path.create", &json!({"anchors": [{"x": -10, "y": 50}, {"x": 110, "y": 50}]})).unwrap()["id"].as_u64().unwrap());
|
||||
let kept = rect(&mut s, 200.0, 0.0, 30.0, 30.0);
|
||||
s.edit("divide fixture", |d, sel| {
|
||||
let n = d.node_mut(base).unwrap();
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.7)), Paint::solid(Color::BLACK), 3.0).into();
|
||||
n.opacity = 0.65;
|
||||
n.blend = vectorcraft_color::BlendMode::Multiply;
|
||||
let n = d.node_mut(cutter).unwrap();
|
||||
n.appearance =
|
||||
Appearance::basic(if line_has_fill { Paint::solid(Color::BLACK) } else { Paint::None }, Paint::solid(Color::rgb(0.8, 0.2, 0.1)), 2.0)
|
||||
.into();
|
||||
if curved {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((-10.0, 50.0));
|
||||
bp.curve_to((30.0, 15.0), (70.0, 85.0), (110.0, 50.0));
|
||||
n.kind = NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false };
|
||||
}
|
||||
sel.set([cutter, base]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selected = s.doc().unwrap().selection.clone();
|
||||
let undo_len = s.doc().unwrap().history.undo.len();
|
||||
let result = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap())[0];
|
||||
let group = node(&s, result);
|
||||
let pieces = group.children().unwrap();
|
||||
assert_eq!(pieces.len(), 2, "line fill {line_has_fill}, curved {curved}");
|
||||
let source = before.node(base).unwrap();
|
||||
assert!(pieces.iter().all(|n| n.appearance == source.appearance && n.opacity == source.opacity && n.blend == source.blend));
|
||||
let total: f64 = pieces.iter().map(|n| area_of(&s, n.id)).sum();
|
||||
assert!((total - 10000.0).abs() < 0.01);
|
||||
assert_eq!(node(&s, kept), *before.node(kept).unwrap());
|
||||
assert!(s.doc().unwrap().doc.node(base).is_none() && s.doc().unwrap().doc.node(cutter).is_none());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![result]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let result_selection = s.doc().unwrap().selection.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().selection, selected);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
assert_eq!(s.doc().unwrap().selection, result_selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
|
||||
for anchors in [
|
||||
json!([{"x": 10, "y": 10}, {"x": 50, "y": 90}, {"x": 90, "y": 10}]),
|
||||
json!([{"x": 0, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}, {"x": 100, "y": 0}]),
|
||||
] {
|
||||
let mut s = session();
|
||||
let filled = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
let cutter =
|
||||
NodeId(s.execute("path.create", &json!({"anchors": [{"x": -10, "y": 150}, {"x": 110, "y": 150}]})).unwrap()["id"].as_u64().unwrap());
|
||||
s.edit("open fill fixture", |d, sel| {
|
||||
let mut appearance = Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.2)), Paint::None, 0.0);
|
||||
// An empty top fill must not hide the nonempty fill below it.
|
||||
appearance.items.insert(0, vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::None)));
|
||||
d.node_mut(filled).unwrap().appearance = appearance.into();
|
||||
d.node_mut(cutter).unwrap().appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into();
|
||||
sel.set([cutter, filled]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let source = node(&s, filled);
|
||||
assert!(source.path_data().unwrap().subpaths.iter().all(|sp| !sp.closed));
|
||||
let expected = area_of(&s, filled);
|
||||
assert!(expected > 3000.0);
|
||||
let result = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap())[0];
|
||||
let group = node(&s, result);
|
||||
let pieces = group.children().unwrap();
|
||||
let actual: f64 = pieces.iter().map(|n| area_of(&s, n.id)).sum();
|
||||
assert!((actual - expected).abs() < 0.01, "{actual} != {expected}");
|
||||
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
|
||||
}
|
||||
}
|
||||
@@ -699,6 +699,59 @@ fn art_moved_with_its_artboard_follows_transform_pattern_tiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// General › Transform Pattern Tiles (#394) applies to the objects selected whole that move with
|
||||
/// direct-selected anchors (an arrow key, `path.moveAnchors` or a Direct Selection drag of an
|
||||
/// anchor): off, the tiles of a pattern-filled square stay in place; on, they move with it, and
|
||||
/// `patterns: false` keeps them in place for one call.
|
||||
#[test]
|
||||
fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
let filled = patterned_square(&mut s);
|
||||
let other = square(&mut s, 300.0, 100.0);
|
||||
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
// The square is selected whole with the top-left anchor of `other`, as a click on the square's
|
||||
// fill and a Shift-click on that anchor select them. Each move takes both 10 pt right: Shift
|
||||
// with an arrow key, `path.moveAnchors`, and a drag of the anchor (no params).
|
||||
for (cmd, p) in [
|
||||
("object.nudge", Some(json!({"dx": 1, "dy": 0, "big": true}))),
|
||||
("path.moveAnchors", Some(json!({"dx": 10, "dy": 0}))),
|
||||
("path.moveAnchors", None),
|
||||
] {
|
||||
let run = |s: &mut Session, p: Option<&Value>| {
|
||||
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
match p {
|
||||
Some(p) => {
|
||||
s.execute(cmd, p).unwrap();
|
||||
}
|
||||
None => gesture(s, v, &[(Down, 300.0, 100.0), (Drag, 305.0, 100.0), (Drag, 310.0, 100.0), (Up, 310.0, 100.0)]),
|
||||
}
|
||||
assert_eq!(top_left(s, filled), (110.0, 100.0), "{cmd}: the square moves whole");
|
||||
let anchors = &s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors;
|
||||
assert_eq!((anchors[0].p.x, anchors[1].p.x), (310.0, 350.0), "{cmd}: the selected anchor moves alone");
|
||||
assert_eq!(s.journal.last().unwrap().0, cmd);
|
||||
};
|
||||
run(&mut s, p.as_ref());
|
||||
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "transformPatternTiles", json!(true));
|
||||
run(&mut s, p.as_ref());
|
||||
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{cmd}: on, they move with the square");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
if let Some(mut p) = p {
|
||||
p["patterns"] = json!(false);
|
||||
run(&mut s, Some(&p));
|
||||
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
set_pref(&mut s, "transformPatternTiles", json!(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
|
||||
/// swatch; on, only the same tint.
|
||||
#[test]
|
||||
|
||||
@@ -526,6 +526,29 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() {
|
||||
s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap();
|
||||
}
|
||||
|
||||
/// A row that hides selected objects deselects them, the key object included. Undo shows them
|
||||
/// and selects them again with the key object.
|
||||
#[test]
|
||||
fn a_row_that_hides_selected_objects_deselects_them() {
|
||||
let mut s = session();
|
||||
let (r, q) = (rect(&mut s), rect(&mut s));
|
||||
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Show": false}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r.0, q.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": r.0})).unwrap();
|
||||
|
||||
s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![q], None), "r, the key object, is hidden");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!((sel.objects, sel.key), (vec![r, q], Some(r)), "undo selects r again as the key object");
|
||||
}
|
||||
|
||||
/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of
|
||||
/// the same name, and which dataset is active, alone.
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Artboard clicks fit the object without changing its art or selection, and are one undo step.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
fn fixture(x: f64, y: f64) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
(s, NodeId(id))
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_inside_and_outside_an_artboard_fit_the_visible_object_in_one_undo_step() {
|
||||
for (x, y) in [(100.0, 100.0), (650.0, 700.0)] {
|
||||
let (mut s, id) = fixture(x, y);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let at = (x + 40.0, y + 25.0);
|
||||
pointer(&mut s, PointerKind::Down, at.0, at.1);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a press alone does not edit the document");
|
||||
pointer(&mut s, PointerKind::Drag, at.0 + 1.0, at.1);
|
||||
pointer(&mut s, PointerKind::Up, at.0 + 1.0, at.1);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.artboards.len(), 2);
|
||||
let rect = Rect::new(x - 5.0, y - 5.0, x + 105.0, y + 65.0);
|
||||
assert_eq!(st.doc.artboards[1].rect, rect, "includes the visible stroke, like Fit to Selected Art");
|
||||
assert_eq!(st.doc.bounds_of(&[id], true), Some(rect));
|
||||
assert_eq!(json!(st.doc.layers), json!(before.layers));
|
||||
assert_eq!(json!(st.selection), json!(sel));
|
||||
assert_eq!(st.history.undo.len(), history + 1);
|
||||
assert_eq!(s.tool_options()["active"], 1);
|
||||
pointer(&mut s, PointerKind::Up, at.0, at.1);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2, "one release creates once");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
assert_eq!(json!(s.doc().unwrap().selection), json!(sel));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_topmost_group_is_fitted_instead_of_the_selection_or_its_clicked_child() {
|
||||
let (mut s, bottom) = fixture(100.0, 100.0);
|
||||
let a = s.execute("shape.rectangle", &json!({"x": 120, "y": 110, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 120, "width": 40, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let group = s.doc().unwrap().selection.objects[0];
|
||||
let expected = s.doc().unwrap().doc.bounds_of(&[group], true).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [bottom.0]})).unwrap();
|
||||
let layers = json!(s.doc().unwrap().doc.layers);
|
||||
pointer(&mut s, PointerKind::Down, 135.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Up, 135.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, expected);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![bottom]);
|
||||
assert_eq!(json!(s.doc().unwrap().doc.layers), layers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_clicks_do_not_create_an_artboard_or_leak_into_the_next_gesture() {
|
||||
for switch in [false, true] {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
|
||||
if switch {
|
||||
s.select_tool("selection", ViewInfo::default()).unwrap();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
} else {
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
|
||||
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
||||
}
|
||||
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
pointer(&mut s, PointerKind::Down, 300.0, 300.0);
|
||||
pointer(&mut s, PointerKind::Up, 300.0, 300.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "empty clicks do not use a stale object");
|
||||
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_hidden_modifier_handle_and_drag_gestures_keep_their_existing_meaning() {
|
||||
for command in ["object.lock", "object.hide"] {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
|
||||
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
|
||||
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
for mods in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] {
|
||||
let (mut s, _) = fixture(100.0, 100.0);
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 140.0, 125.0).with_mods(mods), ViewInfo::default()).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
let (mut s, _) = fixture(0.0, 0.0);
|
||||
pointer(&mut s, PointerKind::Down, 0.0, 0.0);
|
||||
pointer(&mut s, PointerKind::Up, 0.0, 0.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "an artboard handle wins over art beneath it");
|
||||
for (x, y) in [(100.0, 100.0), (650.0, 700.0)] {
|
||||
let (mut s, _) = fixture(x, y);
|
||||
pointer(&mut s, PointerKind::Down, x + 40.0, y + 25.0);
|
||||
pointer(&mut s, PointerKind::Drag, x + 80.0, y + 50.0);
|
||||
pointer(&mut s, PointerKind::Drag, x + 40.0, y + 25.0);
|
||||
pointer(&mut s, PointerKind::Up, x + 40.0, y + 25.0);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a drag returning to its start does not become an object click");
|
||||
}
|
||||
}
|
||||
@@ -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())]
|
||||
|
||||
@@ -2083,7 +2092,7 @@ proptest! {
|
||||
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
|
||||
/// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files.
|
||||
#[test]
|
||||
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
@@ -2098,6 +2107,8 @@ proptest! {
|
||||
bytes.truncate(cut.max(4));
|
||||
let file = ai::ai(&bytes, ai::page_pdf());
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
|
||||
let file = ai::pdf(&bytes, ai::page_pdf());
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.pdf", &file).ok().map(|l| l.doc))?;
|
||||
let file = ai::eps(&bytes, ai::page_ps());
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! #1212: ruler coordinates are saved metadata, independent of the artwork's coordinates.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_format::{SaveOptions, load, save, save_with};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 125})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn origin(s: &mut Session) -> Value {
|
||||
s.execute("document.rulerOrigin", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_origin_preserves_artwork_and_selection_and_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
|
||||
let p = json!({"x": 125.5, "y": -30.25});
|
||||
s.execute("document.setRulerOrigin", &p).unwrap();
|
||||
assert_eq!(origin(&mut s), p);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
let mut changed = s.execute("document.json", &json!({})).unwrap();
|
||||
changed.as_object_mut().unwrap().remove("ruler_origin");
|
||||
assert_eq!(changed, serde_json::to_value(&*before).unwrap(), "only the origin changed");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let revision = s.doc().unwrap().revision;
|
||||
s.execute("document.setRulerOrigin", &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "same origin is a no-op");
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(&*s.doc().unwrap().doc, &*before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(origin(&mut s), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_uses_the_requested_artboards_top_left_and_ignores_noops() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
|
||||
s.execute("document.setRulerOrigin", &json!({"x": 25, "y": 50})).unwrap();
|
||||
let boards = s.doc().unwrap().doc.artboards.clone();
|
||||
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(origin(&mut s), json!({"x": 700.0, "y": -250.0}));
|
||||
assert_eq!(s.doc().unwrap().doc.artboards, boards);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
for p in [json!({"artboard": 9}), json!({"artboard": -1}), json!({"artboard": "first"})] {
|
||||
assert!(s.execute("document.resetRulerOrigin", &p).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
s.execute("document.resetRulerOrigin", &json!({})).unwrap();
|
||||
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero() {
|
||||
let mut s = session();
|
||||
let legacy = String::from_utf8(save(&s.doc().unwrap().doc, false)).unwrap().replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
|
||||
assert!(serde_json::to_value(load(legacy.as_bytes()).unwrap()).unwrap().get("ruler_origin").is_none());
|
||||
let p = json!({"x": -12.5, "y": 27.75});
|
||||
s.execute("document.setRulerOrigin", &p).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for (version, compress) in [(1, false), (2, false), (3, false), (4, false), (4, true)] {
|
||||
let bytes = save_with(doc, &SaveOptions { version, compress, ..Default::default() }).unwrap();
|
||||
let back = load(&bytes).unwrap();
|
||||
assert_eq!(serde_json::to_value(&back).unwrap()["ruler_origin"], p, "v{version}, compression {compress}");
|
||||
assert_eq!(back.layers, doc.layers);
|
||||
assert_eq!(back.guides, doc.guides);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_rejects_invalid_coordinates_without_changing_absolute_command_coordinates() {
|
||||
let mut s = session();
|
||||
s.execute("document.setRulerOrigin", &json!({"x": 200, "y": 150})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for p in
|
||||
[json!({"x": 4_000_001, "y": 0}), json!({"x": 0, "y": -4_000_001}), json!({"x": "10", "y": 0}), json!({"x": 0}), json!({"x": null, "y": 0})]
|
||||
{
|
||||
assert!(s.execute("document.setRulerOrigin", &p).is_err(), "{p}");
|
||||
assert_eq!(&*s.doc().unwrap().doc, &*before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
s.execute("object.setBounds", &json!({"x": 80, "y": 40, "reference": 0})).unwrap();
|
||||
let bounds = s.doc().unwrap().doc.bounds_of(&s.doc().unwrap().selection.objects, false).unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (80.0, 40.0), "engine coordinates stay absolute");
|
||||
assert_eq!(origin(&mut s), json!({"x": 200.0, "y": 150.0}));
|
||||
}
|
||||
@@ -113,20 +113,24 @@ impl<'a> Lexer<'a> {
|
||||
continue;
|
||||
}
|
||||
if b == b'%' {
|
||||
if self.at_line_start() {
|
||||
if self.rest().starts_with(b"%_") {
|
||||
self.pos += 2;
|
||||
self.hidden = true;
|
||||
continue;
|
||||
}
|
||||
let line = self.line();
|
||||
if let Some(t) = self.special(line) {
|
||||
return Some(t);
|
||||
}
|
||||
let start = self.at_line_start();
|
||||
if start && self.rest().starts_with(b"%_") {
|
||||
self.pos += 2;
|
||||
self.hidden = true;
|
||||
continue;
|
||||
}
|
||||
// Right after `%_` a section still starts (a hidden image's data, #1264).
|
||||
let prefixed = self.hidden && self.pos >= 2 && self.src.get(self.pos - 2..self.pos) == Some(b"%_");
|
||||
let line = self.line();
|
||||
if (start || prefixed)
|
||||
&& let Some(t) = self.special(line)
|
||||
{
|
||||
return Some(Token { hidden: prefixed, ..t });
|
||||
}
|
||||
if start {
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
|
||||
}
|
||||
// A comment later in a line.
|
||||
self.line();
|
||||
continue;
|
||||
}
|
||||
let hidden = self.hidden;
|
||||
@@ -310,6 +314,10 @@ mod tests {
|
||||
let toks = all(src);
|
||||
assert!(toks.contains(&t(Tok::Data(b"(%)A"), false)), "{toks:?}");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Word(b"XH"), false)));
|
||||
// #1264: on `%_` lines too, where the samples would otherwise be read as tokens.
|
||||
let toks = all(b"%_[1 0 0 1 0 0] XI\r%_%%BeginData: 7\rXI\n(U)A%%EndData\r%_XH\ru\r");
|
||||
assert!(toks.contains(&t(Tok::Data(b"(U)A"), true)), "{toks:?}");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Word(b"u"), false)), "{toks:?}");
|
||||
// A section of hex lines is comments.
|
||||
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
|
||||
@@ -1080,29 +1080,32 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.ai` `visible` (read as its PDF part) read through its editing copy (`private`, the joined
|
||||
/// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the
|
||||
/// file, its artboards, and the art that lies outside them, which its PDF part doesn't have.
|
||||
/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through
|
||||
/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead,
|
||||
/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the
|
||||
/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have.
|
||||
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
|
||||
/// `outlined`: the PDF part's type was read as outlines (a file whose type shows then comes in as
|
||||
/// its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
|
||||
/// The flag returned is `true` when the document is the one read from the layers; else the document
|
||||
/// is `visible`, its pages moved by `place_pages` when the layers can't be read. `outlined`: the
|
||||
/// PDF part's type was read as outlines (a file whose type shows then comes in as its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>, bool) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings, false) };
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings);
|
||||
return (visible, warnings, false);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined) {
|
||||
Ok((done, notes)) => {
|
||||
Ok((mut done, notes)) => {
|
||||
done.setup.bleed = visible.setup.bleed;
|
||||
let mut all = warnings;
|
||||
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
|
||||
(done, all)
|
||||
(done, all, true)
|
||||
}
|
||||
Err(why) => {
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible);
|
||||
(visible, all)
|
||||
(visible, all, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +215,9 @@ fn pdf_part(x: f64, y: f64) -> Document {
|
||||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
|
||||
assert!(read);
|
||||
assert_eq!(warnings, ["a note"], "nothing else to say");
|
||||
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}");
|
||||
@@ -227,12 +228,12 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
// No editing data this reads.
|
||||
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
|
||||
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
|
||||
assert_eq!(d.layers.len(), pdf.layers.len());
|
||||
assert!(w.is_empty());
|
||||
assert!(w.is_empty() && !read);
|
||||
// Editing data that isn't for this page.
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
@@ -266,8 +267,8 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let moved = squares(&d);
|
||||
@@ -275,7 +276,7 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
}
|
||||
}
|
||||
@@ -597,7 +598,7 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
|
||||
let art = native(&format!("{dots}{ground}"));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
|
||||
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
|
||||
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
|
||||
@@ -682,12 +683,12 @@ fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
|
||||
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
|
||||
let (d, w) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
|
||||
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
|
||||
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
|
||||
// Type only on a hidden layer: the layers come in.
|
||||
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
|
||||
let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
|
||||
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
|
||||
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,18 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
|
||||
if op == PathfinderOp::Unite {
|
||||
return unite();
|
||||
}
|
||||
// Open paths are cutting edges for Divide; the filled-only operations keep their
|
||||
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
|
||||
if op == PathfinderOp::Divide && shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) {
|
||||
return crate::shape_builder(shapes, false)
|
||||
.regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let key = shapes.get(r.top()?)?.key;
|
||||
Some(Shape::new(r.path, FillRule::NonZero, key))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let Some(arr) = arrangement(shapes) else { return Vec::new() };
|
||||
let p = &arr.tidy;
|
||||
match op {
|
||||
@@ -564,4 +576,74 @@ mod tests {
|
||||
same_as_one_mask_at_a_time(&v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_open_paths_cut_faces_without_taking_the_cutters_paint() {
|
||||
let line = |a: (f64, f64), b: (f64, f64)| shapes::line(Point::from(a), Point::from(b));
|
||||
let mut cases = vec![
|
||||
("horizontal", vec![line((-10.0, 50.0), (110.0, 50.0))], 2),
|
||||
("reverse", vec![line((110.0, 50.0), (-10.0, 50.0))], 2),
|
||||
("vertical", vec![line((50.0, -10.0), (50.0, 110.0))], 2),
|
||||
("diagonal", vec![line((-10.0, -10.0), (110.0, 110.0))], 2),
|
||||
("crossed", vec![line((-10.0, 50.0), (110.0, 50.0)), line((50.0, -10.0), (50.0, 110.0))], 4),
|
||||
("outside", vec![line((-10.0, 150.0), (110.0, 150.0))], 1),
|
||||
("dangling", vec![line((-10.0, 50.0), (50.0, 50.0))], 1),
|
||||
("boundary", vec![line((-10.0, 0.0), (110.0, 0.0))], 1),
|
||||
("tangent", vec![line((-10.0, 10.0), (10.0, -10.0))], 1),
|
||||
];
|
||||
let mut curve = BezPath::new();
|
||||
curve.move_to((-10.0, 50.0));
|
||||
curve.curve_to((30.0, 15.0), (70.0, 85.0), (110.0, 50.0));
|
||||
cases.push(("curved", vec![PathData::from_bezpath(&curve)], 2));
|
||||
let mut bent = BezPath::new();
|
||||
bent.move_to((-10.0, 25.0));
|
||||
bent.line_to((50.0, 75.0));
|
||||
bent.line_to((110.0, 25.0));
|
||||
cases.push(("bent", vec![PathData::from_bezpath(&bent)], 2));
|
||||
for (label, cutters, count) in cases {
|
||||
let mut inputs = vec![Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7)];
|
||||
inputs.extend(cutters.into_iter().map(|p| Shape::new(p, FillRule::NonZero, 99)));
|
||||
let divided = pathfinder(PathfinderOp::Divide, &inputs);
|
||||
assert_eq!(divided.len(), count, "{label}");
|
||||
assert!(divided.iter().all(|s| s.key == 7 && s.path.subpaths.iter().all(|sp| sp.closed)), "{label}");
|
||||
let total: f64 = divided.iter().map(|s| crate::area(&s.path, s.rule)).sum();
|
||||
assert!((total - 10000.0).abs() < 0.01, "{label}: area {total}");
|
||||
for p in [Point::new(20.0, 31.0), Point::new(79.0, 37.0), Point::new(81.0, 68.0), Point::new(17.0, 85.0)] {
|
||||
assert_eq!(divided.iter().filter(|s| s.path.to_bezpath().winding(p) != 0).count(), 1, "{label}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_open_paths_preserve_frontmost_fills_and_holes() {
|
||||
let cutter = || Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(160.0, 50.0)), FillRule::NonZero, 99);
|
||||
let inputs = [
|
||||
Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7),
|
||||
Shape::new(rect(50.0, 0.0, 150.0, 100.0), FillRule::NonZero, 8),
|
||||
cutter(),
|
||||
];
|
||||
let faces = pathfinder(PathfinderOp::Divide, &inputs);
|
||||
assert_eq!(faces.len(), 6);
|
||||
assert_eq!(faces.iter().filter(|s| s.key == 7).count(), 2);
|
||||
assert_eq!(faces.iter().filter(|s| s.key == 8).count(), 4);
|
||||
let total: f64 = faces.iter().map(|s| crate::area(&s.path, s.rule)).sum();
|
||||
assert!((total - 15000.0).abs() < 1e-6);
|
||||
let mut ring = rect(0.0, 0.0, 100.0, 100.0);
|
||||
ring.subpaths.extend(rect(30.0, 30.0, 70.0, 70.0).subpaths);
|
||||
let faces = pathfinder(PathfinderOp::Divide, &[Shape::new(ring, FillRule::EvenOdd, 7), cutter()]);
|
||||
assert_eq!(faces.len(), 2);
|
||||
assert!(faces.iter().all(|s| s.key == 7 && s.path.to_bezpath().winding(Point::new(50.0, 40.0)) == 0));
|
||||
let total: f64 = faces.iter().map(|s| crate::area(&s.path, s.rule)).sum();
|
||||
assert!((total - 8400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_open_paths_retains_the_existing_segment_cap() {
|
||||
let mut inputs = vec![Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7)];
|
||||
inputs.extend((0..4093).map(|_| Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0)), FillRule::NonZero, 99)));
|
||||
let faces = pathfinder(PathfinderOp::Divide, &inputs);
|
||||
assert_eq!(faces.len(), 1, "4097 segments fall back to filled areas");
|
||||
assert_eq!(faces[0].key, 7);
|
||||
assert!((crate::area(&faces[0].path, faces[0].rule) - 10000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
+139
-40
@@ -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(®ion) {
|
||||
if let Some(clip) = to_path(s, ®ion) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
@@ -136,7 +136,7 @@ pub const OFF_ARTBOARD_NOTE: &str =
|
||||
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
|
||||
|
||||
/// Warning: art reaching past the page box was clipped to it.
|
||||
pub(crate) const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it";
|
||||
pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it";
|
||||
|
||||
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
|
||||
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
|
||||
@@ -200,6 +200,13 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let (mut order, mut known) = (vec![], HashSet::new());
|
||||
for (i, &number) in picked.iter().enumerate() {
|
||||
let Some(page) = pages.get(number) else { continue };
|
||||
// Artboards on the trim boxes have the bleed boxes round them as the document's bleed
|
||||
// (the widest of the pages' on each side), so the art there shows and prints as bleed.
|
||||
if opts.crop == CropTo::Trim {
|
||||
for (side, b) in doc.setup.bleed.iter_mut().zip(crate::pages::bleed(page)) {
|
||||
*side = side.max(b);
|
||||
}
|
||||
}
|
||||
// The chosen box sits at (x, 0); the page draws round it.
|
||||
let (init, frame) = crate::pages::frame(page, opts.crop);
|
||||
let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height());
|
||||
@@ -774,7 +781,7 @@ fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
|
||||
}
|
||||
|
||||
/// `p` with every subpath closed: how a fill reads it.
|
||||
fn closed_subpaths(p: &BezPath) -> BezPath {
|
||||
pub(crate) fn closed_subpaths(p: &BezPath) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut open = false;
|
||||
for el in p.elements() {
|
||||
@@ -1062,6 +1069,9 @@ impl<'p> Builder<'p> {
|
||||
// A rectangle around all of its art (a form's box) clips nothing.
|
||||
grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g));
|
||||
None
|
||||
} else if let Some(shape) = gradient_shape(&clip, &f.children) {
|
||||
// One path, so tools such as the Shape Builder see the shape, not its box (#1304).
|
||||
Some(shape)
|
||||
} else {
|
||||
let mut ch = vec![Arc::new(*clip)];
|
||||
ch.extend(f.children);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
|
||||
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
|
||||
//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that
|
||||
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
|
||||
//! and triangle meshes become gradient meshes, a gradient drawn through a luminosity mask that is
|
||||
//! the same gradient in grey (how stop opacity is written) gets its opacity stops back, and a shape
|
||||
//! clipping a gradient that fills it becomes one path with the gradient.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,11 +11,12 @@ use hayro_interpret::shading::{ShadingFunction, ShadingType};
|
||||
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use crate::import::round3;
|
||||
use crate::import::{closed_subpaths, round3};
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::luminance;
|
||||
use crate::import_mask::{contains, is_rectangle, luminance};
|
||||
|
||||
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
|
||||
/// colour.
|
||||
@@ -266,3 +268,23 @@ pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<No
|
||||
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
|
||||
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
|
||||
}
|
||||
|
||||
/// A shape filled with a gradient, as files write it (the shape as a clip, then `sh` filling the
|
||||
/// clip's bounds), as one path: the clip's outline with `content`'s look. `None` unless `content`
|
||||
/// is one rectangle covering the clip, filled with a gradient placed on the page and nothing else.
|
||||
pub(crate) fn gradient_shape(clip: &Node, content: &[Arc<Node>]) -> Option<Node> {
|
||||
let ([only], NodeKind::Path { path: outline, rule, .. }) = (content, &clip.kind) else { return None };
|
||||
let NodeKind::Path { path: area, clipping: false, guide: false, .. } = &only.kind else { return None };
|
||||
let [AppearanceItem::Fill(f)] = only.appearance.items.as_slice() else { return None };
|
||||
let Paint::Gradient(g) = &f.paint else { return None };
|
||||
let (area, outline) = (area.to_bezpath(), outline.to_bezpath());
|
||||
let covers = is_rectangle(&area) && contains(area.bounding_box().inflate(0.01, 0.01), outline.bounding_box());
|
||||
if g.geom.is_none() || !covers {
|
||||
return None;
|
||||
}
|
||||
let mut shape = (**only).clone();
|
||||
// A clip closes its subpaths, as a fill does.
|
||||
let path = PathData::from_bezpath(&closed_subpaths(&outline));
|
||||
shape.kind = NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false };
|
||||
Some(shape)
|
||||
}
|
||||
@@ -69,8 +69,8 @@ mod syntax;
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report, page_areas};
|
||||
pub use import::{OFF_ARTBOARD_NOTE, import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, illustrator_data, info, is_postscript};
|
||||
pub use import::{OFF_ARTBOARD_NOTE, PAST_PAGE_NOTE, import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
|
||||
pub use post::{THUMBNAIL_SIZE, Thumbnail};
|
||||
pub use presets::*;
|
||||
pub use print::*;
|
||||
@@ -148,6 +148,7 @@ settings::choice! {
|
||||
Art = "art", "Art";
|
||||
/// The visible page area (what viewers show).
|
||||
Crop = "crop", "Crop";
|
||||
/// The finished page: an import takes the bleed box round it as the document's bleed.
|
||||
Trim = "trim", "Trim";
|
||||
Bleed = "bleed", "Bleed";
|
||||
/// The whole sheet.
|
||||
|
||||
+21
-2
@@ -5,6 +5,7 @@ use hayro_syntax::object::{Dict, Stream};
|
||||
use hayro_syntax::page::Page;
|
||||
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
|
||||
use kurbo::{Affine, Rect};
|
||||
use vectorcraft_doc::setup::MAX_BLEED;
|
||||
|
||||
use crate::{Choice, CropTo, ImportOptions, PdfError};
|
||||
|
||||
@@ -128,6 +129,15 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
|
||||
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
|
||||
}
|
||||
|
||||
/// How far `page`'s bleed box reaches past its trim box as the page shows (rotated): the bleed
|
||||
/// `[top, bottom, left, right]` in points, kept to 0–[`MAX_BLEED`].
|
||||
pub(crate) fn bleed(page: &Page<'_>) -> [f64; 4] {
|
||||
let init = Affine::new(page.initial_transform(true).as_coeffs());
|
||||
let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed)));
|
||||
[trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1]
|
||||
.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The `PieceInfo` key such files keep their private data under.
|
||||
const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use
|
||||
|
||||
@@ -135,20 +145,29 @@ const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files
|
||||
const MAX_PRIVATE: usize = 256 << 20;
|
||||
|
||||
/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData`
|
||||
/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read).
|
||||
/// streams in order, joined. A PDF saved with the editing data has its streams under
|
||||
/// `AIPDFPrivateData1`, `AIPDFPrivateData2`, … instead: those are read when there is no
|
||||
/// `AIPrivateData1`. `None` for a PDF without either (or with a stream that can't be read).
|
||||
/// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`).
|
||||
pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>> {
|
||||
let pdf = open(bytes, password).ok()?;
|
||||
let page = pdf.pages().first()?;
|
||||
let private = page.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(b"Private")?;
|
||||
let key = if private.contains_key(b"AIPrivateData1") { "AIPrivateData" } else { "AIPDFPrivateData" };
|
||||
let mut data = vec![];
|
||||
for n in 1..=100_000u32 {
|
||||
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{n}").as_bytes()) else { break };
|
||||
let Some(stream) = private.get::<Stream<'_>>(format!("{key}{n}").as_bytes()) else { break };
|
||||
data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?);
|
||||
}
|
||||
(!data.is_empty()).then_some(data)
|
||||
}
|
||||
|
||||
/// Does the PDF's first page carry an editor's private data (a PDF-compatible `.ai`)? Without
|
||||
/// reading the data.
|
||||
pub fn has_illustrator_data(bytes: &[u8], password: Option<&str>) -> bool {
|
||||
open(bytes, password).is_ok_and(|pdf| pdf.pages().first().is_some_and(has_private_data))
|
||||
}
|
||||
|
||||
/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's
|
||||
/// `decoded()`, which has no limit, so a stream of zeros can't grow to the size of the memory. Only a
|
||||
/// stream that is plain or only deflated is read (the private data is no other); else, or when it
|
||||
|
||||
+74
-1
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
|
||||
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
|
||||
//! and shading Extend flags; strokes stay live strokes.
|
||||
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity,
|
||||
//! shading Extend flags and shapes filled with a shading → gradient-filled paths; strokes stay
|
||||
//! live strokes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -352,6 +353,37 @@ fn a_shading_that_does_not_extend_paints_only_between_its_ends() {
|
||||
assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
/// A diamond (10–90 × 10–90) clipping `paint`, which fills its bounds: how a shape filled with a
|
||||
/// gradient is written (`W n`, then `sh`).
|
||||
fn clipped_diamond(paint: &str) -> Document {
|
||||
let shading = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [10 0 90 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>";
|
||||
let resources = format!("/Shading << /Sh0 {shading} >> /Pattern << /P0 << /PatternType 2 /Shading {shading} >> >>");
|
||||
open(&one_page(&format!("q 10 50 m 50 90 l 90 50 l 50 10 l h W n {paint} Q"), &resources, &[]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_filled_with_a_shading_becomes_one_path_with_the_gradient() {
|
||||
let d = clipped_diamond("/Sh0 sh");
|
||||
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "no group");
|
||||
let objs = objects(&d);
|
||||
let [n] = objs.as_slice() else { panic!("one object: {objs:#?}") };
|
||||
let p = n.path_data().unwrap();
|
||||
assert!(p.is_closed() && p.anchor_count() == 4, "the diamond: {p:?}");
|
||||
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", n.geometric_bounds());
|
||||
let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) else { panic!("a gradient fill: {:?}", n.appearance.items) };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Linear);
|
||||
assert_eq!(g.gradient.stops.first().map(|s| s.color.to_rgb()), Some([1.0, 0.0, 0.0]));
|
||||
assert_eq!(g.gradient.stops.last().map(|s| s.color.to_rgb()), Some([0.0, 0.0, 1.0]));
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
assert!((geom.start.x - 10.0).abs() < 0.01 && (geom.end.x - 90.0).abs() < 0.01, "placed as before: {geom:?}");
|
||||
assert!(n.appearance.stroke().is_none());
|
||||
// A gradient that doesn't cover the clip, and a flat colour, stay clipped.
|
||||
for paint in ["/Pattern cs /P0 scn 40 40 20 20 re f", "1 0 0 rg 0 0 100 100 re f"] {
|
||||
let d = clipped_diamond(paint);
|
||||
assert!(all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "{paint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_items_are_untouched_for_plain_art() {
|
||||
// Plain art keeps importing as before: no masks, groups or clips appear.
|
||||
|
||||
@@ -408,3 +408,19 @@ fn an_ai_files_private_data_is_its_streams_joined() {
|
||||
let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>");
|
||||
assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none());
|
||||
}
|
||||
|
||||
/// A PDF saved with the editing data has its streams under `AIPDFPrivateData` keys instead (#868);
|
||||
/// a file with both kinds is read from its `AIPrivateData` streams.
|
||||
#[test]
|
||||
fn a_pdfs_private_data_is_its_pdf_streams_joined() {
|
||||
let (a, b, c) = (first_extra(1), first_extra(1) + 1, first_extra(1) + 2);
|
||||
let page = |keys: String| PdfPage {
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << {keys} /NumBlock 2 >> >> >>"),
|
||||
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
|
||||
};
|
||||
let (one, two, other) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"), stream("", "%AI24_ZStandard_Data other"));
|
||||
let pdf = pdf_with_catalog(&[page(format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R"))], &[&one, &two], "", None);
|
||||
assert_eq!(illustrator_data(&pdf, None).unwrap(), b"%AI24_ZStandard_Data first second");
|
||||
let both = format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R /AIPrivateData1 {c} 0 R");
|
||||
assert_eq!(illustrator_data(&pdf_with_catalog(&[page(both)], &[&one, &two, &other], "", None), None).unwrap(), b"%AI24_ZStandard_Data other");
|
||||
}
|
||||
@@ -52,6 +52,45 @@ fn each_crop_box_sizes_the_artboard_and_places_the_art_in_it() {
|
||||
assert_eq!(ImportOptions::default().crop, CropTo::Crop);
|
||||
}
|
||||
|
||||
/// 10 mm in points.
|
||||
const BLEED: f64 = 10.0 * 72.0 / 25.4;
|
||||
|
||||
/// A 300 × 200 sheet with a 200 × 100 trim box, the bleed box 10 mm round it, and a red rectangle
|
||||
/// reaching 20 pt past the trim box's left and bottom edges into the bleed.
|
||||
fn bled_page() -> PdfPage {
|
||||
PdfPage {
|
||||
bleed: Some([50.0 - BLEED, 50.0 - BLEED, 250.0 + BLEED, 150.0 + BLEED]),
|
||||
trim: Some([50.0, 50.0, 250.0, 150.0]),
|
||||
..PdfPage::new(300.0, 200.0, "1 0 0 rg 30 30 100 60 re f")
|
||||
}
|
||||
}
|
||||
|
||||
/// The same within the precision of a box read from a file (32-bit floats).
|
||||
fn near(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_box_artboards_take_the_bleed_box_round_them_as_the_document_bleed() {
|
||||
let bytes = pdf(&[bled_page()], None);
|
||||
let trim = ImportOptions { crop: CropTo::Trim, ..Default::default() };
|
||||
let d = open(&bytes, trim.clone());
|
||||
let ab = d.artboards[0].rect;
|
||||
assert!(close(ab, Rect::new(0.0, 0.0, 200.0, 100.0)), "{ab:?}");
|
||||
assert!(near(d.setup.bleed, [BLEED; 4]), "{:?}", d.setup.bleed);
|
||||
// The rectangle comes in whole, reaching into the bleed (y down: the bottom bleed is below).
|
||||
assert!(close(art(&d), Rect::new(-20.0, 60.0, 80.0, 120.0)), "{:?}", art(&d));
|
||||
assert!(d.setup.bleed_rect(ab).contains_rect(art(&d)));
|
||||
// Each side as the page shows it: PDF space is y up, and a rotated page turns its bleed.
|
||||
let uneven = PdfPage { bleed: Some([49.0, 48.0, 253.0, 154.0]), ..bled_page() };
|
||||
let turned = PdfPage { rotate: 90, ..uneven.clone() };
|
||||
assert!(near(open(&pdf(&[uneven], None), trim.clone()).setup.bleed, [4.0, 2.0, 1.0, 3.0]));
|
||||
assert!(near(open(&pdf(&[turned], None), trim).setup.bleed, [1.0, 3.0, 2.0, 4.0]));
|
||||
// On the crop box (the sheet here) there is no bleed round the artboard.
|
||||
let d = open(&bytes, ImportOptions::default());
|
||||
assert_eq!((d.artboards[0].rect.width(), d.setup.bleed), (300.0, [0.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_boxes_fall_back_to_the_crop_box_and_boxes_are_clipped_to_the_media_box() {
|
||||
let page = PdfPage { crop: Some([10.0, 10.0, 290.0, 390.0]), art: Some([-50.0, 0.0, 100.0, 100.0]), ..PdfPage::new(300.0, 400.0, "") };
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -684,8 +684,8 @@ impl Renderer {
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
Ink::K => effects::pixel::Channels::KPlane,
|
||||
Ink::Display if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display => effects::pixel::Channels::Rgb,
|
||||
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
|
||||
@@ -40,13 +40,16 @@ pub(crate) enum Ink {
|
||||
Cmy,
|
||||
/// The complemented K ink, as a grey.
|
||||
K,
|
||||
/// Screen colours, with pure CMYK black as RGB black ([`RenderOptions::rich_black`]).
|
||||
RichBlack,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// The three values painted for colour `c`.
|
||||
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
|
||||
match self {
|
||||
Ink::Display => c.to_rgb(),
|
||||
match (self, *c) {
|
||||
(Ink::RichBlack, Color::Cmyk { c, m, y, k }) if is_pure_black([c, m, y, k]) => [0.0; 3],
|
||||
(Ink::Display | Ink::RichBlack, _) => c.to_rgb(),
|
||||
_ => self.plane(inks(c)),
|
||||
}
|
||||
}
|
||||
@@ -65,7 +68,7 @@ impl Ink {
|
||||
|
||||
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
|
||||
match self {
|
||||
Ink::Display => [r, g, b, a],
|
||||
Ink::Display | Ink::RichBlack => [r, g, b, a],
|
||||
_ => {
|
||||
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
|
||||
let [r, g, b] = self.plane(inks(&Color::Lab { l: lab.l, a: lab.a, b: lab.b })).map(q);
|
||||
@@ -83,6 +86,11 @@ fn q(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Black ink alone at 100%: what Appearance of Black calls pure CMYK black.
|
||||
fn is_pure_black([c, m, y, k]: [f32; 4]) -> bool {
|
||||
c <= 0.0 && m <= 0.0 && y <= 0.0 && k >= 1.0
|
||||
}
|
||||
|
||||
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
|
||||
/// in Outline view or Separations Preview (which paints plates).
|
||||
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
|
||||
@@ -168,11 +176,12 @@ fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> (Arc<T>, u64) {
|
||||
}
|
||||
|
||||
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
|
||||
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where
|
||||
/// only the frame's `background` shows it keeps its exact screen colour.
|
||||
pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<u8> {
|
||||
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile (pure
|
||||
/// black as RGB black with [`RenderOptions::rich_black`]). Where only the `background` of `opts`
|
||||
/// shows it keeps its exact screen colour.
|
||||
pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions) -> Vec<u8> {
|
||||
let (lut, _) = cached(&DISPLAY, InkLut::build);
|
||||
let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
|
||||
let bg = opts.background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
|
||||
let mut out = Vec::with_capacity(cmy.len());
|
||||
// Runs of one colour convert once.
|
||||
let mut last: Option<([u8; 8], [u8; 4])> = None;
|
||||
@@ -185,7 +194,8 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<
|
||||
_ => {
|
||||
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
|
||||
let a = p[3];
|
||||
let rgb = lut.apply(planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3])));
|
||||
let inks = planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3]));
|
||||
let rgb = if opts.rich_black && is_pure_black(inks) { [0.0; 3] } else { lut.apply(inks) };
|
||||
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
|
||||
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
|
||||
last = Some((key, v));
|
||||
@@ -245,7 +255,7 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option<Pixmap> {
|
||||
/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as
|
||||
/// painted with `ink`, cached with the images, and their key.
|
||||
pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
|
||||
if ink == Ink::Display {
|
||||
if matches!(ink, Ink::Display | Ink::RichBlack) {
|
||||
return (key.clone(), pm.clone());
|
||||
}
|
||||
let (luts, generation) = cached(&SEPARATE, |cms| {
|
||||
|
||||
@@ -127,6 +127,9 @@ pub struct RenderOptions {
|
||||
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
|
||||
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
|
||||
pub image_mips: bool,
|
||||
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
|
||||
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
|
||||
pub rich_black: bool,
|
||||
}
|
||||
|
||||
/// The opacity of the art around an isolated group or layer.
|
||||
@@ -201,6 +204,7 @@ impl Default for RenderOptions {
|
||||
precise: false,
|
||||
supersample: false,
|
||||
image_mips: false,
|
||||
rich_black: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,7 +553,8 @@ impl Renderer {
|
||||
let inv = view.inverse();
|
||||
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
|
||||
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
|
||||
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: screen };
|
||||
self.begin_frame();
|
||||
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
|
||||
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
|
||||
@@ -564,7 +569,7 @@ impl Renderer {
|
||||
let stats = self.stats;
|
||||
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
|
||||
self.stats = stats;
|
||||
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) }
|
||||
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts) }
|
||||
} else {
|
||||
self.draw_frame(&frame, w, h)
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ struct TileEntry {
|
||||
|
||||
struct PatternCache {
|
||||
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
|
||||
tiles: [FrameCache<String, TileEntry>; 3],
|
||||
tiles: [FrameCache<String, TileEntry>; 4],
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
@@ -131,3 +131,34 @@ fn placed_images_are_separated_not_blackened() {
|
||||
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
|
||||
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
|
||||
}
|
||||
|
||||
/// Appearance of Black's Rich Black (`rich_black`, #394): pure CMYK black (black ink alone at
|
||||
/// 100%) shows as RGB black, in an opaque CMYK document (screen colours) and in one drawn by ink
|
||||
/// planes (Multiply); a rich black, a black tint and placed images show as they do without it.
|
||||
#[test]
|
||||
fn rich_black_shows_black_ink_alone_as_rgb_black() {
|
||||
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
let centre_with = |d: &Document, rich_black| {
|
||||
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, rich_black, ..Default::default() };
|
||||
Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)
|
||||
};
|
||||
for blend in [Blend::Normal, Blend::Multiply] {
|
||||
let black = doc(ColorMode::Cmyk, None, k(1.0), blend, 1.0);
|
||||
let accurate = centre_with(&black, false);
|
||||
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0]), 1) && accurate[0] > 10, "{blend:?}: accurate {accurate:?}");
|
||||
assert_eq!(centre_with(&black, true), [0, 0, 0, 255], "{blend:?}: rich");
|
||||
for c in [Color::cmyk(0.6, 0.4, 0.4, 1.0), k(0.5)] {
|
||||
let d = doc(ColorMode::Cmyk, None, c, blend, 1.0);
|
||||
assert_eq!(centre_with(&d, true), centre_with(&d, false), "{blend:?}: {c:?}");
|
||||
}
|
||||
}
|
||||
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
|
||||
let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
add(&mut d, n);
|
||||
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
|
||||
//! EPS and `.ai` files that carry it, for import tests. Nothing here comes from files another app
|
||||
//! wrote.
|
||||
//! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another
|
||||
//! app wrote, except the key names that [`pdf`] uses.
|
||||
|
||||
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
|
||||
/// marks are the page), `body` (setup and layers), the trailer.
|
||||
@@ -104,6 +104,19 @@ pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
|
||||
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
|
||||
/// two streams.
|
||||
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
|
||||
with_private(private, page, "AIPrivateData")
|
||||
}
|
||||
|
||||
/// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under
|
||||
/// `AIPDFPrivateData1` and `AIPDFPrivateData2`. These key names were seen in the PDF attached to
|
||||
/// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too
|
||||
/// (<https://exiftool.org/TagNames/PDF.html>).
|
||||
pub fn pdf(private: &[u8], page: &str) -> Vec<u8> {
|
||||
with_private(private, page, "AIPDFPrivateData")
|
||||
}
|
||||
|
||||
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`.
|
||||
fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
|
||||
let (first, second) = private.split_at(private.len() / 2);
|
||||
let stream = |part: &[u8]| {
|
||||
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
|
||||
@@ -117,7 +130,7 @@ pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
|
||||
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
|
||||
/PieceInfo << /{PIECE} << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
|
||||
/PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
stream(page.as_bytes()),
|
||||
|
||||
@@ -238,6 +238,17 @@ pub struct CornerDrag {
|
||||
/// The pointer went past fully round: the dragged corner is at its largest radius.
|
||||
at_limit: bool,
|
||||
at: Point,
|
||||
/// Direct Selection: what a click (no drag) on the pressed widget selects.
|
||||
click: Option<CornerClick>,
|
||||
}
|
||||
|
||||
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
|
||||
/// (Shift: toggles), as a click on the corner's anchor does.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CornerClick {
|
||||
id: NodeId,
|
||||
anchors: Vec<(usize, usize)>,
|
||||
shift: bool,
|
||||
}
|
||||
|
||||
impl CornerDrag {
|
||||
@@ -247,6 +258,14 @@ impl CornerDrag {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
|
||||
let widget = widgets.hit(p, cx.tol(5.0))?;
|
||||
let radius = widgets.widget(widget.0, widget.1)?.radius;
|
||||
let click = any_path
|
||||
.then(|| {
|
||||
let path = widgets.paths.get(widget.0)?;
|
||||
let corner = path.widgets.get(widget.1)?.corner.index;
|
||||
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
|
||||
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
|
||||
})
|
||||
.flatten();
|
||||
let outlines = widgets
|
||||
.paths
|
||||
.iter()
|
||||
@@ -257,7 +276,7 @@ impl CornerDrag {
|
||||
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
|
||||
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click })
|
||||
}
|
||||
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
@@ -323,6 +342,11 @@ impl CornerDrag {
|
||||
vec![Action::Commit]
|
||||
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
|
||||
} else if let Some(c) = self.click {
|
||||
// Direct Selection: the click selects the widget's corner, so the next drag on this
|
||||
// widget rounds that corner alone (its widget is then the only one showing).
|
||||
let mode = if c.shift { "toggle" } else { "set" };
|
||||
vec![Action::Exec("select.anchors".into(), json!({"id": c.id.0, "anchors": c.anchors, "mode": mode}))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
@@ -531,6 +555,37 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
|
||||
}
|
||||
|
||||
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
|
||||
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
|
||||
/// toggles the corner. The Selection tool's click still does nothing.
|
||||
#[test]
|
||||
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
|
||||
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = pts(&CornerWidgets::of(&d, &s, 1.0, true).unwrap())[2];
|
||||
// The bottom-right corner of a rounded rectangle is drawn with two anchors.
|
||||
let anchors: Vec<(usize, usize)> = LiveCorners::of(d.node(id).unwrap()).unwrap().anchors_of(&[2].into()).into_iter().collect();
|
||||
assert_eq!(anchors.len(), 2);
|
||||
let mut t = crate::create("directSelection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
// With that corner selected, its widget alone shows, and a drag rounds it alone
|
||||
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
|
||||
let mut part = selected(id);
|
||||
part.anchors.insert(id, anchors.iter().copied().collect());
|
||||
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Artboard tool (Shift+O).
|
||||
//!
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds
|
||||
//! Click an object to create an artboard fitted to its visible bounds; drag still moves the
|
||||
//! containing artboard or draws one on the pasteboard. Click an empty part of an artboard to
|
||||
//! make it active (dashed bounds, 8 handles and its name); Shift-click adds
|
||||
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
|
||||
//! all of them (#1139). Drag inside moves
|
||||
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
|
||||
@@ -60,6 +62,8 @@ pub struct ArtboardTool {
|
||||
/// artboard's proportions.
|
||||
pub scale_art: bool,
|
||||
drag: Option<Drag>,
|
||||
/// The pressed object can be fitted on release; a drag, modifier or cancellation takes over.
|
||||
click_art: Option<(Point, Rect)>,
|
||||
preview: Option<Rect>,
|
||||
/// Smart Guide targets, gathered when a move or resize starts.
|
||||
targets: Option<Targets>,
|
||||
@@ -79,6 +83,7 @@ impl Default for ArtboardTool {
|
||||
move_art: true,
|
||||
scale_art: false,
|
||||
drag: None,
|
||||
click_art: None,
|
||||
preview: None,
|
||||
targets: None,
|
||||
copy_targets: None,
|
||||
@@ -178,6 +183,7 @@ impl ArtboardTool {
|
||||
|
||||
fn end_drag(&mut self) {
|
||||
self.drag = None;
|
||||
self.click_art = None;
|
||||
self.preview = None;
|
||||
self.targets = None;
|
||||
self.copy_targets = None;
|
||||
@@ -189,6 +195,13 @@ impl ArtboardTool {
|
||||
/// An artboard's shortest side (as `artboard.setProps` keeps it).
|
||||
const MIN_SIDE: f64 = 1.0;
|
||||
|
||||
fn create_artboard(r: Rect) -> Vec<Action> {
|
||||
vec![
|
||||
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
|
||||
Action::Notify("created".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
|
||||
/// proportional resize keeps its proportions where the command would lengthen only the short side.
|
||||
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
|
||||
@@ -220,14 +233,26 @@ impl Tool for ArtboardTool {
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let m = ev.mods;
|
||||
if ev.kind == PointerKind::Drag && self.click_art.is_some_and(|(start, _)| m != Mods::default() || p.distance(start) >= cx.tol(3.0)) {
|
||||
self.click_art = None;
|
||||
}
|
||||
match (ev.kind, self.drag) {
|
||||
(PointerKind::Down, _) => {
|
||||
self.click_art = None;
|
||||
if let Some(r) = self.active_rect(cx)
|
||||
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
|
||||
{
|
||||
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
|
||||
return vec![];
|
||||
}
|
||||
if m == Mods::default() {
|
||||
self.click_art = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())
|
||||
.and_then(|h| cx.doc.bounds_of(&[h.top_object(cx.isolation)], true))
|
||||
// Match Fit to Selected Art, and do not stretch zero-sized art to the
|
||||
// artboard command's minimum side or pass non-finite bounds to it.
|
||||
.filter(|r| r.width() >= MIN_SIDE && r.height() >= MIN_SIDE && [r.x0, r.y0, r.x1, r.y1].iter().all(|n| n.is_finite()))
|
||||
.map(|r| (p, r));
|
||||
}
|
||||
if let Some(i) = cx.doc.artboard_at(p)
|
||||
&& let Some(a) = cx.doc.artboards.get(i)
|
||||
{
|
||||
@@ -299,7 +324,14 @@ impl Tool for ArtboardTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Up, Some(d)) => {
|
||||
let click_art = self.click_art;
|
||||
self.end_drag();
|
||||
if m == Mods::default()
|
||||
&& let Some((start, r)) = click_art
|
||||
&& p.distance(start) < cx.tol(3.0)
|
||||
{
|
||||
return create_artboard(r);
|
||||
}
|
||||
match d {
|
||||
Drag::Move { began: true, copy, many, .. } => {
|
||||
// The copies, last in the previewed document, become the selection (the
|
||||
@@ -318,10 +350,7 @@ impl Tool for ArtboardTool {
|
||||
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
|
||||
return vec![];
|
||||
}
|
||||
vec![
|
||||
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
|
||||
Action::Notify("created".into()),
|
||||
]
|
||||
create_artboard(r)
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
|
||||
+133
-10
@@ -185,9 +185,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
ruler_guides(app, ui, full, rect, &xf);
|
||||
}
|
||||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
context_menu(app, &resp, &xf);
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -254,6 +252,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
pixel,
|
||||
smooth_images,
|
||||
isolated: st.isolation.map(|id| id.0),
|
||||
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
|
||||
rich_black: app.session.prefs.black_on_screen == "rich",
|
||||
};
|
||||
// Placed documents' bitmaps are being made: draw again when they are ready.
|
||||
if vectorcraft_render::placed_document::busy() {
|
||||
@@ -307,6 +307,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
isolated: key.isolated.map(vectorcraft_doc::NodeId),
|
||||
// Images far smaller on screen than they are sample their mip levels (#1244).
|
||||
image_mips: true,
|
||||
rich_black: key.rich_black,
|
||||
..opts
|
||||
};
|
||||
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
|
||||
@@ -432,7 +433,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
if let Some(origin) = origin_preview {
|
||||
let at = xf.to_screen(origin);
|
||||
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim));
|
||||
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin()), &t);
|
||||
}
|
||||
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
@@ -1073,8 +1079,8 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||||
/// it makes none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) {
|
||||
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
|
||||
fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
|
||||
@@ -1101,14 +1107,49 @@ fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egu
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click());
|
||||
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click_and_drag());
|
||||
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
if let Some((id, p)) = unit_clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
if corner.double_clicked_by(egui::PointerButton::Primary) {
|
||||
let artboard = app.view().map_or(0, |v| v.artboard);
|
||||
if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
// Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling
|
||||
// never adds an undo step or interferes with the drawing tool's interaction.
|
||||
let drag_id = egui::Id::new("ruler-origin-drag");
|
||||
let uid = app.session.active().map(|d| d.uid);
|
||||
if corner.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (uid, false)));
|
||||
}
|
||||
let mut preview = None;
|
||||
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
|
||||
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
|
||||
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
|
||||
if corner.drag_stopped_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
if !cancelled
|
||||
&& let Some(at) = at
|
||||
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y}))
|
||||
{
|
||||
app.status(e.to_string());
|
||||
}
|
||||
} else if !ui.input(|i| i.pointer.primary_down()) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
|
||||
if !cancelled {
|
||||
preview = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, origin: Point, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
for r in [top, left, corner] {
|
||||
@@ -1140,7 +1181,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
|
||||
// (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance
|
||||
// (screen points) from `from` along the ruler, and its value in `unit`.
|
||||
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| {
|
||||
let along = |s: Pos2| xf.to_doc(s).to_vec2().dot(axis);
|
||||
let along = |s: Pos2| (xf.to_doc(s) - origin).dot(axis);
|
||||
let (a, b) = (along(from), along(to));
|
||||
let mut v = (a / per / minor).floor() * minor;
|
||||
while v * per <= b {
|
||||
@@ -2165,7 +2206,7 @@ mod tests {
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
|
||||
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, &Tokens::get(ui.ctx())));
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, Point::ZERO, &Tokens::get(ui.ctx())));
|
||||
out.textures_delta.clear();
|
||||
// The ticks' positions along each ruler.
|
||||
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
|
||||
@@ -2196,6 +2237,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
let origin = Point::new(350.25, 250.5);
|
||||
for deg in [0.0_f64, 30.0, 90.0, 200.0] {
|
||||
for unit in [Unit::Points, Unit::Millimeters] {
|
||||
let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, unit, origin, &Tokens::get(ui.ctx())));
|
||||
out.textures_delta.clear();
|
||||
let at = xf.to_screen(origin);
|
||||
for (r, horizontal) in [(top, true), (left, false)] {
|
||||
let zeros: Vec<_> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|c| c.clip_rect == r)
|
||||
.filter_map(|c| match &c.shape {
|
||||
Shape::Text(t) if t.galley.text() == "0" => Some(t.pos),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Left-ruler labels are separate digits: a zero in "100" isn't the zero label.
|
||||
assert!(
|
||||
zeros.iter().any(|zero| {
|
||||
let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y };
|
||||
delta.abs() < 0.02
|
||||
}),
|
||||
"{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_selection_highlights_only_the_visible_result() {
|
||||
let mut s = Session::new();
|
||||
@@ -2827,6 +2904,52 @@ mod tests {
|
||||
assert!(smooth(&mut app), "Pixel Preview, on");
|
||||
}
|
||||
|
||||
/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows
|
||||
/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black
|
||||
/// shows it as RGB black. A rich black shows the same either way.
|
||||
#[test]
|
||||
fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
// Larger than the view: the middle of the art's raster is its fill.
|
||||
let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone();
|
||||
let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| {
|
||||
let [c, m, y, k] = cmyk;
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap();
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
// The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered.
|
||||
let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> {
|
||||
let (_, mut delta) = frame_output(app, &ctx);
|
||||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||||
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||||
let egui::ImageData::Color(img) = &d.image;
|
||||
let [w, h] = img.size;
|
||||
img.pixels[h / 2 * w + w / 2].to_array()
|
||||
});
|
||||
delta.clear();
|
||||
px
|
||||
};
|
||||
let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0);
|
||||
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
|
||||
fill(&mut app, [0, 0, 0, 100]);
|
||||
let accurate = middle(&mut app).expect("the first frame renders the art");
|
||||
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}");
|
||||
assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}");
|
||||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
|
||||
assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black");
|
||||
|
||||
fill(&mut app, [60, 40, 40, 100]);
|
||||
let rich = middle(&mut app).expect("re-rendered");
|
||||
assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}");
|
||||
|
||||
fill(&mut app, [0, 0, 0, 100]);
|
||||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap();
|
||||
assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again");
|
||||
}
|
||||
|
||||
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
|
||||
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
|
||||
/// or with the option off.
|
||||
|
||||
@@ -313,15 +313,21 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
&& ui.available_width() >= INLINE_TRANSFORM_WIDTH
|
||||
{
|
||||
let c = b.center();
|
||||
let origin = app.ruler_origin();
|
||||
let link = app.session.prefs.constrain_proportions;
|
||||
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] {
|
||||
for (k, lbl, v, offset) in [
|
||||
("x", "X:", c.x, origin.x),
|
||||
("y", "Y:", c.y, origin.y),
|
||||
("width", "W:", b.rect.width(), 0.0),
|
||||
("height", "H:", b.rect.height(), 0.0),
|
||||
] {
|
||||
// The W/H link sits between W and H.
|
||||
if k == "height" {
|
||||
crate::panels::transform::constrain_link(app, ui);
|
||||
}
|
||||
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
|
||||
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
|
||||
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
|
||||
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v - offset), units, 80.0) {
|
||||
app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w
|
||||
fn lengths(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"move" => &["dx", "dy"],
|
||||
"rectangle" | "ellipse" | "rectangularGrid" | "artboardOptions" => &["width", "height"],
|
||||
"rectangle" | "ellipse" | "rectangularGrid" | "polarGrid" | "artboardOptions" => &["width", "height"],
|
||||
"roundedRectangle" => &["width", "height", "radius"],
|
||||
"polygon" => &["radius"],
|
||||
"star" => &["radius1", "radius2"],
|
||||
@@ -68,6 +68,7 @@ fn order(kind: &str) -> &'static [&'static str] {
|
||||
"offsetPath" => &["offset", "joins", "miterLimit"],
|
||||
"splitIntoGrid" => &["rows", "columns", "gutter"],
|
||||
"rectangularGrid" => &["width", "height", "rows", "columns"],
|
||||
"polarGrid" => &["width", "height", "concentric", "radial"],
|
||||
"artboardOptions" => &["name", "width", "height"],
|
||||
_ => &[],
|
||||
}
|
||||
@@ -182,6 +183,8 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let label = match (d.kind.as_str(), k.as_str()) {
|
||||
("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(),
|
||||
("rectangularGrid", "columns") => tl!("Vertical Dividers:").into(),
|
||||
("polarGrid", "concentric") => tl!("Concentric Dividers:").into(),
|
||||
("polarGrid", "radial") => tl!("Radial Dividers:").into(),
|
||||
_ => humanized(&k),
|
||||
};
|
||||
field(ui, d, &k, &label);
|
||||
|
||||
@@ -290,7 +290,19 @@ mod tests {
|
||||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
app.run("document.save", json!({"path": doc})).unwrap();
|
||||
let original_stamp = std::fs::metadata(&pic).unwrap().modified().unwrap();
|
||||
let mark_modified = |seconds| {
|
||||
// Link stamps use milliseconds. Equal-size writes can finish within one tick;
|
||||
// advance the fixture's mtime explicitly rather than relying on a sleep or load.
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&pic)
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(original_stamp + std::time::Duration::from_secs(seconds)))
|
||||
.unwrap();
|
||||
};
|
||||
std::fs::write(&pic, png([0, 0, 200])).unwrap();
|
||||
mark_modified(2);
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("asks");
|
||||
assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string()));
|
||||
@@ -299,8 +311,10 @@ mod tests {
|
||||
// Manually: no question.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
|
||||
std::fs::write(&pic, png([0, 200, 0])).unwrap();
|
||||
mark_modified(4);
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 1);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,7 @@ macro_rules! registry {
|
||||
|
||||
registry! {
|
||||
NewDocument: [new_document::KIND] => new_document::SPEC,
|
||||
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid"] => shapes::SPEC,
|
||||
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid", "polarGrid"] => shapes::SPEC,
|
||||
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
|
||||
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
|
||||
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
|
||||
|
||||
@@ -17,6 +17,7 @@ fn title(kind: &str) -> &'static str {
|
||||
"polygon" => tl!("Polygon"),
|
||||
"star" => tl!("Star"),
|
||||
"rectangularGrid" => tl!("Rectangular Grid"),
|
||||
"polarGrid" => tl!("Polar Grid"),
|
||||
_ => tl!("Line Segment Tool Options"),
|
||||
}
|
||||
}
|
||||
@@ -35,6 +36,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
|
||||
"rows": (d.f64("rows", 5.0) as u64).min(999), "columns": (d.f64("columns", 5.0) as u64).min(999)}),
|
||||
),
|
||||
"polarGrid" => (
|
||||
"shape.polarGrid",
|
||||
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
|
||||
"concentric": (d.f64("concentric", 5.0) as u64).min(999), "radial": (d.f64("radial", 5.0) as u64).min(999)}),
|
||||
),
|
||||
"lineSegment" => {
|
||||
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
|
||||
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
|
||||
@@ -45,13 +51,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
),
|
||||
};
|
||||
// On the active plane while the perspective grid shows, sized in plane units from the click.
|
||||
let options = (d.kind == "rectangularGrid").then(|| json!({"rows": params["rows"], "columns": params["columns"]}));
|
||||
let options = matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid").then(|| params.clone());
|
||||
let result = match vectorcraft_engine::perspective_click(&app.session, id, ¶ms, vectorcraft_geom::Point::new(x, y)) {
|
||||
Some((id, params)) => run_and_close(app, &id, params),
|
||||
None => run_and_close(app, id, params),
|
||||
}?;
|
||||
if let Some(options) = options {
|
||||
app.run("tool.setOption", json!({"tool": "rectangularGrid", "values": options}))?;
|
||||
app.run("tool.setOption", json!({"tool": d.kind, "values": options}))?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ fn kinds_map_to_their_dialogs() {
|
||||
("roundedRectangle", DialogKind::Shape),
|
||||
("lineSegment", DialogKind::Shape),
|
||||
("rectangularGrid", DialogKind::Shape),
|
||||
("polarGrid", DialogKind::Shape),
|
||||
("shear", DialogKind::Transform),
|
||||
("splitIntoGrid", DialogKind::PathOp),
|
||||
("artboardOptions", DialogKind::ArtboardOptions),
|
||||
@@ -87,6 +88,7 @@ fn every_dialog_draws() {
|
||||
"star",
|
||||
"lineSegment",
|
||||
"rectangularGrid",
|
||||
"polarGrid",
|
||||
"rotate",
|
||||
"reflect",
|
||||
"scale",
|
||||
@@ -650,3 +652,72 @@ fn zig_zag_has_sliders_and_radio_buttons() {
|
||||
}
|
||||
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = app();
|
||||
app.select_tool("polarGrid");
|
||||
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 30.0, 40.0), Default::default());
|
||||
}
|
||||
assert_eq!(DialogKind::of("polarGrid"), Some(DialogKind::Shape));
|
||||
let d = app.ui.dialog.as_mut().expect("canvas click opens the shared shape form");
|
||||
assert_eq!(d.fields["width"], json!("100 pt"));
|
||||
assert_eq!(d.fields["height"], json!("100 pt"));
|
||||
assert_eq!((d.fields["concentric"].clone(), d.fields["radial"].clone()), (json!(2), json!(3)));
|
||||
d.fields.insert("width".into(), json!("25.4 mm"));
|
||||
d.fields.insert("height".into(), json!("0.5 in"));
|
||||
frame(&mut app, Default::default());
|
||||
let result = confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let s = app.session.doc().unwrap();
|
||||
let group = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap();
|
||||
let children = group.children().unwrap();
|
||||
assert_eq!(children.len(), 6);
|
||||
assert_eq!(children.iter().filter(|n| n.path_data().unwrap().is_closed()).count(), 3);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
let made = s.doc.clone();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, made);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
|
||||
let mut app = app();
|
||||
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
|
||||
let d = app.ui.dialog.as_mut().expect("shared Polar Grid form opens");
|
||||
d.fields.insert("concentric".into(), json!(8));
|
||||
cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["concentric"], json!(2));
|
||||
for key in ["concentric", "radial"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(0));
|
||||
}
|
||||
let result = confirm(&mut app).unwrap();
|
||||
let group = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(result["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(group.children().unwrap().len(), 1);
|
||||
assert!(group.children().unwrap()[0].path_data().unwrap().is_closed());
|
||||
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["radial"], json!(0));
|
||||
cancel(&mut app);
|
||||
let layer = app.session.doc().unwrap().doc.layers[0].id;
|
||||
app.run("layer.setProps", json!({"ids": [layer.0], "locked": true})).unwrap();
|
||||
let options = app.session.tool_options_of("polarGrid");
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("radial".into(), json!(9));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.tool_options_of("polarGrid"), options);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
|
||||
let options = app.session.tool_options_of(kind);
|
||||
Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "rows": options["rows"], "columns": options["columns"]}))
|
||||
}
|
||||
"polarGrid" => {
|
||||
let options = app.session.tool_options_of(kind);
|
||||
Dialog::new(
|
||||
kind,
|
||||
json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "concentric": options["concentric"], "radial": options["radial"]}),
|
||||
)
|
||||
}
|
||||
// Graph tool click: the graph's size.
|
||||
"graph" => Dialog::new(
|
||||
"command",
|
||||
|
||||
@@ -4198,6 +4198,8 @@ effect Regular Pravidelný
|
||||
@msg missing panel layout chybí rozložení panelů
|
||||
@msg unsupported native docking operation nepodporovaná nativní operace ukotvení
|
||||
@msg this kind of brush has no options dialog yet tento druh štětce zatím nemá dialogové okno možností
|
||||
Ruler Origin Počátek pravítek
|
||||
Reset Ruler Origin Obnovit počátek pravítek
|
||||
Column Width: Šířka sloupce:
|
||||
Import Data… Importovat data…
|
||||
Number of Decimals: Počet desetinných míst:
|
||||
@@ -4496,3 +4498,5 @@ mark-style Japanese Japonské
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) komprimovaný soubor se rozbalí na více, než VectorCraft přečte ({_1} MB dokumentu, {_2} MB na obrázek)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off dokument je příliš velký pro komprimované uložení ({0}): uložte ho s vypnutou volbou Použít kompresi
|
||||
@msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu)
|
||||
Concentric Dividers: Soustředné dělicí čáry:
|
||||
Radial Dividers: Radiální dělicí čáry:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4140,6 +4140,8 @@ theme Appearance Erscheinungsbild
|
||||
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
|
||||
Linux Display Server Linux-Anzeigeserver
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Linealursprung
|
||||
Reset Ruler Origin Linealursprung zurücksetzen
|
||||
Artistic Kunstfilter
|
||||
Film Grain… Filmkörnung…
|
||||
Grain Körnung
|
||||
@@ -4147,3 +4149,5 @@ theme Appearance Erscheinungsbild
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) die komprimierte Datei entpackt sich auf mehr, als VectorCraft liest ({_1} MB Dokument, {_2} MB pro Bild)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off das Dokument ist zu groß, um komprimiert gespeichert zu werden ({0}): speichern Sie es mit deaktivierter Option „Komprimierung verwenden“
|
||||
@msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler)
|
||||
Concentric Dividers: Konzentrische Trennlinien:
|
||||
Radial Dividers: Radiale Trennlinien:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4184,9 +4184,387 @@ lighting Light Luz
|
||||
@msg unsupported native docking operation Operación nativa de anclaje no admitida
|
||||
Linux Display Server Servidor de pantalla de Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origen de las reglas
|
||||
Reset Ruler Origin Restablecer el origen de las reglas
|
||||
Artistic Artístico
|
||||
Film Grain… Granulado de película…
|
||||
Highlight Area Área de iluminaciones
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) el archivo comprimido ocupa al descomprimirse más de lo que VectorCraft lee ({_1} MB de documento, {_2} MB por imagen)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off el documento es demasiado grande para guardarlo comprimido ({0}): guárdelo con Usar compresión desactivado
|
||||
@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.
|
@@ -4194,9 +4194,13 @@ lighting Light Lumière
|
||||
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
|
||||
Linux Display Server Serveur d’affichage Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origine des règles
|
||||
Reset Ruler Origin Réinitialiser l’origine des règles
|
||||
Artistic Artistique
|
||||
Film Grain… Grain photographique…
|
||||
Highlight Area Zone des tons clairs
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) le fichier compressé dépasse une fois décompressé ce que VectorCraft lit ({_1} Mo de document, {_2} Mo par image)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off le document est trop volumineux pour être enregistré compressé ({0}) : enregistrez-le avec Utiliser la compression désactivé
|
||||
@msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue)
|
||||
Concentric Dividers: Divisions concentriques :
|
||||
Radial Dividers: Divisions radiales :
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2774,6 +2774,7 @@
|
||||
Require a password to open the document Jelszó kérése a dokumentum megnyitásához
|
||||
Reselect Újrakijelölés
|
||||
Reset Visszaállít
|
||||
Reset Ruler Origin Vonalzók origójának visszaállítása
|
||||
Reset all Összes visszaállítása
|
||||
Reset all Revolve settings to their defaults. A tengely körüli forgatás minden beállítását visszaállítja az alapértékre.
|
||||
Reset Automation Automation visszaállítása
|
||||
@@ -2888,6 +2889,7 @@
|
||||
Row Size: Sorméret:
|
||||
Rows: Sorok:
|
||||
Rule: Szabály:
|
||||
Ruler Origin Vonalzók origója
|
||||
Rulers Vonalzók
|
||||
Rulers & Grids Vonalzók és rácsok
|
||||
Run Length Futáshossz (RLE)
|
||||
@@ -4590,3 +4592,5 @@ theme Appearance Kinézet
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) a tömörített fájl kicsomagolva nagyobb lenne, mint amennyit a VectorCraft beolvas ({_1} MB dokumentum, képenként {_2} MB)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off a dokumentum túl nagy a tömörített mentéshez ({0}): mentsd a Tömörítés használata beállítás kikapcsolásával
|
||||
@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát)
|
||||
Concentric Dividers: Koncentrikus osztók:
|
||||
Radial Dividers: Sugárirányú osztók:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4186,9 +4186,13 @@ lighting Light Luce
|
||||
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
|
||||
Linux Display Server Server di visualizzazione Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origine dei righelli
|
||||
Reset Ruler Origin Ripristina origine dei righelli
|
||||
Artistic Artistico
|
||||
Film Grain… Grana della pellicola…
|
||||
Highlight Area Area luci
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) il file compresso, una volta decompresso, supera ciò che VectorCraft legge ({_1} MB di documento, {_2} MB per immagine)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off il documento è troppo grande per essere salvato compresso ({0}): salvalo con Usa compressione disattivato
|
||||
@msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore)
|
||||
Concentric Dividers: Divisori concentrici:
|
||||
Radial Dividers: Divisori radiali:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4180,9 +4180,13 @@ lighting Light ライト
|
||||
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
|
||||
Linux Display Server Linux ディスプレイサーバー
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin 定規の原点
|
||||
Reset Ruler Origin 定規の原点をリセット
|
||||
Artistic アーティスティック
|
||||
Film Grain… フィルム粒子…
|
||||
Highlight Area ハイライト領域
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 圧縮ファイルを展開すると VectorCraft が読み込める量を超えます (ドキュメント {_1} MB、画像 1 枚あたり {_2} MB)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off ドキュメントが大きすぎて圧縮保存できません ({0}): 「圧縮を使用」をオフにして保存してください
|
||||
@msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください)
|
||||
Concentric Dividers: 同心円の分割線:
|
||||
Radial Dividers: 放射状の分割線:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
File diff suppressed because it is too large.
Load diff
@@ -82,11 +82,15 @@ fn plural_east_slavic(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 13] = [
|
||||
pub static LANGUAGES: [LangInfo; 14] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Korean: the whole interface and the status and error messages, keeping the same names in
|
||||
// English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an
|
||||
// installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here.
|
||||
LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Czech: the whole interface (every menu string and `tl!` literal) and the status and error
|
||||
// messages, with one/few/other plurals, keeping the same names in English as Spanish.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() },
|
||||
|
||||
@@ -3668,6 +3668,8 @@ axis Both Ambos
|
||||
@msg invalid native panel set conjunto de painéis nativos inválido
|
||||
@msg missing panel layout layout de painéis ausente
|
||||
@msg unsupported native docking operation operação de encaixe nativa não suportada
|
||||
Ruler Origin Origem das réguas
|
||||
Reset Ruler Origin Redefinir origem das réguas
|
||||
Artistic Artístico
|
||||
Film Grain… Granulação de filme…
|
||||
Grain Grão
|
||||
@@ -3675,3 +3677,5 @@ axis Both Ambos
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) o arquivo compactado se expande para mais do que o VectorCraft lê ({_1} MB de documento, {_2} MB por imagem)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off o documento é grande demais para ser salvo compactado ({0}): salve-o com Usar Compressão desativado
|
||||
@msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro)
|
||||
Concentric Dividers: Divisórias concêntricas:
|
||||
Radial Dividers: Divisórias radiais:
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4182,9 +4182,13 @@ lighting Light Свет
|
||||
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
|
||||
Linux Display Server Дисплейный сервер Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Начало координат линеек
|
||||
Reset Ruler Origin Сбросить начало координат линеек
|
||||
Artistic Художественные
|
||||
Film Grain… Зернистость плёнки…
|
||||
Highlight Area Область светов
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) сжатый файл распаковывается в объём больше, чем читает VectorCraft ({_1} МБ документа, {_2} МБ на изображение)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off документ слишком велик для сохранения со сжатием ({0}): сохраните его с выключенным параметром «Использовать сжатие»
|
||||
@msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке)
|
||||
Concentric Dividers: Концентрические разделители:
|
||||
Radial Dividers: Радиальные разделители:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -29,6 +29,9 @@ fn tags_map_to_languages() {
|
||||
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(hu()), "{tag}");
|
||||
}
|
||||
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ko()), "{tag}");
|
||||
}
|
||||
// Traditional Chinese: by region, by script, and with a region after the script.
|
||||
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
|
||||
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
|
||||
@@ -251,7 +254,7 @@ fn language_command_sets_the_preference() {
|
||||
|
||||
/// Chinese and Japanese characters (CJK punctuation, kana, ideographs, full-width forms).
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32, 0x3000..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
matches!(c as u32, 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
}
|
||||
|
||||
/// The UI fonts draw every character the catalogs use, in every family the UI draws text with
|
||||
@@ -446,7 +449,7 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 10] = ["cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "uk"];
|
||||
const KEEPS_MENU_NAMES: [&str; 11] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pt-br", "ru", "uk"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
@@ -631,6 +634,34 @@ fn hungarian_reads_as_hungarian() {
|
||||
assert_eq!(message(hu(), nested), "a parancs jelenleg nem érhető el: `object.group`: nincs kijelölés");
|
||||
}
|
||||
|
||||
fn ko() -> Lang {
|
||||
Lang::from_code("ko").expect("ko registered")
|
||||
}
|
||||
|
||||
/// Korean uses the vector-illustration vocabulary its users know, has no plural forms, and
|
||||
/// translates the reason inside a message too.
|
||||
#[test]
|
||||
fn korean_reads_as_korean() {
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "아트보드 도구"),
|
||||
("Swatches", "견본"),
|
||||
("Pathfinder", "패스파인더"),
|
||||
("Stroke", "선"),
|
||||
("Fill", "칠"),
|
||||
("Direct Selection Tool", "직접 선택 도구"),
|
||||
("Save As…", "다른 이름으로 저장…"),
|
||||
("Undo", "실행 취소"),
|
||||
("Close", "닫기"),
|
||||
] {
|
||||
assert_eq!(tr(ko(), en), want);
|
||||
}
|
||||
for n in [0, 1, 3, 21] {
|
||||
assert_eq!(trn(ko(), n, "{n} Layer", "{n} Layers"), format!("레이어 {n}개"));
|
||||
}
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다");
|
||||
}
|
||||
|
||||
fn uk() -> Lang {
|
||||
Lang::from_code("uk").expect("uk registered")
|
||||
}
|
||||
@@ -1057,7 +1088,7 @@ fn select_key_param_error_is_translated() {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ru", "uk"];
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "ru", "uk"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
|
||||
@@ -4166,9 +4166,13 @@ lighting Light Світло
|
||||
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
|
||||
Linux Display Server Дисплейний сервер Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Початок координат лінійок
|
||||
Reset Ruler Origin Скинути початок координат лінійок
|
||||
Artistic Художні
|
||||
Film Grain… Зернистість плівки…
|
||||
Highlight Area Область світлих тонів
|
||||
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) стислий файл розпаковується в обсяг більший, ніж читає VectorCraft ({_1} МБ документа, {_2} МБ на зображення)
|
||||
@msg the document is too large to save compressed ({0}): save it with Use Compression off документ завеликий для збереження зі стиском ({0}): збережіть його з вимкненим параметром «Використовувати стиск»
|
||||
@msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку)
|
||||
Concentric Dividers: Концентричні розділювачі:
|
||||
Radial Dividers: Радіальні розділювачі:
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3781,6 +3781,10 @@ lighting Light 光源
|
||||
Move Guide to Layer 将参考线移至图层
|
||||
Linux Display Server Linux 显示服务器
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin 标尺原点
|
||||
Reset Ruler Origin 重置标尺原点
|
||||
Artistic 艺术效果
|
||||
Film Grain… 胶片颗粒…
|
||||
Highlight Area 高光区域
|
||||
Concentric Dividers: 同心分隔线:
|
||||
Radial Dividers: 径向分隔线:
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3562,6 +3562,10 @@ lighting Light 光源
|
||||
Move Guide to Layer 將參考線移至圖層
|
||||
Linux Display Server Linux 顯示伺服器
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin 尺標原點
|
||||
Reset Ruler Origin 重設尺標原點
|
||||
Artistic 藝術效果
|
||||
Film Grain… 底片顆粒…
|
||||
Highlight Area 亮部範圍
|
||||
Concentric Dividers: 同心分隔線:
|
||||
Radial Dividers: 徑向分隔線:
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -122,6 +122,8 @@ mod tests_recovery;
|
||||
#[cfg(test)]
|
||||
mod tests_removeanchors;
|
||||
#[cfg(test)]
|
||||
mod tests_ruler_origin;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_saveext;
|
||||
@@ -310,6 +312,9 @@ pub struct CacheKey {
|
||||
pub smooth_images: bool,
|
||||
/// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]).
|
||||
pub isolated: Option<u64>,
|
||||
/// Appearance of Black › On Screen › Display All Blacks as Rich Black
|
||||
/// ([`vectorcraft_render::RenderOptions::rich_black`]).
|
||||
pub rich_black: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -548,6 +553,17 @@ impl VectorcraftApp {
|
||||
pub fn view(&self) -> Option<&View> {
|
||||
self.session.active_index().and_then(|i| self.views.get(i))
|
||||
}
|
||||
|
||||
/// The zero point shown by rulers and position fields; command coordinates stay absolute.
|
||||
pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point {
|
||||
let origin = self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin);
|
||||
// An untrusted file cannot make the ruler's tick loop or position fields overflow.
|
||||
if origin.is_finite() && origin.x.abs() <= vectorcraft_engine::MAX_COORD && origin.y.abs() <= vectorcraft_engine::MAX_COORD {
|
||||
origin
|
||||
} else {
|
||||
vectorcraft_geom::Point::ZERO
|
||||
}
|
||||
}
|
||||
pub fn view_mut(&mut self) -> Option<&mut View> {
|
||||
self.sync_views();
|
||||
let i = self.session.active_index()?;
|
||||
|
||||
@@ -3217,6 +3217,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
app.open_link(id);
|
||||
return;
|
||||
}
|
||||
// Object → Align aligns to what the align buttons align to, unless the call names `to`. With
|
||||
// anchors selected (Direct Selection) it sends its params unchanged, and the points align to
|
||||
// their own bounds.
|
||||
let follows_align_to = id == "object.align"
|
||||
&& p.as_object().is_some_and(|o| !o.contains_key("to"))
|
||||
&& app.session.active().is_some_and(|st| st.selection.anchors.is_empty());
|
||||
let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p };
|
||||
if let Err(e) = app.run(id, p) {
|
||||
app.status(e);
|
||||
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
//! aligning to a key object) and Align To (selection / key object / artboard).
|
||||
|
||||
use egui::Ui;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
/// What Align aligns to. The Align To choice kept with the UI state ([`crate::UiState::align_to`])
|
||||
/// is Selection or Artboard: Align to Key Object stores no choice.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AlignTo {
|
||||
#[default]
|
||||
Selection,
|
||||
@@ -26,14 +30,34 @@ impl AlignTo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde reader for the saved Align To choice ([`crate::UiState::align_to`]): `"artboard"` is
|
||||
/// Artboard, and any other value, `"key"` included, is Selection.
|
||||
pub fn deserialize_align_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result<AlignTo, D::Error> {
|
||||
let v = <Value as serde::Deserialize>::deserialize(d)?;
|
||||
Ok(if v == "artboard" { AlignTo::Artboard } else { AlignTo::Selection })
|
||||
}
|
||||
|
||||
/// What Align aligns to with `n_selected` objects selected: the artboard for a single object, else
|
||||
/// `to`.
|
||||
fn aligns_to(to: AlignTo, n_selected: usize) -> AlignTo {
|
||||
if n_selected == 1 { AlignTo::Artboard } else { to }
|
||||
}
|
||||
|
||||
/// `object.align` params: a single object aligns to the artboard (Illustrator does the same).
|
||||
pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value {
|
||||
let mut p = base;
|
||||
let to = if n_selected == 1 { AlignTo::Artboard } else { to };
|
||||
p["to"] = json!(to.param());
|
||||
p["to"] = json!(aligns_to(to, n_selected).param());
|
||||
p
|
||||
}
|
||||
|
||||
/// `object.align` params `base` with the `to` that the align buttons of the Align panel, the Control
|
||||
/// bar and the Properties panel send: the artboard for a single selected object, else the key object
|
||||
/// while there is one, else the Align To choice. Object → Align sends it too while every selected
|
||||
/// object is selected whole ([`crate::menus::invoke`]).
|
||||
pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value {
|
||||
align_params(base, align_to(app), selection_len(app))
|
||||
}
|
||||
|
||||
pub const ALIGN: [(&str, &str, &str, &str); 6] = [
|
||||
("dc-al-left", "Horizontal Align Left", "horizontal", "left"),
|
||||
("dc-al-hcenter", "Horizontal Align Center", "horizontal", "center"),
|
||||
@@ -56,8 +80,6 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [
|
||||
/// They align to what the Align panel's Align To says (the key object while there is one), as
|
||||
/// its own buttons do (#789).
|
||||
pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
let n = selection_len(app);
|
||||
let to = align_to(app, ui.ctx());
|
||||
for (i, (icon, tip, axis, v)) in [
|
||||
("align-start-vertical", "Horizontal Align Left", "horizontal", "left"),
|
||||
("align-center-vertical", "Horizontal Align Center", "horizontal", "center"),
|
||||
@@ -70,10 +92,10 @@ pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
.enumerate()
|
||||
{
|
||||
if i == 3 && widgets::icon_button(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, size).clicked() {
|
||||
app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok();
|
||||
app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, icon, tl!(tip), false, size).clicked() {
|
||||
app.run("object.align", align_params(json!({axis: v}), to, n)).ok();
|
||||
app.run("object.align", with_align_to(app, json!({axis: v}))).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,13 +113,13 @@ fn topmost(app: &VectorcraftApp) -> Option<vectorcraft_doc::NodeId> {
|
||||
/// What Align aligns to: the key object while there is one (a click on an object of the
|
||||
/// selection makes it the key, and Align to Key Object makes the topmost one the key), else the
|
||||
/// stored Align To choice, Selection or Artboard.
|
||||
fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo {
|
||||
if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") }
|
||||
fn align_to(app: &VectorcraftApp) -> AlignTo {
|
||||
if has_key(app) { AlignTo::Key } else { app.ui.align_to }
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let n = selection_len(app);
|
||||
let to = align_to(app, ui.ctx());
|
||||
let to = align_to(app);
|
||||
widgets::subheader(ui, tl!("Align Objects:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 5.0;
|
||||
@@ -105,12 +127,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if i == 3 {
|
||||
ui.add_space(6.0);
|
||||
if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, n >= 1, 32.0).clicked() {
|
||||
app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok();
|
||||
app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 1, 32.0).clicked() {
|
||||
app.run("object.align", align_params(json!({*axis: v}), to, n)).ok();
|
||||
app.run("object.align", with_align_to(app, json!({*axis: v}))).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -155,6 +177,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
widgets::subheader(ui, tl!("Align To:"));
|
||||
// With one object selected the buttons align it to the artboard, and Align To shows Artboard.
|
||||
let shown = aligns_to(to, n);
|
||||
ui.horizontal(|ui| {
|
||||
for (v, icon, tip) in [
|
||||
(AlignTo::Selection, "dc-alignto-selection", tl!("Align to Selection")),
|
||||
@@ -163,7 +187,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
] {
|
||||
// A key object is one of two or more selected objects.
|
||||
let on = v != AlignTo::Key || n >= 2;
|
||||
if widgets::icon_button_enabled(ui, icon, tl!(tip), to == v, on, 28.0).clicked() {
|
||||
if widgets::icon_button_enabled(ui, icon, tl!(tip), shown == v, on, 28.0).clicked() {
|
||||
if v == AlignTo::Key {
|
||||
// The topmost selected object becomes the key object, unless
|
||||
// there is one already. The Align To choice stays as it was, and
|
||||
@@ -174,7 +198,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("select.key", json!({"id": id.0})).ok();
|
||||
}
|
||||
} else {
|
||||
set_pstate(ui.ctx(), "align-to", v);
|
||||
app.ui.align_to = v;
|
||||
// Aligning to the selection or the artboard lets go of the key object.
|
||||
if has_key(app) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
@@ -197,7 +221,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::transform::set_pref(app, "usePreviewBounds", !pb);
|
||||
}
|
||||
// Cancel Key Object clears the key, and Align To shows its stored choice again.
|
||||
if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) {
|
||||
if menu_item(ui, tl!("Cancel Key Object"), align_to(app) == AlignTo::Key, false) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
}
|
||||
menu_item(ui, tl!("Align to Glyph Bounds"), false, false);
|
||||
@@ -252,7 +276,7 @@ mod tests {
|
||||
frame(&mut app, vec![press(false)]);
|
||||
assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge");
|
||||
// The Align panel's Horizontal Align Left.
|
||||
let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app));
|
||||
let p = with_align_to(&app, json!({"horizontal": "left", "bounds": "geometric"}));
|
||||
app.run("object.align", p).unwrap();
|
||||
assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge");
|
||||
}
|
||||
@@ -274,12 +298,11 @@ mod tests {
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
|
||||
assert_eq!(align_to(&app), AlignTo::Selection);
|
||||
app.run("select.key", json!({"id": b})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
|
||||
assert_eq!(align_to(&app), AlignTo::Key);
|
||||
app.run("select.key", json!({})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again");
|
||||
assert_eq!(align_to(&app), AlignTo::Selection, "no key: the choice again");
|
||||
}
|
||||
|
||||
/// Two rectangles, the back one at x 10 and the front one at x 80, selected front first →
|
||||
@@ -326,7 +349,7 @@ mod tests {
|
||||
app.run("select.set", json!({"ids": [back, front]})).unwrap();
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected last");
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
|
||||
assert_eq!(align_to(&app), AlignTo::Key);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!((left_edge(&app, &back), left_edge(&app, &front)), (80.0, 80.0), "to the key's left edge: {}", app.ui.status);
|
||||
app.run("select.key", json!({"id": back})).unwrap();
|
||||
@@ -344,20 +367,40 @@ mod tests {
|
||||
let ([_, key, artboard], _) = panel_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, artboard.center(), show);
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
|
||||
assert_eq!(align_to(&app), AlignTo::Key);
|
||||
let cancel = text_rect(&frame_events(&ctx, &mut app, vec![], menu), " Cancel Key Object");
|
||||
click(&ctx, &mut app, cancel.center(), menu);
|
||||
assert!(!has_key(&app));
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "Cancel Key Object");
|
||||
assert_eq!(align_to(&app), AlignTo::Artboard, "Cancel Key Object");
|
||||
// The Selection tool's click on the key object runs `select.key {}`.
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert!(has_key(&app));
|
||||
app.run("select.key", json!({})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "a click on the key");
|
||||
assert_eq!(align_to(&app), AlignTo::Artboard, "a click on the key");
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert!(has_key(&app));
|
||||
app.run("select.set", json!({"ids": [back, front]})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "another selection");
|
||||
assert_eq!(align_to(&app), AlignTo::Artboard, "another selection");
|
||||
}
|
||||
|
||||
/// The Align To choice is kept with the UI state, which the app saves with its preferences and
|
||||
/// `ui.inspect` reports. A saved value other than "artboard" reads as Selection, and the rest of
|
||||
/// the state still loads.
|
||||
#[test]
|
||||
fn the_align_to_choice_is_kept_with_the_ui_state() {
|
||||
let (mut app, _, _) = back_and_front();
|
||||
let ctx = egui::Context::default();
|
||||
let ([_, _, artboard], _) = panel_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, artboard.center(), show);
|
||||
assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["align_to"], "artboard");
|
||||
let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(back.align_to, AlignTo::Artboard);
|
||||
for v in [json!("key"), json!("Artboard"), json!(3), Value::Null] {
|
||||
let ui: crate::UiState = serde_json::from_value(json!({"align_to": v, "place_link": false})).unwrap();
|
||||
assert_eq!((ui.align_to, ui.place_link), (AlignTo::Selection, false), "{v}");
|
||||
}
|
||||
let old: crate::UiState = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(old.align_to, AlignTo::Selection, "preferences saved without it");
|
||||
}
|
||||
|
||||
/// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the
|
||||
@@ -372,11 +415,36 @@ mod tests {
|
||||
assert!(!clickable, "disabled");
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(app.session.active().unwrap().selection.key, None);
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
|
||||
assert_eq!(align_to(&app), AlignTo::Selection);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge");
|
||||
}
|
||||
|
||||
/// One frame of the Align panel → the rects of the Align To buttons drawn as chosen.
|
||||
fn chosen_align_to(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::Rect> {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let fill = crate::theme::Tokens::get(ctx).tool_active;
|
||||
let chosen = |s: &egui::Shape| match s {
|
||||
egui::Shape::Rect(r) if r.fill == fill && r.rect.size() == egui::vec2(28.0, 28.0) => Some(r.rect),
|
||||
_ => None,
|
||||
};
|
||||
out.shapes.iter().filter_map(|c| chosen(&c.shape)).collect()
|
||||
}
|
||||
|
||||
/// With one object selected the align buttons align it to the artboard, and Align To shows
|
||||
/// Artboard; with two, it shows the stored choice.
|
||||
#[test]
|
||||
fn align_to_shows_artboard_while_one_object_is_selected() {
|
||||
let (mut app, _, front) = back_and_front();
|
||||
let ctx = egui::Context::default();
|
||||
let ([selection, _, artboard], _) = panel_buttons(&mut app, &ctx);
|
||||
assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "two objects");
|
||||
app.run("select.set", json!({"ids": [front]})).unwrap();
|
||||
assert_eq!(chosen_align_to(&mut app, &ctx), [artboard], "one object");
|
||||
assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice");
|
||||
}
|
||||
|
||||
/// #789: the Control bar's and the Properties panel's align buttons follow Align To too.
|
||||
#[test]
|
||||
fn the_other_align_buttons_align_to_the_align_to_choice() {
|
||||
@@ -386,7 +454,7 @@ mod tests {
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "align-to", AlignTo::Artboard);
|
||||
app.ui.align_to = AlignTo::Artboard;
|
||||
// One frame finds the first button (Horizontal Align Right is third); the next clicks it.
|
||||
let at = std::cell::Cell::new(egui::Pos2::ZERO);
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
@@ -407,4 +475,55 @@ mod tests {
|
||||
let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1;
|
||||
assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge");
|
||||
}
|
||||
|
||||
/// Runs the Object → Align item `label` as a click on it does.
|
||||
fn object_align(app: &mut VectorcraftApp, label: &str) {
|
||||
let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item");
|
||||
let (id, p) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
|
||||
/// Object → Align aligns as the align buttons do: it aligns one object to the active artboard
|
||||
/// whatever Align To says, and with Align to Artboard chosen it aligns two objects to the active
|
||||
/// artboard too.
|
||||
#[test]
|
||||
fn object_align_follows_align_to() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
let bounds = |app: &VectorcraftApp, id: &Value| {
|
||||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap()
|
||||
};
|
||||
app.run("select.set", json!({"ids": [b]})).unwrap();
|
||||
object_align(&mut app, "Horizontal Align Left");
|
||||
assert_eq!(bounds(&app, &b).x0, 0.0, "one object, Align to Selection: the artboard's left edge");
|
||||
app.ui.align_to = AlignTo::Artboard;
|
||||
app.view_mut().unwrap().artboard = 1;
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
object_align(&mut app, "Horizontal Align Right");
|
||||
assert_eq!((bounds(&app, &a).x1, bounds(&app, &b).x1), (boards[1].x1, boards[1].x1), "the active artboard's right edge");
|
||||
}
|
||||
|
||||
/// With anchor points selected, Object → Align sends its params unchanged: two anchor points of
|
||||
/// one path align to their own bounds with Align to Selection and with Align to Artboard chosen.
|
||||
#[test]
|
||||
fn object_align_aligns_anchor_points_to_each_other() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap();
|
||||
let anchors = |app: &VectorcraftApp| -> Vec<(f64, f64)> {
|
||||
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
|
||||
node.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect()
|
||||
};
|
||||
for to in [AlignTo::Selection, AlignTo::Artboard] {
|
||||
app.ui.align_to = to;
|
||||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
|
||||
object_align(&mut app, "Horizontal Align Left");
|
||||
assert_eq!(anchors(&app), [(100.0, 40.0), (100.0, 60.0), (150.0, 160.0)], "{to:?}: {}", app.ui.status);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(anchors(&app)[1], (200.0, 60.0), "undone");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -199,6 +199,7 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
/// Exit back to the Selection tool (#530).
|
||||
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let i = super::artboards::selected(app, n);
|
||||
let scale_art = super::artboards::scale_art(app);
|
||||
@@ -214,8 +215,13 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
{
|
||||
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
|
||||
dim_label(ui, label);
|
||||
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
|
||||
app.run("artboard.setProps", json!({"index": i, key: v, "scaleArt": scale_art, "moveArt": move_art})).ok();
|
||||
let offset = match key {
|
||||
"x" => origin.x,
|
||||
"y" => origin.y,
|
||||
_ => 0.0,
|
||||
};
|
||||
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v - offset), units, fw) {
|
||||
app.run("artboard.setProps", json!({"index": i, key: v + offset, "scaleArt": scale_art, "moveArt": move_art})).ok();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -393,6 +399,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
// The bounding box, rotated with rotated objects (as in the Transform panel).
|
||||
let Some(bx) = app.selection_box() else {
|
||||
dim_label(ui, tl!("No Selection"));
|
||||
@@ -411,8 +418,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let link = app.session.prefs.constrain_proportions;
|
||||
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
|
||||
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
|
||||
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x - origin.x), units, fw) {
|
||||
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok();
|
||||
}
|
||||
dim_label(ui, "W:");
|
||||
if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) {
|
||||
@@ -420,8 +427,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.end_row();
|
||||
dim_label(ui, "Y:");
|
||||
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y), units, fw) {
|
||||
app.run("object.setBounds", json!({"y": v, "reference": refi})).ok();
|
||||
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y - origin.y), units, fw) {
|
||||
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok();
|
||||
}
|
||||
dim_label(ui, "H:");
|
||||
if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) {
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn artboard_rect(r: Rect, refi: usize, x: Option<f64>, y: Option<f64>, w: Op
|
||||
/// The Artboard tool's view of the panel: the active artboard's position and size.
|
||||
fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) {
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
||||
let link = app.session.prefs.constrain_proportions;
|
||||
let rp = vectorcraft_geom::reference_point(r, refi);
|
||||
@@ -71,8 +72,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("xfp-ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
widgets::dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x), units, 80.0) {
|
||||
set(app, Some(v), None, None, None);
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x - origin.x), units, 80.0) {
|
||||
set(app, Some(v + origin.x), None, None, None);
|
||||
}
|
||||
widgets::dim_label(ui, "W:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) {
|
||||
@@ -80,8 +81,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
|
||||
}
|
||||
ui.end_row();
|
||||
widgets::dim_label(ui, "Y:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y), units, 80.0) {
|
||||
set(app, None, Some(v), None, None);
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y - origin.y), units, 80.0) {
|
||||
set(app, None, Some(v + origin.y), None, None);
|
||||
}
|
||||
widgets::dim_label(ui, "H:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) {
|
||||
@@ -104,6 +105,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
let units = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
// The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference
|
||||
// point on the page.
|
||||
let bx = app.selection_box();
|
||||
@@ -120,8 +122,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
widgets::dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x), units, 80.0) {
|
||||
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x - origin.x), units, 80.0) {
|
||||
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok();
|
||||
}
|
||||
widgets::dim_label(ui, "W:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) {
|
||||
@@ -129,8 +131,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.end_row();
|
||||
widgets::dim_label(ui, "Y:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y), units, 80.0) {
|
||||
app.run("object.setBounds", json!({"y": v, "reference": refi})).ok();
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y - origin.y), units, 80.0) {
|
||||
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok();
|
||||
}
|
||||
widgets::dim_label(ui, "H:");
|
||||
if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) {
|
||||
|
||||
@@ -28,12 +28,20 @@ pub struct PrintJob<'a> {
|
||||
pub pdf: &'a [u8],
|
||||
}
|
||||
|
||||
/// Submission callable from the desktop background worker.
|
||||
pub type SharedPrintFn = std::sync::Arc<dyn Fn(&PrintJob<'_>) -> Result<String, String> + Send + Sync>;
|
||||
|
||||
/// The platform's printing ([`crate::Services::print`]).
|
||||
pub trait PrintService {
|
||||
/// The printers the system knows (none: only the default one, or the browser's dialog).
|
||||
fn printers(&mut self) -> Vec<Printer>;
|
||||
/// Send `job` to its printer → what happened, for the status bar.
|
||||
fn print(&mut self, job: &PrintJob) -> Result<String, String>;
|
||||
/// Desktop services can submit on the existing background worker; browser services stay
|
||||
/// on the UI thread to invoke their print dialog.
|
||||
fn shared_print(&self) -> Option<SharedPrintFn> {
|
||||
None
|
||||
}
|
||||
/// Whether Setup… can open the system's printer settings.
|
||||
fn has_setup(&self) -> bool {
|
||||
false
|
||||
@@ -88,6 +96,35 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
let printer = s("printer").filter(|n| !n.is_empty());
|
||||
let service = app.services.print.as_mut().ok_or("no print service")?;
|
||||
if let Some(submit) = service.shared_print() {
|
||||
let pending_printer = printer.clone();
|
||||
let mut pending = crate::background::run(
|
||||
app,
|
||||
true,
|
||||
format!("Printing {title}"),
|
||||
None,
|
||||
move |_| {
|
||||
submit(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf }).map(|note| json!({ "note": note, "printer": printer }))
|
||||
},
|
||||
move |app, result| {
|
||||
let receipt = result?;
|
||||
app.status(receipt.get("note").and_then(Value::as_str).unwrap_or_default());
|
||||
Ok(json!({ "printer": receipt["printer"], "printed": true }))
|
||||
},
|
||||
)?;
|
||||
if pending.get("background").and_then(Value::as_bool) == Some(true) {
|
||||
// Acceptance by the worker is not acceptance by the spooler.
|
||||
pending["printer"] = json!(pending_printer);
|
||||
pending["printed"] = json!(false);
|
||||
}
|
||||
// Preserve the engine's pages, byte count, format and warnings in automation replies.
|
||||
if let Some(fields) = pending.as_object() {
|
||||
for (key, value) in fields {
|
||||
v[key] = value.clone();
|
||||
}
|
||||
}
|
||||
return Ok(v);
|
||||
}
|
||||
let done = service.print(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf })?;
|
||||
app.status(done);
|
||||
v["printer"] = json!(printer);
|
||||
|
||||
@@ -360,6 +360,10 @@ pub struct UiState {
|
||||
/// swatches) or "" (none) (`ui.colorGuideLimit`).
|
||||
#[serde(default)]
|
||||
pub color_guide_limit: String,
|
||||
/// Align panel: the Align To choice, Selection or Artboard, which Object → Align and the align
|
||||
/// buttons follow ([`crate::panels::align::with_align_to`]).
|
||||
#[serde(default, deserialize_with = "crate::panels::align::deserialize_align_to")]
|
||||
pub align_to: crate::panels::align::AlignTo,
|
||||
/// The SVG Options chosen last (`svg` object of the export/save commands; null: never used).
|
||||
#[serde(default)]
|
||||
pub svg_options: Value,
|
||||
@@ -639,6 +643,7 @@ impl Default for UiState {
|
||||
library_panel: None,
|
||||
flattener_preview: Default::default(),
|
||||
color_guide_limit: String::new(),
|
||||
align_to: Default::default(),
|
||||
svg_options: Value::Null,
|
||||
place_link: true,
|
||||
dialog_file: None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Numeric fields: math typed before the unit suffix, and the whole text (unit included) selected
|
||||
//! on focus or double-click so typing replaces it.
|
||||
//! on keyboard focus, a released focus click or double-click so typing replaces it; a drag
|
||||
//! keeps the pressed character as its selection anchor.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
@@ -49,6 +50,12 @@ impl Field {
|
||||
std::iter::once(Event::PointerMoved(at)).chain((0..count).flat_map(|_| [button(true), button(false)])).collect()
|
||||
}
|
||||
|
||||
fn text_point(&self, text: &str, index: usize, margin: f32) -> Pos2 {
|
||||
let galley = self.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), egui::FontId::proportional(12.5), egui::Color32::BLACK));
|
||||
let x = galley.pos_from_cursor(egui::text::CCursor::new(index)).left();
|
||||
Pos2::new(self.rect.get().left() + margin + x + 0.1, self.rect.get().center().y)
|
||||
}
|
||||
|
||||
/// The selected character range of the field's text.
|
||||
fn selection(&self) -> Option<(usize, usize)> {
|
||||
let st = egui::TextEdit::load_state(&self.ctx, self.id.get())?;
|
||||
@@ -292,3 +299,76 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn numeric_fixture(ui: &mut egui::Ui, mode: usize) -> Option<f64> {
|
||||
match mode {
|
||||
0 => widgets::num_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0),
|
||||
1 => widgets::num_field(ui, "f", Some(123456.0), Unit::Points, 100.0),
|
||||
2 => widgets::plain_field(ui, "f", 123456.0, "°", 0, 100.0),
|
||||
_ => widgets::opt_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_text(mode: usize) -> &'static str {
|
||||
["123456 px", "123456 pt", "123456°", "123456"][mode]
|
||||
}
|
||||
|
||||
fn pointer_button(at: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_drag_keeps_its_press_position_with_or_without_prior_focus() {
|
||||
for mode in 0..4 {
|
||||
for focused in [false, true] {
|
||||
for (start, end) in [(1, 4), (4, 1)] {
|
||||
let mut f = Field::new();
|
||||
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
|
||||
f.frame(vec![], &draw);
|
||||
if focused {
|
||||
f.frame(f.click(1), &draw);
|
||||
f.frame(vec![], &draw);
|
||||
f.time += 1.0;
|
||||
}
|
||||
let margin = if mode == 3 { 5.0 } else { 7.0 };
|
||||
let at = f.text_point(field_text(mode), start, margin);
|
||||
let to = f.text_point(field_text(mode), end, margin);
|
||||
f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
|
||||
assert_eq!(f.selection(), Some((start, start)), "mode {mode}, prior focus {focused}");
|
||||
f.frame(vec![], &draw); // Holding still must leave the press position alone.
|
||||
f.frame(vec![Event::PointerMoved(to)], &draw);
|
||||
f.frame(vec![pointer_button(to, false)], &draw);
|
||||
assert_eq!(f.selection(), Some((1, 4)), "mode {mode}, {start}->{end}, prior focus {focused}");
|
||||
f.frame(vec![Event::Text("7".into())], &draw);
|
||||
assert_eq!(f.frame(vec![enter()], &draw), Some(1756.0), "replace only the dragged text");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_numeric_click_selects_all_on_release_without_moving_its_press_caret() {
|
||||
let mut f = Field::new();
|
||||
f.num(vec![]);
|
||||
let at = f.rect.get().center();
|
||||
f.num(vec![Event::PointerMoved(at), pointer_button(at, true)]);
|
||||
assert!(f.selection().is_some_and(|(a, b)| a == b), "a hold can still start a text drag");
|
||||
f.num(vec![]);
|
||||
assert!(f.selection().is_some_and(|(a, b)| a == b));
|
||||
f.num(vec![pointer_button(at, false)]);
|
||||
assert_eq!(f.selection(), Some((0, "100 px".len())));
|
||||
f.num(vec![Event::Text("7".into())]);
|
||||
assert_eq!(f.num(vec![enter()]), Some(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_focus_keeps_selecting_the_entire_numeric_field() {
|
||||
for mode in 0..4 {
|
||||
let mut f = Field::new();
|
||||
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
|
||||
f.frame(vec![], &draw);
|
||||
f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw);
|
||||
f.frame(vec![], &draw);
|
||||
assert_eq!(f.selection(), Some((0, field_text(mode).chars().count())), "mode {mode}");
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,55 @@ fn postscript_is_saved_not_printed() {
|
||||
assert_eq!(*sent.borrow(), 1);
|
||||
assert!(app.run("file.print", json!({"format": "ps2"})).is_err());
|
||||
}
|
||||
|
||||
/// Submission stays off the frame and the eventual queue receipt/refusal reaches the status bar.
|
||||
#[test]
|
||||
fn desktop_print_pending_receipt_and_refusal_are_distinct() {
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
struct AsyncPrinter {
|
||||
release: Arc<Mutex<mpsc::Receiver<()>>>,
|
||||
refused: bool,
|
||||
}
|
||||
impl PrintService for AsyncPrinter {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
Vec::new()
|
||||
}
|
||||
fn print(&mut self, _: &PrintJob) -> Result<String, String> {
|
||||
Err("UI thread must not submit".into())
|
||||
}
|
||||
fn shared_print(&self) -> Option<crate::print::SharedPrintFn> {
|
||||
let release = self.release.clone();
|
||||
let refused = self.refused;
|
||||
Some(Arc::new(move |job| {
|
||||
release.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(job.printer, Some("Office Laser"));
|
||||
assert!(job.pdf.starts_with(b"%PDF-"));
|
||||
if refused { Err("queue refused this job".into()) } else { Ok("Sent to Office Laser".into()) }
|
||||
}))
|
||||
}
|
||||
}
|
||||
for refused in [false, true] {
|
||||
let (release, wait) = mpsc::channel();
|
||||
let services = Services {
|
||||
print: Some(Box::new(AsyncPrinter { release: Arc::new(Mutex::new(wait)), refused })),
|
||||
write_shared: Some(Arc::new(|_, _| Err("printing must not write a document".into()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({ "width": 100, "height": 80 })).unwrap();
|
||||
let pending = app.run("file.print", json!({ "printer": "Office Laser" })).unwrap();
|
||||
assert_eq!(pending["background"], true);
|
||||
assert_eq!(pending["printed"], false);
|
||||
assert_eq!(pending["pages"], 1);
|
||||
assert!(pending["bytes"].as_u64().is_some_and(|bytes| bytes > 0));
|
||||
assert!(!app.ui.status.starts_with("Sent"));
|
||||
release.send(()).unwrap();
|
||||
crate::background::wait_all(&mut app);
|
||||
assert!(app.background.jobs.is_empty());
|
||||
if refused {
|
||||
assert!(app.ui.status.contains("queue refused this job"));
|
||||
} else {
|
||||
assert_eq!(app.ui.status, "Sent to Office Laser");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! #1212: real ruler gestures and position fields share the document's zero point.
|
||||
|
||||
use egui::{Event, Key, PointerButton, Pos2, Rect, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, Unit};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{VectorcraftApp, canvas, chrome, panels};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.control_bar = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0))),
|
||||
time: Some(ctx.input(|i| i.time) + 0.05),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
fn press(at: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn key(key: Key) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn start_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: Pos2) {
|
||||
let at = pos2(8.0, 8.0);
|
||||
frame(app, ctx, vec![Event::PointerMoved(at)], canvas::show);
|
||||
frame(app, ctx, vec![press(at, true)], canvas::show);
|
||||
frame(app, ctx, vec![Event::PointerMoved(to)], canvas::show);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() {
|
||||
let mut app = app();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], canvas::show);
|
||||
let v = app.view_mut().unwrap();
|
||||
v.center = Point::new(220.0, 130.0);
|
||||
v.zoom = 2.3;
|
||||
v.rotation = 35.0;
|
||||
frame(&mut app, &ctx, vec![], canvas::show);
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let goal = Point::new(125.25, 90.5);
|
||||
let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(goal);
|
||||
start_drag(&mut app, &ctx, to);
|
||||
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "preview does not edit the document");
|
||||
frame(&mut app, &ctx, vec![press(to, false)], canvas::show);
|
||||
let st = app.session.doc().unwrap();
|
||||
assert!(st.doc.ruler_origin.distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin);
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag");
|
||||
assert!(st.doc.guides.is_empty());
|
||||
let origin = st.doc.ruler_origin;
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.ruler_origin(), origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_double_click_resets_to_the_active_artboard() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
|
||||
app.run("document.setRulerOrigin", json!({"x": 125, "y": 90})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], canvas::show);
|
||||
app.view_mut().unwrap().artboard = 1;
|
||||
let at = pos2(8.0, 8.0);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
for _ in 0..2 {
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)], canvas::show);
|
||||
frame(&mut app, &ctx, vec![press(at, true)], canvas::show);
|
||||
frame(&mut app, &ctx, vec![press(at, false)], canvas::show);
|
||||
}
|
||||
assert_eq!(app.ruler_origin(), Point::new(700.0, -250.0));
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.ruler_origin(), Point::new(125.0, 90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_cancelled_drags_do_not_modify_either_document() {
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Cancel {
|
||||
Outside,
|
||||
Escape,
|
||||
Focus,
|
||||
Document,
|
||||
}
|
||||
for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] {
|
||||
let mut app = app();
|
||||
app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], canvas::show);
|
||||
let to = app.canvas_rect.unwrap().center();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
start_drag(&mut app, &ctx, to);
|
||||
let release = match cancel {
|
||||
Cancel::Outside => pos2(4.0, 4.0),
|
||||
Cancel::Escape => {
|
||||
frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show);
|
||||
to
|
||||
}
|
||||
Cancel::Focus => {
|
||||
let mut out = ctx.run_ui(egui::RawInput { focused: false, ..Default::default() }, |ui| canvas::show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
to
|
||||
}
|
||||
Cancel::Document => {
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
to
|
||||
}
|
||||
};
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show);
|
||||
if cancel == Cancel::Document {
|
||||
assert_eq!(app.ruler_origin(), Point::ZERO);
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
|
||||
} else {
|
||||
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "{cancel:?}");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{cancel:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_position_fields_show_relative_values_and_edit_absolute_positions() {
|
||||
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show, chrome::control_bar] {
|
||||
for (label, arrow) in [("X:", (1.0, 0.0)), ("Y:", (0.0, 1.0))] {
|
||||
let mut app = app();
|
||||
let id = NodeId(app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], draw);
|
||||
let texts = frame(&mut app, &ctx, vec![], draw);
|
||||
assert!(texts.iter().filter(|(t, _)| t == &Unit::Points.format(20.0)).count() >= 2, "{texts:?}");
|
||||
let label = texts.iter().find(|(t, _)| t == label).unwrap().1;
|
||||
let at = pos2(label.right() + 40.0, label.center().y);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
|
||||
frame(&mut app, &ctx, vec![press(at, false)], draw);
|
||||
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
|
||||
let bounds = app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (100.0 + arrow.0, 50.0 + arrow.1));
|
||||
assert_eq!((bounds.width(), bounds.height()), (80.0, 40.0));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0, 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_origin_artboard_fields_preserve_the_absolute_reference_point() {
|
||||
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show] {
|
||||
for label in ["X:", "Y:"] {
|
||||
let mut app = app();
|
||||
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], draw);
|
||||
let texts = frame(&mut app, &ctx, vec![], draw);
|
||||
let label_rect = texts.iter().find(|(t, _)| t == label).unwrap().1;
|
||||
let at = pos2(label_rect.right() + 40.0, label_rect.center().y);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
|
||||
frame(&mut app, &ctx, vec![press(at, false)], draw);
|
||||
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
|
||||
let ab = app.session.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((ab.x0, ab.y0), if label == "X:" { (1.0, 0.0) } else { (0.0, 1.0) });
|
||||
assert_eq!((ab.width(), ab.height()), (400.0, 300.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -309,13 +309,33 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) {
|
||||
st.store(ui.ctx(), id);
|
||||
}
|
||||
|
||||
/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked
|
||||
/// (egui would select one word): typing a number or an expression replaces it.
|
||||
/// Numeric fields select the whole text on keyboard focus, a completed focus click or a
|
||||
/// double-click. A pointer press keeps its caret so dragging selects from the pressed position.
|
||||
fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
|
||||
if !(resp.gained_focus() || resp.double_clicked()) {
|
||||
let pending = resp.id.with("select-all-on-focus-click");
|
||||
let (pressed, released) =
|
||||
ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary)));
|
||||
if resp.gained_focus() {
|
||||
ui.data_mut(|d| {
|
||||
if pressed {
|
||||
d.insert_temp(pending, true);
|
||||
} else {
|
||||
d.remove_temp::<bool>(pending);
|
||||
}
|
||||
});
|
||||
}
|
||||
let clicked_focus = released && ui.data_mut(|d| d.remove_temp::<bool>(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary);
|
||||
if resp.lost_focus() {
|
||||
ui.data_mut(|d| d.remove_temp::<bool>(pending));
|
||||
}
|
||||
if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus) {
|
||||
return;
|
||||
}
|
||||
if egui::TextEdit::load_state(ui.ctx(), resp.id).is_some() {
|
||||
if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) {
|
||||
// Preserve a text selection if the focus click already produced one.
|
||||
if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) {
|
||||
return;
|
||||
}
|
||||
select_all(ui, resp.id, text);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
|
||||
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
|
||||
@@ -52,6 +52,13 @@ down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/en
|
||||
lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See
|
||||
[file-format-parity.md](file-format-parity.md).
|
||||
|
||||
Desktop printer discovery and PDF submission use `craft-print`, pinned to an immutable
|
||||
craft-libs revision. The engine still owns sheet composition, copies, separations and colour
|
||||
management. Submission runs on the existing background worker; its receipt or refusal
|
||||
reaches the status bar, and Quit waits for pending submissions. Windows 7 retains WMI discovery
|
||||
and the installed viewer's Print/PrintTo verb;
|
||||
its target excludes the shared backend because Windows.Data.Pdf is unavailable there.
|
||||
|
||||
## Agent control
|
||||
|
||||
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
|
||||
@@ -69,5 +76,6 @@ saves copies in the background. See [development.md](development.md#robustness-v
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
|
||||
| 2026-10-10 | minor | Added the `three-d` crate |
|
||||
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user