mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-19 05:34:23 -04:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a466625c89 | ||
|
|
73f921fa0a | ||
|
|
fb18533e12 | ||
|
|
56be31fe9b | ||
|
|
a23d7a827f | ||
|
|
1c80b23745 | ||
|
|
c9a2491ef7 | ||
|
|
2ae992870b | ||
|
|
cec31c5f4a | ||
|
|
3b527ca339 | ||
|
|
4f18f1c860 | ||
|
|
b529f356cc | ||
|
|
048875cc4e | ||
|
|
57ddb71d97 | ||
|
|
49f7f5d7d8 | ||
|
|
aa9c70a7b1 | ||
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
bb14031aa3 | ||
|
|
eec304dde1 | ||
|
|
b934451b6d | ||
|
|
5906f53ca5 | ||
|
|
411f5f7680 | ||
|
|
63cebd392a | ||
|
|
3041b6a672 | ||
|
|
090659b78b | ||
|
|
640c87b537 | ||
|
|
bf2379f401 | ||
|
|
561db27fef | ||
|
|
cc1a496947 | ||
|
|
48ef70e873 | ||
|
|
b58100c2f2 | ||
|
|
37b90eb050 | ||
|
|
7cf8dd4764 | ||
|
|
4547439176 | ||
|
|
797e899c56 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/affinity/fuzz"
|
||||
- "/crates/plugins/example"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
Generated
+1
@@ -5007,6 +5007,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -224,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -238,22 +237,22 @@ 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, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- about half of the raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -372,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+4
-2
@@ -112,8 +112,8 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
@@ -164,6 +165,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 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-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. |
|
||||
| 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) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
|
||||
@@ -79,14 +79,16 @@ USAGE:
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", json!({"path": path, "scale": scale}))
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -274,7 +274,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -801,6 +806,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
|
||||
@@ -82,6 +82,25 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
stamp_from_env(|n| std::env::var(n).ok()).or_else(now_unix)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +167,21 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
|
||||
+124
-12
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if transparent && flat.is_some() {
|
||||
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
|
||||
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
|
||||
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
@@ -481,6 +489,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
@@ -493,6 +505,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -511,13 +526,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
|
||||
}
|
||||
},
|
||||
};
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod tgaread;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"PDF Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -646,7 +647,16 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
extensions: &["tga", "vda", "icb", "vst"],
|
||||
mime: "image/x-tga",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TGA_OPTIONS,
|
||||
},
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -690,9 +704,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
/// them, and the packaging declares no `.epsf`, `.ps` or Targa type (File › Open and Place still
|
||||
/// read them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -733,6 +747,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -1085,14 +1103,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
pub(crate) fn document_path_with(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
resolve(Some(share.as_str()))?
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
/// `\\?\UNC\server\share\…`.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn share_path(document: &str) -> Option<String> {
|
||||
share_of(document, |folder| {
|
||||
may_read(folder).ok()?;
|
||||
std::fs::canonicalize(folder).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
|
||||
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let mut chars = document.chars();
|
||||
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// The drive's root folder keeps its separator (`Z:\`).
|
||||
let split = document.rfind(['\\', '/'])?;
|
||||
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = final_path(folder)?;
|
||||
let target = target.to_str()?;
|
||||
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
|
||||
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(r"\\{unc}\{name}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
|
||||
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
|
||||
//! boxes, a thumbnail) for the Import PDF dialog.
|
||||
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
}
|
||||
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
|
||||
Ok(i) => i,
|
||||
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
|
||||
@@ -183,10 +187,34 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
|
||||
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
|
||||
let (doc, _) = page_document(&bytes, page, &o)?;
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
||||
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
||||
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
||||
/// file's preview when its PostScript can't be read.
|
||||
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
||||
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
||||
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
||||
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
|
||||
if n != 1 {
|
||||
return Err(bad(INFO, format!("thumbnail: no page {n} (the file has 1)")));
|
||||
}
|
||||
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
}
|
||||
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
|
||||
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
|
||||
/// pixels would fit, is refused as too large to draw before it is decoded.
|
||||
#[test]
|
||||
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
|
||||
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
|
||||
let side = 20_000u32;
|
||||
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
|
||||
for at in [18, 30, 102] {
|
||||
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
|
||||
}
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
|
||||
@@ -229,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
|
||||
for pages in [json!("1"), json!(1), json!("1-")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
|
||||
check_sample(&s);
|
||||
}
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
|
||||
//! others are read by the PostScript interpreter or come in as their preview.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_svgedit::{comparable, rich};
|
||||
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
|
||||
/// differencing, in an EPS and in a PostScript `.ai`.
|
||||
#[test]
|
||||
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
|
||||
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
|
||||
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ps = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
|
||||
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
for name in ["predicted.eps", "predicted.ai"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, name, ps.as_bytes()).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut grays = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
|
||||
}
|
||||
});
|
||||
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
|
||||
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
|
||||
/// order mark: "hash " and the hash it held, padded with spaces to its length.
|
||||
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = b"(\xFE\xFF".to_vec();
|
||||
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
|
||||
let mut s = rich();
|
||||
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
|
||||
let r = open(&mut s, name, &utf16_described(&file));
|
||||
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
|
||||
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
|
||||
let r = open(&mut s, name, &utf16_described(&edited));
|
||||
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_payload_name_is_accepted() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
||||
}
|
||||
// An EPS with a binary header too, and the PDF info of either.
|
||||
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
|
||||
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
|
||||
// PostScript, so its PDF info is an error.
|
||||
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
binary.extend_from_slice(&[0; 28]);
|
||||
binary.extend_from_slice(ps);
|
||||
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
}
|
||||
|
||||
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
|
||||
/// with an error that names File › Open and File › Place, and both of those read the file.
|
||||
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
|
||||
/// names File › Open and File › Place, and both of those read the file.
|
||||
#[test]
|
||||
fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
|
||||
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
|
||||
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
|
||||
}
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
|
||||
assert!(!e.contains("can't be opened"), "{e}");
|
||||
for name in ["legacy.ai", "art.eps"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
@@ -186,6 +190,44 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
|
||||
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
|
||||
/// file here opens on that page with `editingData: false`.
|
||||
#[test]
|
||||
fn pdf_info_reads_postscript_as_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
|
||||
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
|
||||
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
|
||||
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
|
||||
for (name, bytes, size) in [
|
||||
("art.eps", &eps[..], (300.0, 200.0)),
|
||||
("legacy.ai", &ai, (101.0, 51.0)),
|
||||
("hires.eps", hires, (100.5, 40.25)),
|
||||
("page.ps", letter, (612.0, 792.0)),
|
||||
("bare.ps", bare, (612.0, 792.0)),
|
||||
] {
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 100, "{name}");
|
||||
let mut v = v;
|
||||
v["thumbnail"] = json!("png");
|
||||
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
|
||||
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no page 2"), "{e}");
|
||||
}
|
||||
|
||||
/// A page showing only text, with an editor's private data when `private`.
|
||||
fn text_page(private: bool, also: &str) -> PdfPage {
|
||||
PdfPage {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
//! ones are refused, and the files open again; Targa files open and place.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
|
||||
/// active document's first layer.
|
||||
fn stored_pixels(s: &Session) -> image::RgbaImage {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let im = match &doc.layers[0].children().unwrap()[0].kind {
|
||||
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
};
|
||||
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
|
||||
/// image descriptor `descriptor`.
|
||||
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([depth, descriptor]);
|
||||
f
|
||||
}
|
||||
|
||||
/// Targa files open and place at their pixel size with the transparency they store, by any of the
|
||||
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
|
||||
#[test]
|
||||
fn targa_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let mut s = session();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
|
||||
// bits are flattened on white) and the black rectangle stays black.
|
||||
for depth in [16, 24, 32] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
|
||||
let px = stored_pixels(&s);
|
||||
assert_eq!(px.dimensions(), (60, 40));
|
||||
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
|
||||
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
|
||||
if depth == 24 {
|
||||
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
|
||||
}
|
||||
let black = px.get_pixel(40, 20).0;
|
||||
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
|
||||
// The next export starts from the session's document again.
|
||||
s = session();
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
|
||||
for name in ["art.vda", "ART.ICB", "art.vst"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!(r["format"], "tga", "{name}: {r}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
|
||||
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
|
||||
// Copied in a file manager, a Targa file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
|
||||
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
|
||||
for e in ["tga", "vda", "icb", "vst"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
|
||||
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
|
||||
}
|
||||
|
||||
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
|
||||
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
|
||||
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
|
||||
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
|
||||
/// attribute bit.
|
||||
#[test]
|
||||
fn targa_layouts_and_alpha_read() {
|
||||
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
|
||||
let mut rle = Vec::new();
|
||||
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
|
||||
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
|
||||
|
||||
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
|
||||
// packet of red, blue and a clear pixel for the first row, then a run of green.
|
||||
let mut f = targa_header(10, 3, 2, 16, 0x31);
|
||||
f.push(0x02);
|
||||
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.push(0x82);
|
||||
f.extend(0x83e0u16.to_le_bytes());
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
|
||||
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
|
||||
|
||||
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
|
||||
for fourth in [[0, 0], [0, 255]] {
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x20);
|
||||
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
|
||||
}
|
||||
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x28);
|
||||
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p.0[3] == 0));
|
||||
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
|
||||
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
|
||||
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
|
||||
let mut f = targa_header(2, 1, 1, 32, 0x28);
|
||||
f.extend([50, 50, 50, 128]);
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = kind;
|
||||
let at = f.len() as u32;
|
||||
f.extend(area);
|
||||
f.extend(at.to_le_bytes());
|
||||
f.extend([0; 4]);
|
||||
f.extend(b"TRUEVISION-XFILE.\0");
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
|
||||
}
|
||||
|
||||
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
|
||||
// raw row of 8-bit indices and as runs.
|
||||
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
|
||||
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
|
||||
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.extend(data);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
|
||||
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
|
||||
}
|
||||
|
||||
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
|
||||
// its header.
|
||||
let huge = targa_header(11, 32768, 32768, 8, 0);
|
||||
let e = raster_image(&huge).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
|
||||
|
||||
// Headers with values the specification doesn't define aren't Targa files.
|
||||
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
|
||||
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
|
||||
}
|
||||
assert!(raster_image(b"nothing an image reader knows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
|
||||
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
|
||||
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
|
||||
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
|
||||
//! every read is checked.
|
||||
|
||||
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
|
||||
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
|
||||
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
|
||||
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
|
||||
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
|
||||
pub(super) fn is_targa(bytes: &[u8]) -> bool {
|
||||
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return false;
|
||||
};
|
||||
matches!(map_type, 0 | 1)
|
||||
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
|
||||
&& (map_type == 1 || !matches!(image_type, 1 | 9))
|
||||
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
|
||||
&& u16::from_le_bytes([w0, w1]) > 0
|
||||
&& u16::from_le_bytes([h0, h1]) > 0
|
||||
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
|
||||
&& descriptor & 0xc0 == 0
|
||||
}
|
||||
|
||||
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
|
||||
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
|
||||
/// Attributes Type, when the file has one, says what they hold:
|
||||
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
|
||||
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
|
||||
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
|
||||
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
|
||||
/// the bit;
|
||||
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
|
||||
/// are divided by it.
|
||||
///
|
||||
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
|
||||
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
|
||||
let kind = attributes_type(bytes);
|
||||
if bits == 0 || matches!(kind, Some(1 | 2)) {
|
||||
img.pixels_mut().for_each(|p| p.0[3] = 255);
|
||||
return;
|
||||
}
|
||||
if bits == 1 {
|
||||
one_bit_alpha(bytes, img);
|
||||
}
|
||||
if kind == Some(4) {
|
||||
for p in img.pixels_mut() {
|
||||
let a = u16::from(p.0[3]);
|
||||
for c in &mut p.0[..3] {
|
||||
// A fully transparent pixel keeps its colors.
|
||||
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
|
||||
*c = v.min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
|
||||
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
|
||||
/// gives it.
|
||||
fn attributes_type(bytes: &[u8]) -> Option<u8> {
|
||||
let footer = bytes.len().checked_sub(26)?;
|
||||
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
|
||||
return None;
|
||||
}
|
||||
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
|
||||
// The area follows the header and ends before the footer; its first field is its size.
|
||||
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(at + 494).copied()
|
||||
}
|
||||
|
||||
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
|
||||
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
|
||||
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
|
||||
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
|
||||
/// the data doesn't reach, keep the alpha they have.
|
||||
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
|
||||
let mapped = matches!(image_type, 1 | 9);
|
||||
// The number of bytes each pixel takes.
|
||||
let size = match image_type {
|
||||
2 | 10 if depth == 16 => 2,
|
||||
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
|
||||
_ => return,
|
||||
};
|
||||
if img.dimensions() != (w, h) {
|
||||
return;
|
||||
}
|
||||
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
|
||||
let map_at = 18 + usize::from(id_len);
|
||||
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
|
||||
// The attribute bit is the top bit of the little-endian pixel or color map entry.
|
||||
let attribute = |at: usize| -> Option<bool> {
|
||||
let px = bytes.get(at..at.checked_add(size)?)?;
|
||||
let high = if mapped {
|
||||
let index = match *px {
|
||||
[i] => u16::from(i),
|
||||
[lo, hi] => u16::from_le_bytes([lo, hi]),
|
||||
_ => return None,
|
||||
};
|
||||
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
|
||||
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
|
||||
} else {
|
||||
*px.get(1)?
|
||||
};
|
||||
Some(high & 0x80 != 0)
|
||||
};
|
||||
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
|
||||
let total = u64::from(w) * u64::from(h);
|
||||
let mut set = |i: u64, opaque: bool| {
|
||||
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
|
||||
let y = if top_down { row } else { h - 1 - row };
|
||||
let x = if right_to_left { w - 1 - col } else { col };
|
||||
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
|
||||
};
|
||||
let mut at = map_at + map_len;
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
if image_type < 9 {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
continue;
|
||||
}
|
||||
let Some(&packet) = bytes.get(at) else { return };
|
||||
let count = u64::from(packet & 0x7f) + 1;
|
||||
at += 1;
|
||||
if packet & 0x80 != 0 {
|
||||
// A run packet repeats one pixel.
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
at += size;
|
||||
for _ in 0..count.min(total - i) {
|
||||
set(i, opaque);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..count.min(total - i) {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
|
||||
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
|
||||
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
|
||||
// Bounded so coordinates stay finite (and the document stays serializable).
|
||||
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
|
||||
@@ -480,6 +481,7 @@ impl Rasterized {
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
|
||||
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
|
||||
let resize = Affine::translate(b.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
|
||||
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return trim_image(s, id, (**im).clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let rect = match p.get("rect").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if v.len() != 4 {
|
||||
return Err(bad(C, "rect must be [x, y, width, height]"));
|
||||
@@ -832,6 +835,7 @@ impl Session {
|
||||
|
||||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.createTrimMarks";
|
||||
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||||
None => s.crop_mark_style(),
|
||||
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
L::Row => (1, n_ab),
|
||||
L::Column => (n_ab, 1),
|
||||
};
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
|
||||
@@ -500,7 +500,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
|
||||
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
|
||||
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
|
||||
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
|
||||
s.note_journal(key, serde_json::json!(t));
|
||||
t
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
|
||||
|
||||
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
|
||||
fn dos_time() -> (u16, u16) {
|
||||
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
|
||||
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
|
||||
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
|
||||
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -37,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Direction Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
|
||||
has_doc,
|
||||
set_handle
|
||||
),
|
||||
@@ -100,11 +101,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
|
||||
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
|
||||
cmd!(
|
||||
"path.cutAtAnchors",
|
||||
"Cut Path at Selected Anchor Points",
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
@@ -680,18 +681,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((i, dist)) if dist <= tol => i,
|
||||
_ => sp.insert_anchor(seg, t),
|
||||
};
|
||||
let origin = sp.anchors[grabbed].p;
|
||||
// Each anchor's distance along the path from the grabbed one, the shorter way round a
|
||||
// closed path: the lengths are measured once, as a path can have many anchors.
|
||||
let pos = sp.anchor_arc_positions();
|
||||
let total = pos.last().copied().unwrap_or(0.0);
|
||||
// The default reach is the subpath's size, as before, now measured along it.
|
||||
let r = radius.unwrap_or_else(|| {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&bp);
|
||||
(b.width().hypot(b.height()) / 2.0).max(1.0)
|
||||
});
|
||||
let from = pos.get(grabbed).copied().unwrap_or(0.0);
|
||||
let closed = sp.closed;
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
1.0
|
||||
} else {
|
||||
let q = (a.p.distance(origin) / r).min(1.0);
|
||||
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
|
||||
let d = if closed { d.min(total - d) } else { d };
|
||||
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
|
||||
let q = (d / r).min(1.0);
|
||||
(1.0 - q * q).powi(2)
|
||||
};
|
||||
let v = delta * w;
|
||||
@@ -706,7 +716,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, _| {
|
||||
|
||||
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
anchors_many
|
||||
),
|
||||
cmd!(
|
||||
"select.color",
|
||||
"Select by Color",
|
||||
[],
|
||||
None,
|
||||
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
|
||||
has_doc,
|
||||
select_color
|
||||
),
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
"Fill Color",
|
||||
@@ -528,6 +537,29 @@ fn stroke_weight(n: &Node) -> f64 {
|
||||
if paint.is_none() { 0.0 } else { width }
|
||||
}
|
||||
|
||||
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
|
||||
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.color";
|
||||
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
|
||||
};
|
||||
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return Err(bad(C, "give `fill`, `stroke` or both"));
|
||||
}
|
||||
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
|
||||
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
|
||||
}
|
||||
|
||||
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
|
||||
/// it holds its members, and its members report none.
|
||||
fn paint_reported(d: &Document, n: &Node) -> bool {
|
||||
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
|
||||
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
|
||||
@@ -160,6 +160,13 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
|
||||
|
||||
// ---------- Change Case ----------
|
||||
|
||||
/// Does `c` take a capital at the start of a word or sentence? Georgian has no word capitals:
|
||||
/// Mtavruli is a separate all-caps style, so title and sentence case leave Mkhedruli (and turn
|
||||
/// Mtavruli back into) lower case. Upper case still gives Mtavruli, as Unicode defines it.
|
||||
fn capitalizes(c: char) -> bool {
|
||||
!matches!(c, '\u{10D0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}')
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CaseState {
|
||||
/// Previous character (across runs).
|
||||
@@ -187,7 +194,7 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"upper" | "uppercase" => out.extend(c.to_uppercase()),
|
||||
"lower" | "lowercase" => out.extend(c.to_lowercase()),
|
||||
"title" => {
|
||||
if st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
|
||||
if capitalizes(c) && st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
|
||||
out.extend(c.to_uppercase())
|
||||
} else {
|
||||
out.extend(c.to_lowercase())
|
||||
@@ -195,7 +202,11 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => {
|
||||
if c.is_alphabetic() && st.sentence {
|
||||
out.extend(c.to_uppercase());
|
||||
if capitalizes(c) {
|
||||
out.extend(c.to_uppercase());
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
st.sentence = false;
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
|
||||
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(s.menu.wand))
|
||||
}
|
||||
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
|
||||
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
|
||||
/// without any, the default character fill, as `document.inspect` reports it).
|
||||
pub(crate) fn fill_of(n: &Node) -> Paint {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
|
||||
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
|
||||
_ => n.appearance.fill_paint(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_falloff_follows_arc_length_on_open_paths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({
|
||||
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
|
||||
"closed": false
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
|
||||
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
|
||||
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
|
||||
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
|
||||
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
|
||||
let mut s = session();
|
||||
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
|
||||
/// key, and no mosaic is made.
|
||||
#[test]
|
||||
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = undo(&s);
|
||||
for (p, msg) in [
|
||||
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
|
||||
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
|
||||
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
|
||||
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
|
||||
] {
|
||||
let r = s.execute("object.createObjectMosaic", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
|
||||
assert_eq!(undo(&s), before, "{p}");
|
||||
}
|
||||
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
|
||||
assert_eq!(r["tiles"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_pixel_rounds_drawing_and_moves() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -130,6 +130,62 @@ fn pencil_continues_selected_open_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
|
||||
}
|
||||
|
||||
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
|
||||
/// selected path replaces the section between, drawn either way; the path stays one path with
|
||||
/// its ends, and is one undo step.
|
||||
#[test]
|
||||
fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() {
|
||||
let bump = [(100.0, 101.0), (125.0, 70.0), (150.0, 60.0), (175.0, 70.0), (200.0, 101.0)];
|
||||
for reversed in [false, true] {
|
||||
let mut s = session();
|
||||
let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);
|
||||
let mut pts = bump.to_vec();
|
||||
if reversed {
|
||||
pts.reverse();
|
||||
}
|
||||
let undo = undo_steps(&s);
|
||||
drag(&mut s, "pencil", &pts, Mods::default());
|
||||
assert_eq!(paths(&s).len(), 1, "no new object");
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
assert!(near(ps[0], Point::new(0.0, 100.0)) && near(*ps.last().unwrap(), Point::new(300.0, 100.0)), "{ps:?}");
|
||||
// The section starts and ends on the line, where the stroke met it, and rises between.
|
||||
let on = |x: f64| ps.iter().any(|p| (p.x - x).abs() < 1.5 && (p.y - 100.0).abs() < 1e-6);
|
||||
assert!(on(100.0) && on(200.0), "{reversed}: {ps:?}");
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < 75.0, "the bump: top {top}");
|
||||
assert!(ps.windows(2).all(|w| w[0].x <= w[1].x + 1.0), "in order along the line: {ps:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a closed path the stroke replaces the shorter way between where it starts and ends, and the
|
||||
/// path stays closed; a stroke that doesn't end on the path draws a new one.
|
||||
#[test]
|
||||
fn pencil_redraws_the_shorter_way_round_a_closed_path() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("path.freehand", &json!({"points": [[50, 1], [80, -25], [100, -30], [120, -25], [150, 1]], "redraw": {"id": id.0, "within": 4}}))
|
||||
.unwrap();
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
for corner in [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] {
|
||||
assert!(ps.iter().any(|p| near(*p, Point::new(corner.0, corner.1))), "{corner:?} kept: {ps:?}");
|
||||
}
|
||||
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
|
||||
assert!(top < -20.0, "the dip: top {top}");
|
||||
// Not ending on the path: a new path, the rectangle as it was.
|
||||
let before = path(&s, id);
|
||||
s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err());
|
||||
}
|
||||
|
||||
/// With Keep selected off, the path a stroke draws or continues is not selected afterwards, the
|
||||
/// rest of the selection stays, and the stroke is one undo step.
|
||||
#[test]
|
||||
@@ -811,6 +867,131 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
|
||||
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
|
||||
fn two_subpaths(s: &mut Session) -> NodeId {
|
||||
let id = curve(s);
|
||||
let sub = |y: f64| {
|
||||
let anchors = json!([
|
||||
{"x": 100, "y": y, "out": [130, y - 30.0]},
|
||||
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
|
||||
{"x": 300, "y": y, "in": [270, y - 30.0]},
|
||||
]);
|
||||
json!({"anchors": anchors, "closed": false})
|
||||
};
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
|
||||
/// names `key`, and the document and the undo history stay as they were.
|
||||
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
|
||||
for v in [json!(1.5), json!(-1), json!("1")] {
|
||||
let mut p = p.clone();
|
||||
p[key] = v;
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_steps(s);
|
||||
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
|
||||
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
|
||||
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
|
||||
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
|
||||
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
|
||||
/// subpath 0 alone.
|
||||
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
|
||||
let before = path(s, id).subpaths;
|
||||
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = path(s, id).subpaths;
|
||||
assert_eq!(after.len(), 2, "{cmd} {p}");
|
||||
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
|
||||
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
|
||||
// `anchor`.
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let at_anchor = json!({"id": id.0, "anchor": 1});
|
||||
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
|
||||
let before = path(&s, id).subpaths;
|
||||
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
|
||||
s.execute("path.split", &p).unwrap();
|
||||
let after = path(&s, id).subpaths;
|
||||
// The subpath split becomes two of two anchors each, in its place.
|
||||
let kept = if si == 1 { &after[0] } else { &after[2] };
|
||||
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
|
||||
assert_eq!(kept, &before[1 - si], "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
|
||||
let mut s = session();
|
||||
let id = two_subpaths(&mut s);
|
||||
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
|
||||
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
|
||||
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
|
||||
@@ -62,10 +62,14 @@ fn clicking_without_typing_leaves_no_text_and_no_history() {
|
||||
click(&mut s, 150.0, 100.0);
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click in two places: the first is gone when the second is placed.
|
||||
// Click in two places: the second click discards the first type and places none (#2871); a
|
||||
// third places new type.
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(!s.tool_wants_text());
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
@@ -95,6 +99,25 @@ fn typed_text_stays_and_typing_it_away_leaves_nothing() {
|
||||
assert_eq!(undo_labels(&s), kept);
|
||||
}
|
||||
|
||||
/// A click on blank canvas while type is being edited ends the editing and makes no new type; the
|
||||
/// Type tool stays, and the next click places point type (#2871, #2854).
|
||||
#[test]
|
||||
fn a_click_away_from_typed_text_ends_editing_without_new_type() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
|
||||
assert_eq!(s.tool_id(), "type");
|
||||
assert!(!s.tool_wants_text());
|
||||
assert_eq!(undo_labels(&s), ["Type", "Typing"]);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
assert!(s.tool_wants_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_when_left_empty() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified:
|
||||
|
||||
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
|
||||
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
|
||||
let before = vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
let before = vectorcraft_doc::metadata::output_time().unwrap();
|
||||
let out = f();
|
||||
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
|
||||
(out, before..=vectorcraft_doc::metadata::output_time().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -371,7 +371,7 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
|
||||
}
|
||||
|
||||
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
|
||||
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
|
||||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||||
/// an orange square on the next layer.
|
||||
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||||
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
|
||||
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
|
||||
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
|
||||
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
|
||||
/// #2692 and #2830: the image inside a clipping group, clipped in the file or flattened by the
|
||||
/// save, is written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space
|
||||
/// inside each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`.
|
||||
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
|
||||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||||
/// clipping group, the image and the square.
|
||||
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
|
||||
/// holds.
|
||||
#[test]
|
||||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
|
||||
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
@@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
|
||||
let (last, full) = lines[at + 1..end].split_last().unwrap();
|
||||
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
assert_eq!(lines[end + 1], "F", "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
|
||||
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
|
||||
assert!(text.contains(square), "{text}");
|
||||
@@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(layer_names(d), [layer, "above"]);
|
||||
assert_eq!(images(d), (1, 1), "{layer}");
|
||||
let mut drawn = 0;
|
||||
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
|
||||
assert_eq!(drawn, paths, "{layer}");
|
||||
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
|
||||
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,86 @@ fn linked_files_on_other_computers_are_never_read() {
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
|
||||
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
|
||||
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
|
||||
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
|
||||
let root = r"\\?\UNC\server\share";
|
||||
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
|
||||
use crate::cmd::fileio::share_of;
|
||||
// craft-docpath refuses a link on the share when the document is given by its drive letter.
|
||||
let link = r"\\server\share\img\logo.png";
|
||||
assert_eq!(craft_docpath::resolve(link, Some(std::path::Path::new(r"Z:\jobs\doc.vectorcraft"))), Err(craft_docpath::Refused::Network));
|
||||
assert!(craft_docpath::resolve(link, Some(std::path::Path::new(r"\\server\share\jobs\doc.vectorcraft"))).is_ok());
|
||||
|
||||
// The document's path on the share, from its folder's final path.
|
||||
let mut asked = vec![];
|
||||
let mut ask = |folder: &str| {
|
||||
asked.push(folder.to_string());
|
||||
on_share(folder)
|
||||
};
|
||||
assert_eq!(share_of(r"Z:\jobs\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
assert_eq!(share_of("Z:/jobs/doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
|
||||
assert_eq!(asked, [r"Z:\jobs", r"Z:\", "Z:/jobs"]);
|
||||
let unc = |p: &'static str| move |_: &str| Some(std::path::PathBuf::from(p));
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\unc\server\share\")).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
|
||||
|
||||
// A folder on a local volume (also behind a `subst` drive), a folder that can't be opened,
|
||||
// and a final path without a share name give no path on a share.
|
||||
assert_eq!(share_of(r"C:\art\doc.vectorcraft", unc(r"\\?\C:\art")), None);
|
||||
assert_eq!(share_of(r"S:\doc.vectorcraft", unc(r"\\?\D:\real")), None);
|
||||
assert_eq!(share_of(r"Z:\gone\doc.vectorcraft", |_| None), None);
|
||||
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\UNC\server")), None);
|
||||
// A document given by its share, or not by a drive, is not looked up.
|
||||
for doc in [
|
||||
r"\\server\share\doc.vectorcraft",
|
||||
r"\\?\Z:\doc.vectorcraft",
|
||||
"/Users/a/doc.vectorcraft",
|
||||
"doc.vectorcraft",
|
||||
"Z:doc.vectorcraft",
|
||||
r"Z:\jobs\",
|
||||
] {
|
||||
assert_eq!(share_of(doc, |f: &str| panic!("{doc}: looked up {f}")), None, "{doc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn links_on_a_mapped_drives_share_are_followed() {
|
||||
use crate::cmd::fileio::{document_path_with, share_of};
|
||||
let doc = Some(r"Z:\jobs\doc.vectorcraft");
|
||||
let share = |d: &str| share_of(d, on_share);
|
||||
// A link on the share behind the drive is followed; one on another share still isn't.
|
||||
assert_eq!(document_path_with(r"\\server\share\img\logo.png", doc, share).as_deref(), Ok(r"\\server\share\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\SERVER\Share\logo.png", doc, share).as_deref(), Ok(r"\\SERVER\Share\logo.png"));
|
||||
assert_eq!(document_path_with(r"\\other\share\logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
assert_eq!(document_path_with("file://server/share/logo.png", doc, share), Err(craft_docpath::Refused::Network));
|
||||
// On a local volume, the link stays refused.
|
||||
assert_eq!(document_path_with(r"\\server\share\logo.png", doc, |_| None), Err(craft_docpath::Refused::Network));
|
||||
// Links that were followed resolve as before, without looking the share up.
|
||||
let unasked = |d: &str| -> Option<String> { panic!("looked up {d}") };
|
||||
assert_eq!(document_path_with(r"img\logo.png", doc, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
|
||||
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_off_a_mapped_drive_keeps_its_path() {
|
||||
use crate::cmd::fileio::share_path;
|
||||
let dir = Folder::new("docpath-share");
|
||||
// A folder on this computer's own disk, and one that doesn't exist.
|
||||
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
|
||||
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
|
||||
// Elsewhere than Windows, no document is looked up.
|
||||
if cfg!(not(windows)) {
|
||||
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_images_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-svg");
|
||||
|
||||
@@ -157,6 +157,42 @@ fn a_linked_photoshop_document_places_and_updates() {
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
}
|
||||
|
||||
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
|
||||
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([24, 0]);
|
||||
let [r, g, b] = rgb;
|
||||
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
|
||||
f
|
||||
}
|
||||
|
||||
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
|
||||
/// relinked to one.
|
||||
#[test]
|
||||
fn a_linked_targa_file_places_updates_and_relinks() {
|
||||
let dir = Folder::new("tga");
|
||||
let pic = dir.file("art/photo.tga");
|
||||
write(&pic, &targa(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
assert_eq!(image(&s, id).link.unwrap().path, pic);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
write(&pic, &targa(400, 400, BLUE));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height), (400, 400));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
let other = dir.file("other/red.vst");
|
||||
write(&other, &targa(300, 100, RED));
|
||||
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_relative_path_finds_the_file_after_the_folder_moves() {
|
||||
let a = Folder::new("move-a");
|
||||
|
||||
@@ -83,6 +83,13 @@ fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
|
||||
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
|
||||
let r = s.execute(cmd, p);
|
||||
let want = format!("`{key}` must be a number, not {}", p[key]);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
|
||||
}
|
||||
|
||||
// ---------- Object → Lock / Hide ----------
|
||||
|
||||
#[test]
|
||||
@@ -399,6 +406,29 @@ fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
|
||||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
|
||||
}
|
||||
|
||||
/// Transform Each refuses a scale, move or rotation given as text or as another value that is not a
|
||||
/// number, and the object stays as it was; numbers for the scales and the horizontal move transform it.
|
||||
#[test]
|
||||
fn transform_each_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), bounds(&s, a));
|
||||
for (key, p) in [
|
||||
("scaleH", json!({"scaleH": "50"})),
|
||||
("scaleV", json!({"scaleH": 50, "scaleV": "50%"})),
|
||||
("moveH", json!({"moveH": "10"})),
|
||||
("moveV", json!({"moveH": 10, "moveV": [5]})),
|
||||
("rotate", json!({"rotate": "45"})),
|
||||
("rotate", json!({"moveH": 10, "rotate": true})),
|
||||
] {
|
||||
refuses_non_number(&mut s, "object.transformEach", &p, key);
|
||||
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
|
||||
}
|
||||
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": null})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
|
||||
}
|
||||
|
||||
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
|
||||
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
|
||||
#[test]
|
||||
@@ -571,6 +601,22 @@ fn rasterize_replaces_selection_with_image() {
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
|
||||
/// selected art stays as it was.
|
||||
#[test]
|
||||
fn rasterize_refuses_a_ppi_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = (undo_len(&s), node(&s, a));
|
||||
for p in [json!({"ppi": "144"}), json!({"ppi": "300 ppi", "padding": 0}), json!({"ppi": [72]})] {
|
||||
refuses_non_number(&mut s, "object.rasterize", &p, "ppi");
|
||||
assert_eq!((undo_len(&s), node(&s, a)), before, "{p}");
|
||||
}
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap());
|
||||
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_says_when_an_image_could_not_be_decoded() {
|
||||
let mut s = session();
|
||||
@@ -608,6 +654,25 @@ fn crop_image_to_rect() {
|
||||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
|
||||
/// null `rect` crops to the artboard as a missing one does.
|
||||
#[test]
|
||||
fn crop_image_refuses_a_rect_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let before = (undo_len(&s), node(&s, img));
|
||||
for p in [json!({"rect": "0, 0, 20, 20"}), json!({"rect": {"x": 0, "y": 0, "width": 20, "height": 20}}), json!({"rect": 20})] {
|
||||
let r = s.execute("object.cropImage", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "rect must be [x, y, width, height]"), "{p}: {r:?}");
|
||||
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
|
||||
}
|
||||
s.execute("object.cropImage", &json!({"rect": null})).unwrap();
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_disabled_without_image() {
|
||||
let mut s = session();
|
||||
@@ -656,6 +721,30 @@ fn trim_marks_around_selection() {
|
||||
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
|
||||
}
|
||||
|
||||
/// Create Trim Marks refuses an offset, length, bleed or weight given as text or as another value
|
||||
/// that is not a number, around the selection and around the artboards, and makes no marks.
|
||||
#[test]
|
||||
fn trim_marks_refuse_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let calls = [
|
||||
("offset", json!({"offset": "10"})),
|
||||
("length", json!({"length": "20 pt"})),
|
||||
("bleed", json!({"style": "japanese", "bleed": "3"})),
|
||||
("weight", json!({"weight": true})),
|
||||
("offset", json!({"allArtboards": true, "offset": {"value": 10}})),
|
||||
];
|
||||
for ids in [vec![a], vec![]] {
|
||||
sel(&mut s, &ids);
|
||||
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.createTrimMarks", p, key);
|
||||
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
|
||||
}
|
||||
}
|
||||
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
|
||||
}
|
||||
|
||||
// ---------- Convert to Shape ----------
|
||||
|
||||
#[test]
|
||||
@@ -791,6 +880,22 @@ fn rearrange_refuses_columns_that_are_not_a_whole_number() {
|
||||
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards refuses a spacing given as text or as another value that is not a number,
|
||||
/// and the artboards stay where they were.
|
||||
#[test]
|
||||
fn rearrange_refuses_a_spacing_given_as_text() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let before = (boards(&s), undo_len(&s));
|
||||
for p in [json!({"spacing": "50"}), json!({"columns": 1, "spacing": "50 pt"}), json!({"layout": "row", "spacing": false})] {
|
||||
refuses_non_number(&mut s, "artboard.rearrange", &p, "spacing");
|
||||
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
|
||||
}
|
||||
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
|
||||
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
|
||||
}
|
||||
|
||||
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
|
||||
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
|
||||
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
|
||||
@@ -1026,6 +1131,55 @@ fn select_same_paint_reads_type_characters() {
|
||||
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
fn select_color_matches_fill_and_stroke_without_a_selection() {
|
||||
let mut s = session();
|
||||
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
let t = text(&mut s, "a");
|
||||
fill(&mut s, a, "#000000");
|
||||
fill(&mut s, t, "#000000");
|
||||
// A grey as a PDF stores it reads as #fefefe.
|
||||
s.execute("paint.setFill", &json!({"ids": [b.0], "color": [0.996, 0.996, 0.996]})).unwrap();
|
||||
fill(&mut s, c, "#ff0000");
|
||||
s.execute("paint.setStroke", &json!({"ids": [a.0, c.0], "color": "#0000ff"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let pick = |s: &mut Session, p: Value| {
|
||||
s.execute("select.color", &p).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#fefefe"})), vec![b]);
|
||||
assert_eq!(pick(&mut s, json!({"stroke": "#0000ff"})), vec![a, c]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000", "stroke": "#0000ff"})), vec![a]);
|
||||
// Select > Reselect repeats it.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// A compound path reads as its own paint, never its members'.
|
||||
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
|
||||
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
|
||||
fill(&mut s, outer, "#000000");
|
||||
fill(&mut s, inner, "#000000");
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, compound]);
|
||||
fill(&mut s, compound, "#ff0000");
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#ff0000"})), vec![c, compound]);
|
||||
// Type without characters reads as the default character fill, black.
|
||||
let empty = text(&mut s, "x");
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(empty).map(|n| &mut n.kind) {
|
||||
t.runs.clear();
|
||||
}
|
||||
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, empty]);
|
||||
assert!(s.execute("select.color", &json!({})).is_err());
|
||||
assert!(s.execute("select.color", &json!({"fill": "not a colour"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_direction_handles_selects_all_anchors() {
|
||||
let mut s = session();
|
||||
@@ -1595,6 +1749,23 @@ fn change_case_variants() {
|
||||
assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err());
|
||||
}
|
||||
|
||||
/// Georgian has no word capitals: title and sentence case leave Mkhedruli lower case (and bring
|
||||
/// Mtavruli back to it) instead of capitalizing it; upper case still gives Mtavruli.
|
||||
#[test]
|
||||
fn change_case_leaves_georgian_uncapitalized() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "ფენის სახელი. ახალი Layer");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "ფენის სახელი. ახალი Layer");
|
||||
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "ფენის სახელი. ახალი layer");
|
||||
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "ᲤᲔᲜᲘᲡ ᲡᲐᲮᲔᲚᲘ. ᲐᲮᲐᲚᲘ LAYER");
|
||||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "ფენის სახელი. ახალი Layer");
|
||||
}
|
||||
|
||||
/// Change Case is one undo step, and Change Case to the case the text has already records no undo
|
||||
/// step: the step to redo stays and the document stays unmodified. It still replies with the
|
||||
/// objects.
|
||||
|
||||
@@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() {
|
||||
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
|
||||
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
|
||||
}
|
||||
|
||||
/// A save records the date the document was made in, so the same commands twice gave different
|
||||
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
|
||||
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
|
||||
#[test]
|
||||
fn a_pinned_date_makes_saves_byte_identical() {
|
||||
let once = |t: i64| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
saved(&mut s, json!({}))
|
||||
};
|
||||
let at = 1_791_208_980;
|
||||
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
|
||||
// The date really is in the bytes, so the comparison above is not vacuous.
|
||||
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! report mixed values, and saves and exports never carry the editing layer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::maskedit::MASK_EDIT_LAYER;
|
||||
@@ -46,6 +48,21 @@ fn data(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #2727: the canvas passes `isolation` while mask art is edited; the editing layer is not drawn on
|
||||
/// the page, so isolation dimming must not run or the artwork washes out.
|
||||
#[test]
|
||||
fn mask_editing_does_not_dim_the_canvas() {
|
||||
let mut s = session();
|
||||
editing_mask(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.isolation.expect("mask editing isolates the temporary layer");
|
||||
let render = |opts: &RenderOptions| Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, opts);
|
||||
let plain = render(&RenderOptions::default());
|
||||
let canvas = render(&RenderOptions { isolated: Some(layer), ..Default::default() });
|
||||
assert_eq!(plain.pixel(50, 100), canvas.pixel(50, 100), "inside the mask");
|
||||
assert_eq!(plain.pixel(250, 100), canvas.pixel(250, 100), "outside the mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_options_act_on_the_masked_object_while_editing() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -763,3 +763,89 @@ fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer
|
||||
let [red, ..] = out.pixel(123, 200);
|
||||
assert!(red > 0x40, "no glow along the edge: red {red}");
|
||||
}
|
||||
|
||||
fn mirror_axis(s: &Session) -> vectorcraft_tools::mirror::MirrorAxis {
|
||||
let st = s.active().unwrap();
|
||||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, 1.0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute("object.repeat.mirror", &json!({})).unwrap());
|
||||
let before = node(&s, id);
|
||||
let axis = mirror_axis(&s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let h = super::tests::history(&s);
|
||||
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 20.0), (PointerKind::Drag, 30.0), (PointerKind::Up, 40.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, axis.center.x + dx, axis.center.y), v).unwrap();
|
||||
assert!(close(mirror_axis(&s).center, axis.center + vectorcraft_geom::Vec2::new(dx, 0.0)));
|
||||
}
|
||||
assert_eq!(super::tests::history(&s).0, h.0 + 1);
|
||||
let after = node(&s, id);
|
||||
let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() };
|
||||
assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place");
|
||||
assert_ne!(a.transforms(), b.transforms(), "the reflected copy updates live");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute("object.repeat.mirror", &json!({})).unwrap());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let before = node(&s, id);
|
||||
let h = super::tests::history(&s);
|
||||
let axis = mirror_axis(&s);
|
||||
let end = axis.ends[1];
|
||||
let destination = axis.center + vectorcraft_geom::Vec2::new(70.0, 60.0);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, destination.x, destination.y).with_mods(shift), v).unwrap();
|
||||
assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6);
|
||||
assert_eq!(mirror_axis(&s).center, axis.center);
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert!(!s.tool_busy());
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y), v).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
assert_eq!(super::tests::history(&s), h);
|
||||
// A click is not an edit, and a subsequent real drag still works after cancellation.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, end.x, end.y), v).unwrap();
|
||||
assert_eq!(super::tests::history(&s), h);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y).with_mods(shift), v).unwrap();
|
||||
assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6);
|
||||
assert_eq!(super::tests::history(&s).0, h.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_hidden_controls_and_other_repeat_kinds_do_not_capture_drags() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
for command in ["object.repeat.mirror", "object.repeat.radial", "object.repeat.grid"] {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute(command, &json!({})).unwrap());
|
||||
let before = node(&s, id);
|
||||
let st = s.active().unwrap();
|
||||
if command != "object.repeat.mirror" {
|
||||
assert!(vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, 1.0).is_none());
|
||||
}
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
// Away from every instance: the hidden axis's top handle cannot edit the repeat.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 170.0, 76.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 80.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 80.0), v).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
}
|
||||
}
|
||||
@@ -1181,7 +1181,10 @@ fn new_type_starts_with_placeholder_text() {
|
||||
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
|
||||
assert!(text(&s).len() > placeholder.len());
|
||||
set_pref(&mut s, "placeholderText", json!(false));
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
// The first click ends editing the area type (#2871); the second places point type.
|
||||
for _ in 0..2 {
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
}
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
|
||||
@@ -1428,6 +1428,57 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Targa files, which start with no signature: run-length encoded 24 bits (as the `image` crate
|
||||
/// writes them), a 16-bit export with a one-bit alpha, and by hand a 16-bit run-length encoded file
|
||||
/// stored right to left and top down, a run-length encoded color-mapped one with 16-bit entries,
|
||||
/// and a 32-bit one with an extension area.
|
||||
fn targa_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let mut rle = vec![];
|
||||
image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])).write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
let mut s = rich_session();
|
||||
let r = s.execute("document.export", &json!({"format": "tga", "depth": 16, "ppi": 2})).unwrap();
|
||||
let export = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let mut alpha = vec![0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 2, 0, 16, 0x31, 0x02, 0x00, 0xfc, 0x1f, 0x80, 0x00, 0x00];
|
||||
alpha.extend([0x82, 0xe0, 0x83]);
|
||||
let mut mapped = vec![0, 1, 9, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 2, 0, 8, 0x21, 0x00, 0x00, 0x00, 0xfc];
|
||||
mapped.extend([0x80, 6, 0x00, 5, 0x83, 6]);
|
||||
let mut extended = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28, 50, 50, 50, 128];
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = 4;
|
||||
extended.extend(area);
|
||||
extended.extend(22u32.to_le_bytes());
|
||||
extended.extend([0; 4]);
|
||||
extended.extend(b"TRUEVISION-XFILE.\0");
|
||||
vec![("a.tga", rle), ("b.tga", export), ("c.tga", alpha), ("d.vda", mapped), ("e.vst", extended)]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated Targa files (headers, run-length packets, color maps, the extension area and the
|
||||
/// footer) never crash placing them.
|
||||
#[test]
|
||||
fn mutated_targa_files_place_without_panics(which in 0usize..5, cut in 0usize..600, edits in prop::collection::vec((0usize..560, any::<u8>()), 0..12)) {
|
||||
let (name, mut bytes) = targa_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place.info", &p));
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG saved with hidden layers and editing data ----------
|
||||
|
||||
/// The rich document with a hidden layer, saved as SVG with its editing data.
|
||||
@@ -1741,6 +1792,27 @@ proptest! {
|
||||
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
|
||||
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5
|
||||
/// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits:
|
||||
/// read as an image, or as an error.
|
||||
#[test]
|
||||
fn eps_tiff_predicted_images_never_panic(
|
||||
data in prop::collection::vec(any::<u8>(), 0..300),
|
||||
bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]),
|
||||
colors in 1u8..6,
|
||||
columns in 1u16..40,
|
||||
image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]),
|
||||
(width, height) in (1u8..24, 1u8..8),
|
||||
) {
|
||||
let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect();
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \
|
||||
currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\
|
||||
{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
@@ -1851,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall",
|
||||
"currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file",
|
||||
"//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode",
|
||||
"<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
|
||||
// Tiling patterns, uncoloured ones and patterns drawing patterns.
|
||||
"<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern",
|
||||
"<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>",
|
||||
|
||||
@@ -2012,9 +2012,15 @@ impl<'a> Reader<'a> {
|
||||
/// An image of operands `vals` and samples `data`.
|
||||
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let n = Self::nums(&vals);
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { return Ok(()) };
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let (w, h) = (*w as u64, *h as u64);
|
||||
if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2080,7 +2086,10 @@ impl<'a> Reader<'a> {
|
||||
rgba.extend_from_slice(&[px[0], px[1], px[2], a]);
|
||||
}
|
||||
}
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return Ok(()) };
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let mut png = vec![];
|
||||
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
@@ -2096,10 +2105,14 @@ impl<'a> Reader<'a> {
|
||||
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
|
||||
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
|
||||
// gives the same place only when the image isn't rotated (b = c = 0).
|
||||
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
|
||||
let [a, b, c, d, tx, ty] = m else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
|
||||
|
||||
@@ -677,6 +677,40 @@ fn images_with_alpha_channels() {
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
|
||||
fn image_left_out_with_a_warning(data: &[u8]) {
|
||||
let r = read(data).unwrap();
|
||||
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
|
||||
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
|
||||
assert_eq!(left_out, 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
|
||||
// The operands hold 10 numbers after the matrix, one short of the binary flag.
|
||||
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
|
||||
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
|
||||
assert_ne!(data, text);
|
||||
image_left_out_with_a_warning(data.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
|
||||
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
// This matrix maps the image onto a line.
|
||||
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
|
||||
}
|
||||
|
||||
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
|
||||
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
|
||||
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
//!
|
||||
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
|
||||
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
|
||||
//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of
|
||||
//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's
|
||||
//! `%AI3_TemplateBox`. See [`Story::place`].
|
||||
//! offset from it. A right-to-left paragraph (key `33` of its attributes) swaps the two: its
|
||||
//! "left"-aligned lines end at the anchor and its "right"-aligned ones start there. The frame of the
|
||||
//! story gives a matrix that carries that layout onto the canvas of the app, which is 16383 points a
|
||||
//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. See
|
||||
//! [`Story::place`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -253,6 +255,9 @@ enum FrameKind {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Para {
|
||||
justify: u8,
|
||||
/// A right-to-left paragraph: its left and right alignments are each other's (the line of
|
||||
/// "left"-aligned type ends at its anchor).
|
||||
rtl: bool,
|
||||
first_indent: f64,
|
||||
left_indent: f64,
|
||||
right_indent: f64,
|
||||
@@ -429,6 +434,7 @@ impl Texts {
|
||||
let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0);
|
||||
Para {
|
||||
justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8),
|
||||
rtl: pick("33").and_then(Val::num) == Some(1.0),
|
||||
first_indent: num("1"),
|
||||
left_indent: num("2"),
|
||||
right_indent: num("3"),
|
||||
@@ -747,6 +753,8 @@ impl Story {
|
||||
fn para_style(p: &Para, scale: f64) -> ParaStyle {
|
||||
ParaStyle {
|
||||
justify: match p.justify {
|
||||
0 if p.rtl => Justify::Right,
|
||||
1 if p.rtl => Justify::Left,
|
||||
1 => Justify::Right,
|
||||
2 => Justify::Center,
|
||||
3 => Justify::JustifyLeft,
|
||||
@@ -795,6 +803,20 @@ fn art_scale(to_doc: Affine) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_swap_left_and_right_alignment() {
|
||||
let texts =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
|
||||
let justify = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).justify;
|
||||
// Right to left from the document's defaults, or the paragraph's own.
|
||||
assert_eq!(justify(b"/0 0"), Justify::Right);
|
||||
assert_eq!(justify(b"/0 1 /33 1"), Justify::Left);
|
||||
assert_eq!(justify(b"/0 2"), Justify::Center);
|
||||
// Left to right keeps them.
|
||||
assert_eq!(justify(b"/0 0 /33 0"), Justify::Left);
|
||||
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
|
||||
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
|
||||
|
||||
@@ -324,16 +324,46 @@ impl Predictor {
|
||||
// Bytes per pixel (at least one) is how far back the left neighbour is.
|
||||
let bpp = (self.colors * self.bpc).div_ceil(8).max(1);
|
||||
if self.kind == 2 {
|
||||
// Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its
|
||||
// difference from the same component of the pixel to its left, wrapping within its bit
|
||||
// width. Each row starts again, and the padding bits at a row's end stay as they are.
|
||||
let mut out = data.to_vec();
|
||||
// Horizontal differencing, for 8-bit components (the common case).
|
||||
if self.bpc == 8 {
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
for line in out.chunks_mut(row.max(1)) {
|
||||
match self.bpc {
|
||||
8 => {
|
||||
for i in bpp..line.len() {
|
||||
let left = line.get(i - bpp).copied().unwrap_or(0);
|
||||
if let Some(b) = line.get_mut(i) {
|
||||
*b = b.wrapping_add(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 16-bit components are big-endian and add as 16-bit numbers.
|
||||
16 => {
|
||||
let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0);
|
||||
for i in (bpp..line.len().saturating_sub(1)).step_by(2) {
|
||||
let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]);
|
||||
let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left);
|
||||
if let Some(w) = line.get_mut(i..i + 2) {
|
||||
w.copy_from_slice(&sum.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1, 2 or 4 bits: components are packed from each byte's high bit.
|
||||
bits @ (1 | 2 | 4) => {
|
||||
let mask = (1u8 << bits) - 1;
|
||||
let shift = |k: usize| 8 - bits - k * bits % 8;
|
||||
let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask);
|
||||
let samples = (line.len() * 8 / bits).min(self.colors * self.columns);
|
||||
for k in self.colors..samples {
|
||||
let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask;
|
||||
if let Some(b) = line.get_mut(k * bits / 8) {
|
||||
*b = (*b & !(mask << shift(k))) | (sum << shift(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Predictor::of` accepts no other depth.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -186,13 +186,27 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import_with(bytes, true)
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
|
||||
fn program(bytes: &[u8]) -> Result<&[u8], String> {
|
||||
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
|
||||
if !ps.starts_with(b"%!") {
|
||||
return Err("this is not a PostScript file".into());
|
||||
}
|
||||
Ok(ps)
|
||||
}
|
||||
|
||||
/// An EPS or PostScript file's page at the origin, read from its comments without running its
|
||||
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
|
||||
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
|
||||
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
|
||||
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
|
||||
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
let ps = program(bytes)?;
|
||||
let dsc = Dsc::read(ps);
|
||||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||||
// type), and its program has all of it, which is read as the format (as before the file
|
||||
|
||||
@@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() {
|
||||
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
|
||||
assert_eq!(pixels(&r, 3, 5), want);
|
||||
}
|
||||
|
||||
/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to
|
||||
/// a whole byte.
|
||||
fn packed(rows: &[Vec<u32>], bpc: usize) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
for row in rows {
|
||||
let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)];
|
||||
for (k, v) in row.iter().enumerate() {
|
||||
if bpc == 16 {
|
||||
bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes());
|
||||
} else {
|
||||
let bit = k * bpc;
|
||||
bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8);
|
||||
}
|
||||
}
|
||||
out.extend(bytes);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at
|
||||
/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the
|
||||
/// difference from the same component of the pixel to its left, within its bit width, and each
|
||||
/// row starts again. The filter gives back the samples before differencing, read here as 8-bit
|
||||
/// gray pixels one byte each.
|
||||
#[test]
|
||||
fn tiff_predicted_rows_at_every_depth() {
|
||||
let columns = 5;
|
||||
let mut wrong = vec![];
|
||||
for bpc in [1, 2, 4, 8, 16] {
|
||||
for colors in [1, 3] {
|
||||
let max = (1u32 << bpc) - 1;
|
||||
let rows: Vec<Vec<u32>> =
|
||||
(0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect();
|
||||
let differenced: Vec<Vec<u32>> = rows
|
||||
.iter()
|
||||
.map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect())
|
||||
.collect();
|
||||
let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc));
|
||||
let width = want.len() / 3;
|
||||
let r = read(&format!(
|
||||
"30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \
|
||||
/DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \
|
||||
/FlateDecode filter >> image\n{}",
|
||||
crate::ps::ascii85(&crate::ps::deflate(&data))
|
||||
));
|
||||
let got: Vec<u8> = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect();
|
||||
if got != want {
|
||||
wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
+86
-32
@@ -29,7 +29,7 @@
|
||||
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
|
||||
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
|
||||
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
|
||||
//! digits, `%AI5_EndRaster`.
|
||||
//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show.
|
||||
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
|
||||
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
|
||||
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
|
||||
@@ -50,7 +50,9 @@
|
||||
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
|
||||
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
|
||||
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
|
||||
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
|
||||
//! names and the title are written in Windows-1252 too, and a character outside it is written as
|
||||
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
|
||||
//!
|
||||
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
|
||||
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
|
||||
@@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
|
||||
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const TITLE_AND_COLOR_NAMES: &str =
|
||||
"characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
|
||||
const FREEFORM: &str = "freeform gradients are written as their average colour";
|
||||
@@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::new(0.0, below, width, top));
|
||||
let title = w.latin_name(&o.title, TITLE_AND_COLOR_NAMES);
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
|
||||
let _ = writeln!(s, "%%Title: {}", latin_string(&title));
|
||||
if let Some(t) = o.created {
|
||||
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
|
||||
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
|
||||
@@ -179,12 +184,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
|
||||
s.push('\n');
|
||||
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
|
||||
for (i, (name, _)) in w.spots.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, latin_string(name));
|
||||
}
|
||||
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
|
||||
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
|
||||
ps::push_nums(&mut s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "{}", ps::string(name));
|
||||
let _ = writeln!(s, "{}", latin_string(name));
|
||||
}
|
||||
for (i, (name, _)) in w.fonts.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
|
||||
@@ -335,7 +340,7 @@ const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header com
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
||||
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
|
||||
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
let name = ps::string(name);
|
||||
let name = latin_string(name);
|
||||
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
|
||||
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
|
||||
for stop in g.stops.iter().rev() {
|
||||
@@ -356,7 +361,7 @@ fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
s.push_str(" 3 ");
|
||||
}
|
||||
@@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool {
|
||||
det.is_finite() && det.abs() > 1e-12
|
||||
}
|
||||
|
||||
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
|
||||
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
|
||||
fn latin_string(name: &str) -> String {
|
||||
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
|
||||
ps::bytes(&bytes)
|
||||
}
|
||||
|
||||
struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
/// The document → art space.
|
||||
@@ -401,8 +413,10 @@ struct Writer<'a> {
|
||||
body: String,
|
||||
/// Gradient definitions, by the name objects use.
|
||||
gradients: Vec<(String, Gradient)>,
|
||||
/// Spot colours used: name and full CMYK equivalent.
|
||||
/// Spot colors used: the name written and full CMYK equivalent.
|
||||
spots: Vec<(String, [f32; 4])>,
|
||||
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
|
||||
spot_names: Vec<(String, String)>,
|
||||
warnings: Vec<String>,
|
||||
/// The lock state the last `A` set.
|
||||
locked: bool,
|
||||
@@ -430,6 +444,7 @@ impl<'a> Writer<'a> {
|
||||
body: String::new(),
|
||||
gradients: vec![],
|
||||
spots: vec![],
|
||||
spot_names: vec![],
|
||||
warnings: vec![],
|
||||
locked: false,
|
||||
nest: 0,
|
||||
@@ -454,23 +469,62 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- layers and objects ----------
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
/// `name` with each character outside Windows-1252 (the encoding type and names are written
|
||||
/// in) replaced by `?`; adds the warning `warning` when it replaces one.
|
||||
fn latin_name(&mut self, name: &str, warning: &str) -> String {
|
||||
let mut lost = false;
|
||||
let bytes: Vec<u8> = name
|
||||
let kept = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
latin::encode(c).unwrap_or_else(|| {
|
||||
if latin::encode(c).is_some() {
|
||||
c
|
||||
} else {
|
||||
lost = true;
|
||||
b'?'
|
||||
})
|
||||
'?'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lost {
|
||||
self.warn(LAYER_NAME);
|
||||
self.warn(warning);
|
||||
}
|
||||
ps::bytes(&bytes)
|
||||
kept
|
||||
}
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
latin_string(&self.latin_name(name, LAYER_NAME))
|
||||
}
|
||||
|
||||
/// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom
|
||||
/// color of the document, and give the name it is written with. Its characters outside
|
||||
/// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color
|
||||
/// is already written with that name; the name is the same each time.
|
||||
fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String {
|
||||
let written = match self.spot_names.iter().find(|(n, _)| n == name) {
|
||||
Some((_, w)) => w.clone(),
|
||||
None => {
|
||||
let base = self.latin_name(name, TITLE_AND_COLOR_NAMES);
|
||||
let mut written = base.clone();
|
||||
let mut i = 1;
|
||||
while self.spot_names.iter().any(|(_, w)| *w == written) {
|
||||
i += 1;
|
||||
written = format!("{base} {i}");
|
||||
}
|
||||
self.spot_names.push((name.to_string(), written.clone()));
|
||||
written
|
||||
}
|
||||
};
|
||||
if !self.spots.iter().any(|(n, _)| *n == written) {
|
||||
self.spots.push((written.clone(), cmyk));
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has
|
||||
/// noted it.
|
||||
fn spot_name<'n>(&'n self, name: &'n str) -> &'n str {
|
||||
self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str())
|
||||
}
|
||||
|
||||
/// A top-level layer. False when it isn't a layer.
|
||||
@@ -965,7 +1019,10 @@ impl<'a> Writer<'a> {
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
|
||||
fn color(&mut self, p: &Paint, stroke: bool) {
|
||||
let ink = self.ink(p);
|
||||
let ink = match self.ink(p) {
|
||||
Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint },
|
||||
ink => ink,
|
||||
};
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
let s = &mut self.body;
|
||||
match ink {
|
||||
@@ -983,13 +1040,10 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
let _ = write!(s, "{} ", latin_string(&name));
|
||||
// The format's tint is how much lighter than the full colour (0: full).
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
let _ = writeln!(s, " {}", op("x", "X"));
|
||||
if !self.spots.iter().any(|(n, _)| *n == name) {
|
||||
self.spots.push((name, cmyk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> {
|
||||
if let Some(sw) = &stop.swatch {
|
||||
// Spot colours in stops are custom colours too.
|
||||
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
|
||||
&& !self.spots.iter().any(|(n, _)| *n == name)
|
||||
{
|
||||
self.spots.push((name, cmyk));
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p) {
|
||||
self.spot(&name, cmyk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> {
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
s.push_str("1 ");
|
||||
s.push_str(&ps::string(&name));
|
||||
s.push_str(&latin_string(&name));
|
||||
s.push(' ');
|
||||
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
|
||||
s.push_str("1 0 0 1 0 0 Bg\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
|
||||
/// time.
|
||||
/// The name of the definition of `g` (its swatch's name when it has one, with each character
|
||||
/// outside Windows-1252 written as `?` and a warning), defined the first time.
|
||||
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
|
||||
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
|
||||
return name.clone();
|
||||
}
|
||||
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
|
||||
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
|
||||
let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES);
|
||||
let mut name = base.clone();
|
||||
let mut i = 1;
|
||||
while taken(&name, &self.gradients) {
|
||||
@@ -1227,7 +1279,9 @@ impl<'a> Writer<'a> {
|
||||
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
|
||||
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
|
||||
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
|
||||
/// `%AI5_EndRaster`.
|
||||
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
|
||||
/// can leave out an image in a clipping group and the art after it. No path is open there, so
|
||||
/// the `F` paints nothing.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * im.xf * flip;
|
||||
@@ -1281,7 +1335,7 @@ impl<'a> Writer<'a> {
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\n");
|
||||
s.push_str("%AI5_EndRaster\nF\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
|
||||
@@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after
|
||||
/// `%AI5_EndRaster`.
|
||||
fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = vec![];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if *l == "%AI5_File:" {
|
||||
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
|
||||
out.push(lines[i..=end].to_vec());
|
||||
out.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec());
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
|
||||
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
|
||||
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
|
||||
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
|
||||
/// reads back with its layers, clipping group, images and square (#2692).
|
||||
/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. The art
|
||||
/// after the images is written as it is without them, and the file reads back with its layers,
|
||||
/// clipping group, images and square (#2692, #2830).
|
||||
#[test]
|
||||
fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let out = write(&images_below_a_square(true));
|
||||
@@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 3, "{text}");
|
||||
for lines in &images {
|
||||
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
|
||||
let [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F"));
|
||||
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
|
||||
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
|
||||
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
|
||||
@@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
|
||||
}
|
||||
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
|
||||
// group: `u`, the image, `U`.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
|
||||
// group: `u`, the image, `U`; the loose image, then the layer's end.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}");
|
||||
assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}");
|
||||
let layer = |t: &str| -> String {
|
||||
let at = t.find("(Above) Ln\n").unwrap();
|
||||
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
|
||||
@@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let below = children(&doc.layers[0]);
|
||||
assert_eq!(below.len(), 3, "{below:#?}");
|
||||
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
|
||||
assert_eq!(window.len(), 2, "{window:#?}");
|
||||
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
|
||||
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
|
||||
assert!(
|
||||
matches!(&below[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_)))
|
||||
);
|
||||
assert!(matches!(below[2].kind, NodeKind::Image(_)));
|
||||
let above = children(&doc.layers[1]);
|
||||
assert_eq!(above.len(), 1);
|
||||
@@ -606,6 +612,127 @@ fn spot_colours_are_custom_colours() {
|
||||
assert!((tint - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A document with a spot color swatch for each of `spots`, a square filled with each at half
|
||||
/// tint, and a square whose linear gradient fill (the first spot color to white) names the
|
||||
/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the
|
||||
/// fill's name).
|
||||
fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, name) in spots.iter().enumerate() {
|
||||
let c = i as f32 * 0.5;
|
||||
doc.swatches.push(vectorcraft_color::Swatch {
|
||||
name: (*name).into(),
|
||||
paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)),
|
||||
global: true,
|
||||
spot: true,
|
||||
});
|
||||
let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 };
|
||||
let x = 10.0 + 40.0 * i as f64;
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0));
|
||||
first.swatch = Some(spots[0].into());
|
||||
let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] };
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some(gradient.into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]).
|
||||
fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput {
|
||||
let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() };
|
||||
let out = export_legacy_ai(doc, &o).unwrap();
|
||||
prints_as(&out.bytes, doc, &o);
|
||||
out
|
||||
}
|
||||
|
||||
/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each
|
||||
/// solid fill's swatch, and each gradient fill's swatch with its first stop's.
|
||||
fn inks(doc: &Document) -> Vec<Option<String>> {
|
||||
let mut out = vec![];
|
||||
for n in children(&doc.layers[0]) {
|
||||
match n.appearance.fill_paint() {
|
||||
Paint::Solid { swatch, .. } => out.push(swatch.clone()),
|
||||
Paint::Gradient(g) => {
|
||||
out.push(g.swatch.clone());
|
||||
out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The title, spot color names and gradient names are written in Windows-1252, as layer names
|
||||
/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its
|
||||
/// stops and `Bg`. The spot color and gradient names read back as they were.
|
||||
#[test]
|
||||
fn spot_gradient_and_title_names_are_written_in_windows_1252() {
|
||||
let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}");
|
||||
let out = write_titled(&doc, "R\u{e9}sum\u{e9}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (R\\351sum\\351)\n",
|
||||
"%%DocumentCustomColors: (Caf\\351)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0.5 x\n",
|
||||
"%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n",
|
||||
"1 (D\\351grad\\351) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert!(!text.contains("\\303"), "{text}");
|
||||
assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let names = inks(&read);
|
||||
let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string()));
|
||||
assert_eq!(names, want);
|
||||
}
|
||||
|
||||
/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these
|
||||
/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still
|
||||
/// tell the colors and gradients apart.
|
||||
#[test]
|
||||
fn names_outside_windows_1252_are_replaced_and_kept_apart() {
|
||||
// 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title).
|
||||
let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}");
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))],
|
||||
};
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.swatch = Some("\u{30b0}\u{30e9}".into());
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write_titled(&doc, "\u{65e5}\u{672c}");
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
for needle in [
|
||||
"%%Title: (??)\n",
|
||||
"%%DocumentCustomColors: (?)\n%%+ (? 2)\n",
|
||||
"%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n",
|
||||
"0 0.5 1 0 (?) 0.5 x\n",
|
||||
"0.5 0.5 1 0 (? 2) 0.5 x\n",
|
||||
"(??) 0 2 Bd\n",
|
||||
"(?? 2) 0 2 Bd\n",
|
||||
"0 0.5 1 0 (?) 0 3 50 0 %_Bs\n",
|
||||
"1 (??) ",
|
||||
"1 (?? 2) ",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings);
|
||||
assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from));
|
||||
assert_eq!(inks(&read), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losses_are_warned() {
|
||||
let mut doc = sample();
|
||||
|
||||
@@ -221,6 +221,19 @@ impl SubPath {
|
||||
let b = &self.anchors[(i + 1) % n];
|
||||
!a.has_out() && !b.has_in()
|
||||
}
|
||||
/// Arc-length at each anchor from the start of this subpath (one entry per anchor).
|
||||
pub fn anchor_arc_positions(&self) -> Vec<f64> {
|
||||
let n = self.anchors.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut pos = vec![0.0];
|
||||
for i in 0..self.segment_count() {
|
||||
let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3);
|
||||
pos.push(pos.last().copied().unwrap_or(0.0) + len);
|
||||
}
|
||||
pos
|
||||
}
|
||||
pub fn to_bezpath_into(&self, out: &mut BezPath) {
|
||||
let Some(first) = self.anchors.first() else { return };
|
||||
out.move_to(first.p);
|
||||
|
||||
@@ -92,6 +92,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
|
||||
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
@@ -132,6 +133,17 @@ fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raster_scale_leaves_other_formats_alone() {
|
||||
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
|
||||
// the drawing and leave its units unset.
|
||||
let mut h = two_boards();
|
||||
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
|
||||
let plain = dxf(&mut h, json!({"format": "dxf"}));
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
|
||||
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
|
||||
+16
-4
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
for k in named_export_params(a) {
|
||||
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
|
||||
params.insert((*k).into(), v.clone());
|
||||
}
|
||||
}
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
|
||||
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
|
||||
/// would resize the drawing and leave its units unset.
|
||||
fn named_export_params(a: &Args) -> &'static [&'static str] {
|
||||
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
|
||||
if format.is_ok_and(|f| f.raster) {
|
||||
&["path", "format", "scale", "artboard", "range", "outlineText"]
|
||||
} else {
|
||||
&["path", "format", "artboard", "range", "outlineText"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
|
||||
/// `isError` result so the model can read and correct them.
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
|
||||
@@ -13,6 +13,7 @@ use krilla::metadata::DateTime;
|
||||
|
||||
use crate::PdfError;
|
||||
use crate::import_color::items;
|
||||
use crate::import_scan::text_string;
|
||||
use crate::lab_spot::find;
|
||||
|
||||
/// The embedded file holding the native document (its name among the PDF's attachments).
|
||||
@@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
|
||||
let files = spec.get::<Dict<'_>>(b"EF")?;
|
||||
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
|
||||
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
|
||||
// The hash ends the description; editing data described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
|
||||
// The hash ends the description (a text string, in any of its encodings); editing data
|
||||
// described otherwise is trusted.
|
||||
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default();
|
||||
let intact = match desc.rsplit_once("hash ") {
|
||||
Some((_, hash)) => hash.trim() == page_hash(pdf),
|
||||
None => true,
|
||||
@@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
|
||||
})
|
||||
}
|
||||
|
||||
/// A file specification naming the editing file (current or legacy name).
|
||||
/// A file specification naming the editing file (current or legacy name), in any text string
|
||||
/// encoding.
|
||||
fn is_editing_file(spec: &Dict<'_>) -> bool {
|
||||
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
|
||||
let name = name.as_bytes();
|
||||
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
|
||||
let name = text_string(name.as_bytes());
|
||||
name == EDITING_FILE || name == LEGACY_EDITING_FILE
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
|
||||
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
|
||||
// The time of writing (none on the web) dates the editing data and the page information.
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
let created_at = opts.created.or(now);
|
||||
crate::pdfx::require_date(set.standard, created_at)?;
|
||||
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
|
||||
|
||||
@@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
let set = &opts.settings;
|
||||
let job = Job::new(doc, set, opts.postscript)?;
|
||||
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::output_time);
|
||||
let sep = Separator::new(&job.doc, set);
|
||||
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
|
||||
let marks = set.marks.printer_marks();
|
||||
|
||||
@@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() {
|
||||
fn the_native_documents_modification_date_is_the_time_of_writing() {
|
||||
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
|
||||
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
|
||||
let before = year(vectorcraft_doc::metadata::now_unix());
|
||||
let before = year(vectorcraft_doc::metadata::output_time());
|
||||
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
|
||||
let after = year(vectorcraft_doc::metadata::now_unix());
|
||||
let after = year(vectorcraft_doc::metadata::output_time());
|
||||
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
|
||||
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
|
||||
}
|
||||
@@ -72,6 +72,35 @@ fn changed_pages_are_noticed() {
|
||||
assert!(editing(&r.bytes).unwrap().intact);
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it
|
||||
/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its
|
||||
/// length.
|
||||
fn described(pdf: &[u8], bom: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = [b"(", bom].concat();
|
||||
for &c in b"hash ".iter().chain(hash) {
|
||||
if bom.len() == 2 {
|
||||
desc.push(0);
|
||||
}
|
||||
desc.push(c);
|
||||
}
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_is_read_from_a_description_in_any_encoding() {
|
||||
let r = pdf_with(Some(b"native"), false);
|
||||
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
|
||||
for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] {
|
||||
assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages");
|
||||
assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
|
||||
let r = pdf_with(Some(b"legacy"), false);
|
||||
@@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() {
|
||||
let e = editing(&raw_pdf(&objects)).unwrap();
|
||||
assert_eq!(e.data, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_name_written_as_utf16_is_found() {
|
||||
let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect();
|
||||
for key in ["UF", "F"] {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Names [(a) 4 0 R] >>".to_string(),
|
||||
format!("<< /Type /Filespec /{key} <FEFF{utf16}> /EF << /F 5 0 R >> >>"),
|
||||
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(),
|
||||
];
|
||||
let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found"));
|
||||
assert_eq!(e.data, b"hello", "/{key}");
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
|
||||
});
|
||||
out
|
||||
};
|
||||
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
let now = || vectorcraft_doc::metadata::output_time().unwrap();
|
||||
// Both print the minute they are written in: try again when the minute turns between them.
|
||||
for _ in 0..3 {
|
||||
let minute = now() / 60;
|
||||
|
||||
+20
-2
@@ -584,7 +584,7 @@ thread_local! {
|
||||
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
|
||||
/// number).
|
||||
fn doc_raster_ppi(doc: &Document) -> f64 {
|
||||
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
doc.raster_ppi()
|
||||
}
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
@@ -1052,7 +1052,9 @@ impl Renderer {
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
|
||||
}
|
||||
None => {
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
|
||||
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
@@ -1112,6 +1114,7 @@ impl Renderer {
|
||||
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
|
||||
let solid = matches!(fx, RasterFx::OuterGlow { .. });
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
|
||||
effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
@@ -2589,6 +2592,21 @@ mod tests {
|
||||
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() {
|
||||
// GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel
|
||||
// of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720.
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let green_outside = |ppi: f64| {
|
||||
let mut d =
|
||||
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
|
||||
d.raster_effects_ppi = ppi;
|
||||
render(&d).pixel(27, 50)[1]
|
||||
};
|
||||
let (coarse, fine) = (green_outside(72.0), green_outside(720.0));
|
||||
assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
|
||||
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
|
||||
|
||||
+79
-11
@@ -1222,7 +1222,9 @@ impl Renderer {
|
||||
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
|
||||
/// it: the isolated container and its layers and groups, from the top layer down to it.
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
// Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on
|
||||
// the page (the mask applies on the object), so canvas dimming would wash out everything.
|
||||
let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
@@ -1823,10 +1825,12 @@ impl Renderer {
|
||||
if f.opts.highlight_substitutions {
|
||||
let setup = &f.doc.setup;
|
||||
for (on, path) in [
|
||||
(setup.highlight_substituted_fonts, &g.substituted_fonts),
|
||||
(setup.highlight_substituted_glyphs, &g.substituted_glyphs),
|
||||
(f.opts.highlight_alternate_glyphs, &g.alternate_glyphs),
|
||||
(setup.highlight_substituted_fonts, Some(&g.substituted_fonts)),
|
||||
(setup.highlight_substituted_glyphs, Some(&g.substituted_glyphs)),
|
||||
// Measured only when shown: it lays the text out again.
|
||||
(f.opts.highlight_alternate_glyphs, f.opts.highlight_alternate_glyphs.then(|| g.alternate_glyphs(t))),
|
||||
] {
|
||||
let Some(path) = path else { continue };
|
||||
if on && !path.elements().is_empty() {
|
||||
ctx.set_transform(xf);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
@@ -2254,9 +2258,13 @@ struct TextGeom {
|
||||
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
|
||||
substituted_fonts: BezPath,
|
||||
substituted_glyphs: BezPath,
|
||||
/// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences ›
|
||||
/// Highlight Alternate Glyphs.
|
||||
alternate_glyphs: BezPath,
|
||||
/// Glyphs OpenType may have substituted (a ligature, or not the face's cmap glyph for their
|
||||
/// character), each with its box: what Preferences › Highlight Alternate Glyphs weighs
|
||||
/// ([`TextGeom::alternate_glyphs`]).
|
||||
alternate_candidates: Vec<AlternateCandidate>,
|
||||
/// The boxes of the candidates that are alternates, measured the first time the preference
|
||||
/// shows them.
|
||||
alternate_glyphs: std::sync::OnceLock<BezPath>,
|
||||
/// Inline graphics placed by the layout.
|
||||
inlines: Vec<vectorcraft_text::InlineGlyph>,
|
||||
}
|
||||
@@ -2279,10 +2287,32 @@ impl TextGeom {
|
||||
std::borrow::Cow::Owned(p)
|
||||
}
|
||||
|
||||
/// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features
|
||||
/// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping
|
||||
/// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the
|
||||
/// lam-alef ligature are what the script needs, not alternates. Measured once.
|
||||
fn alternate_glyphs(&self, t: &TextObject) -> &BezPath {
|
||||
self.alternate_glyphs.get_or_init(|| {
|
||||
let mut out = BezPath::new();
|
||||
if self.alternate_candidates.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let required = required_shaping(t);
|
||||
for c in &self.alternate_candidates {
|
||||
if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) {
|
||||
out.extend(c.cell.iter());
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Approximate heap bytes, for the cache's budget.
|
||||
fn bytes(&self) -> usize {
|
||||
size_of::<Self>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ self.alternate_candidates.iter().map(|c| size_of::<AlternateCandidate>() + lru::path_bytes(&c.cell)).sum::<usize>()
|
||||
+ self.alternate_glyphs.get().map_or(0, lru::path_bytes)
|
||||
+ self.spans.len() * size_of::<(usize, usize, usize)>()
|
||||
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
|
||||
}
|
||||
@@ -2294,6 +2324,35 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c
|
||||
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
|
||||
}
|
||||
|
||||
/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its
|
||||
/// box.
|
||||
#[derive(Debug)]
|
||||
struct AlternateCandidate {
|
||||
byte: usize,
|
||||
len: usize,
|
||||
font_id: u32,
|
||||
gid: u32,
|
||||
cell: BezPath,
|
||||
}
|
||||
|
||||
/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font,
|
||||
/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and
|
||||
/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary
|
||||
/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`,
|
||||
/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets.
|
||||
fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> {
|
||||
let mut plain = t.clone();
|
||||
for run in &mut plain.runs {
|
||||
run.style.features = vec!["-liga".into(), "-calt".into()];
|
||||
}
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain);
|
||||
let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new();
|
||||
for g in &layout.glyphs {
|
||||
glyphs.entry((g.byte, g.len)).or_default().push((g.font_id, g.gid));
|
||||
}
|
||||
glyphs
|
||||
}
|
||||
|
||||
fn text_geom(t: &TextObject) -> TextGeom {
|
||||
text_geom_snapped(t, None)
|
||||
}
|
||||
@@ -2334,7 +2393,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new());
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||||
let plain = t.plain_text();
|
||||
for g in &layout.glyphs {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
@@ -2356,14 +2415,23 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
let Some(first) = cluster.chars().next() else { continue };
|
||||
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
|
||||
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
|
||||
alternate_glyphs.extend(cell.iter());
|
||||
alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell });
|
||||
}
|
||||
}
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
push(&mut all, run, &bar);
|
||||
}
|
||||
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines }
|
||||
TextGeom {
|
||||
all,
|
||||
spans,
|
||||
bounds: layout.bounds,
|
||||
substituted_fonts,
|
||||
substituted_glyphs,
|
||||
alternate_candidates,
|
||||
alternate_glyphs: std::sync::OnceLock::new(),
|
||||
inlines: layout.inlines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only).
|
||||
|
||||
@@ -40,3 +40,118 @@ fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
|
||||
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
|
||||
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
|
||||
}
|
||||
|
||||
/// The number of glyph boxes Highlight Alternate Glyphs paints for `t`.
|
||||
fn alternates(t: &vectorcraft_doc::text::TextObject) -> usize {
|
||||
text_geom(t).alternate_glyphs(t).elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count()
|
||||
}
|
||||
|
||||
/// An installed font that shapes Arabic with OpenType (as `vectorcraft-text`'s bidi tests pick
|
||||
/// one): Arial or Geeza Pro on macOS and Windows, Noto Sans Arabic on Linux. No font is kept in
|
||||
/// the repository, so the tests that need one say so and stop without it.
|
||||
fn arabic_style(test: &str) -> Option<vectorcraft_doc::text::CharStyle> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = ["Arial", "Noto Sans Arabic", "Geeza Pro"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular"))
|
||||
.find(|f| ['خ', 'ل', 'ا', 'م', 'ي'].iter().all(|c| f.covers(*c)));
|
||||
match face {
|
||||
Some(f) => {
|
||||
eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", f.family, f.style);
|
||||
Some(vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() })
|
||||
}
|
||||
None => {
|
||||
eprintln!("SKIPPED: {test}; no installed font covers Arabic");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text(s: &str, style: vectorcraft_doc::text::CharStyle) -> vectorcraft_doc::text::TextObject {
|
||||
vectorcraft_doc::text::TextObject::point(vectorcraft_geom::Point::ZERO, s, style)
|
||||
}
|
||||
|
||||
/// The glyphs of `t` by cluster, as the layout shapes them.
|
||||
fn glyphs(t: &vectorcraft_doc::text::TextObject) -> Vec<(usize, usize, u32)> {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (g.byte, g.len, g.gid)).collect()
|
||||
}
|
||||
|
||||
/// #394: Arabic letters take their joining forms (`init`, `medi`, `fina`, `isol`) in every word:
|
||||
/// required shaping, not alternates, so ordinary Arabic text shows no pink.
|
||||
#[test]
|
||||
fn arabic_joining_forms_are_not_alternates() {
|
||||
let Some(style) = arabic_style("arabic_joining_forms_are_not_alternates") else { return };
|
||||
let t = text("خلطة مبتكرة من اللومي الحساوي", style);
|
||||
// The words are shaped: most glyphs are not their letter's cmap glyph.
|
||||
assert!(!text_geom(&t).alternate_candidates.is_empty(), "the shaper joins the letters");
|
||||
assert_eq!(alternates(&t), 0);
|
||||
}
|
||||
|
||||
/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either.
|
||||
#[test]
|
||||
fn lam_alef_is_not_an_alternate() {
|
||||
let Some(style) = arabic_style("lam_alef_is_not_an_alternate") else { return };
|
||||
for s in ["لا", "سلام", "الاسم"] {
|
||||
assert_eq!(alternates(&text(s, style.clone())), 0, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or
|
||||
/// discretionary alternate…) is highlighted on the glyphs it changes, and only on those.
|
||||
#[test]
|
||||
fn optional_features_on_arabic_flag_only_the_glyphs_they_change() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let s = "خلطة مبتكرة من اللومي الحساوي";
|
||||
// Arabic fonts that may have optional features; the first installed one with a feature that
|
||||
// changes a glyph of the text.
|
||||
let fonts = ["Amiri", "El Messiri", "Noto Naskh Arabic", "Scheherazade New", "Arial", "Noto Sans Arabic", "Geeza Pro"];
|
||||
let tags = ["swsh", "salt", "dlig", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10"];
|
||||
let found = fonts.into_iter().filter_map(|name| db.face(name, "Regular")).filter(|f| f.covers('خ') && f.covers('ل')).find_map(|f| {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() };
|
||||
let plain = glyphs(&text(s, style.clone()));
|
||||
tags.into_iter().find_map(|tag| {
|
||||
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
|
||||
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
|
||||
(changed > 0).then(|| (f.family.clone(), tag, t, changed))
|
||||
})
|
||||
});
|
||||
let Some((family, tag, t, changed)) = found else {
|
||||
eprintln!(
|
||||
"SKIPPED: optional_features_on_arabic_flag_only_the_glyphs_they_change; no installed Arabic font has an optional feature for this text"
|
||||
);
|
||||
return;
|
||||
};
|
||||
eprintln!("ASSERTIONS RUN: optional_features_on_arabic_flag_only_the_glyphs_they_change; {family} with {tag}, {changed} glyphs changed");
|
||||
assert_eq!(alternates(&t), changed, "{family} {tag}: the glyphs it changed");
|
||||
}
|
||||
|
||||
/// Latin keeps its highlight: the `ffi` of "office" is a standard ligature (`liga`).
|
||||
#[test]
|
||||
fn a_latin_standard_ligature_is_still_an_alternate() {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
let t = text("office", style.clone());
|
||||
let shaped = glyphs(&t);
|
||||
assert!(shaped.iter().any(|(_, len, _)| *len > 1), "Source Serif 4 ligates ffi: {shaped:?}");
|
||||
assert!(alternates(&t) >= 1);
|
||||
// Without ligatures nothing is substituted.
|
||||
assert_eq!(alternates(&text("office", vectorcraft_doc::text::CharStyle { features: vec!["-liga".into()], ..style })), 0);
|
||||
}
|
||||
|
||||
/// #394: in a font the app ships (Source Serif 4), an optional feature turned on for the
|
||||
/// characters is highlighted on exactly the glyphs it changes, the others staying clear.
|
||||
#[test]
|
||||
fn an_optional_feature_flags_only_the_glyphs_it_changes() {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
// No ligature in it, so the default features substitute nothing.
|
||||
let s = "Quay 1234 agy";
|
||||
assert_eq!(alternates(&text(s, style.clone())), 0);
|
||||
let plain = glyphs(&text(s, style.clone()));
|
||||
let tags = ["onum", "smcp", "salt", "swsh", "ss01", "ss02", "ss03", "ss04", "ordn", "sups"];
|
||||
let found = tags.into_iter().find_map(|tag| {
|
||||
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
|
||||
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
|
||||
(changed > 0 && changed < plain.len()).then_some((tag, t, changed))
|
||||
});
|
||||
let (tag, t, changed) = found.expect("Source Serif 4 has an optional feature that changes some of these glyphs");
|
||||
assert_eq!(alternates(&t), changed, "{tag}: the glyphs it changed");
|
||||
}
|
||||
@@ -93,10 +93,11 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let font_axes = [("wght".to_string(), 650.0)].into_iter().collect();
|
||||
let style = CharStyle { size: 40.0, font_family: TWO_AXIS_FAMILY.into(), font_axes, ..Default::default() };
|
||||
let g = text_geom(&TextObject::point(Point::new(10.0, 60.0), "lIn", style));
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "lIn", style);
|
||||
let g = text_geom(&t);
|
||||
assert!(!g.all.elements().is_empty());
|
||||
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
|
||||
assert!(g.alternate_glyphs.elements().is_empty());
|
||||
assert!(g.alternate_glyphs(&t).elements().is_empty());
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
|
||||
@@ -1651,7 +1651,11 @@ impl Writer<'_> {
|
||||
return 0;
|
||||
}
|
||||
let bounds = n.visual_bounds_with(&self.doc.symbol_boxes());
|
||||
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
|
||||
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
|
||||
let grow = 18.0 / self.doc.raster_ppi();
|
||||
let reach: f64 =
|
||||
fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::<f64>()
|
||||
+ 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
@@ -1691,9 +1695,10 @@ impl Writer<'_> {
|
||||
self.num(*dy),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
// The glow blurs a solid silhouette: alpha × 255 makes any alpha full.
|
||||
// The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel.
|
||||
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feMorphology operator=\"dilate\" radius=\"{}\"/><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
fmt_num(grow, 3),
|
||||
sd(*blur),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction};
|
||||
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, MorphologyOperator, TransferFunction};
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::Effect;
|
||||
@@ -177,8 +177,9 @@ fn color(c: usvg::Color) -> Color {
|
||||
Color::rgb8(c.red, c.green, c.blue)
|
||||
}
|
||||
|
||||
/// Evaluate one filter's primitives; `None` when one does something the effects can't.
|
||||
fn eval(f: &Filter) -> Option<V> {
|
||||
/// Evaluate one filter's primitives, `k` the scale from the filter's user space to the document;
|
||||
/// `None` when one does something the effects can't.
|
||||
fn eval(f: &Filter, k: f64) -> Option<V> {
|
||||
let mut results: HashMap<&str, V> = HashMap::new();
|
||||
let mut last = V::Graphic;
|
||||
for p in f.primitives() {
|
||||
@@ -238,6 +239,18 @@ fn eval(f: &Filter) -> Option<V> {
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// Our Outer Glow's silhouette grown by a quarter raster pixel (`export`): a dilation of a
|
||||
// plain silhouette by up to that much at 72 ppi is that step.
|
||||
Kind::Morphology(m)
|
||||
if m.operator() == MorphologyOperator::Dilate
|
||||
&& m.radius_x().get() == m.radius_y().get()
|
||||
&& f64::from(m.radius_x().get()) * k <= 0.25 + 1e-6 =>
|
||||
{
|
||||
match input(m.input())? {
|
||||
V::Alpha(s) if s.plain() => V::Alpha(s),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => {
|
||||
let shape =
|
||||
Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false };
|
||||
@@ -264,7 +277,7 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
let blur = |sd: f64| num(2.0 * sd * k);
|
||||
let mode = blends.get(f.id()).map_or("normal", String::as_str);
|
||||
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
|
||||
let (id, params) = match eval(f)? {
|
||||
let (id, params) = match eval(f, k)? {
|
||||
V::Graphic => continue,
|
||||
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
|
||||
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
|
||||
|
||||
@@ -55,6 +55,13 @@ fn every_style_is_written_with_its_numeric_weight() {
|
||||
("Extrabold", Some("800")),
|
||||
("Black", Some("900")),
|
||||
("Heavy", Some("900")),
|
||||
// Japanese faces' numbered weights (#2873); W4 is the regular weight.
|
||||
("W0", Some("100")),
|
||||
("W3", Some("300")),
|
||||
("W4", None),
|
||||
("W6", Some("600")),
|
||||
("W9", Some("900")),
|
||||
("Wide", None),
|
||||
] {
|
||||
let want = want.map(str::to_string);
|
||||
assert_eq!(weights(style), (want.clone(), want), "{style}");
|
||||
|
||||
@@ -43,6 +43,10 @@ impl CraftFont {
|
||||
pub fn is_thai(&self) -> bool {
|
||||
self.scripts.contains(&"Thai")
|
||||
}
|
||||
/// Is the font for Georgian text?
|
||||
pub fn is_georgian(&self) -> bool {
|
||||
self.scripts.contains(&"Geor")
|
||||
}
|
||||
/// A serif (Mincho) family, for document text.
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
@@ -72,8 +76,8 @@ fn japanese_document_fonts_from(fonts: &[CraftFont]) -> Vec<&CraftFont> {
|
||||
/// The craft-fonts faces the document font database loads: the Japanese ones in
|
||||
/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic
|
||||
/// first: the fallback for Arabic text; the other families are picked by name), then the Thai ones
|
||||
/// (Noto Sans Thai: the fallback for Thai text where no Thai font is installed, and on the web).
|
||||
/// Remaining faces follow in manifest order, so every explicitly supplied family can be selected.
|
||||
/// (Noto Sans Thai: the fallback for Thai text where no Thai font is installed, and on the web),
|
||||
/// then the Georgian ones (Noto Sans Georgian, the same for Georgian). Remaining faces follow in manifest order, so every explicitly supplied family can be selected.
|
||||
/// Empty when built without craft-fonts.
|
||||
pub fn document_fonts() -> Vec<&'static CraftFont> {
|
||||
document_fonts_from(CRAFT_FONTS)
|
||||
@@ -83,10 +87,18 @@ fn document_fonts_from(fonts: &[CraftFont]) -> Vec<&CraftFont> {
|
||||
let mut v = japanese_document_fonts_from(fonts);
|
||||
v.extend(fonts.iter().filter(|f| f.is_arabic() && !f.is_japanese()));
|
||||
v.extend(fonts.iter().filter(|f| f.is_thai() && !f.is_japanese() && !f.is_arabic()));
|
||||
v.extend(fonts.iter().filter(|f| !f.is_japanese() && !f.is_arabic() && !f.is_thai()));
|
||||
v.extend(fonts.iter().filter(|f| f.is_georgian() && !f.is_japanese() && !f.is_arabic() && !f.is_thai()));
|
||||
v.extend(fonts.iter().filter(|f| !f.is_japanese() && !f.is_arabic() && !f.is_thai() && !f.is_georgian()));
|
||||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Georgian UI text (Noto Sans Georgian), in manifest order. The UI fonts
|
||||
/// have no Georgian letters. Empty when built without craft-fonts, and on the web, which fetches
|
||||
/// the face at startup (`web-fonts.txt`) instead of embedding it.
|
||||
pub fn georgian_ui_fonts() -> Vec<&'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.is_georgian() && !f.is_japanese()).collect()
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
|
||||
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
|
||||
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
@@ -110,6 +122,7 @@ mod tests {
|
||||
font("Arabic", "Regular", &["Arab"]),
|
||||
font("Mincho", "Regular", &["Jpan", "Hans"]),
|
||||
font("Thai only", "Regular", &["Thai"]),
|
||||
font("Georgian", "Regular", &["Geor"]),
|
||||
font("Latin", "Regular", &["Latn"]),
|
||||
];
|
||||
let ordered: Vec<_> = document_fonts_from(&fonts).into_iter().map(|f| (f.family, f.style)).collect();
|
||||
@@ -122,6 +135,7 @@ mod tests {
|
||||
("Thai", "Regular"),
|
||||
("Arabic", "Regular"),
|
||||
("Thai only", "Regular"),
|
||||
("Georgian", "Regular"),
|
||||
("Chinese", "Regular"),
|
||||
("Latin", "Regular"),
|
||||
]
|
||||
@@ -150,10 +164,11 @@ mod tests {
|
||||
assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url);
|
||||
assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity);
|
||||
}
|
||||
// The fallbacks the first frame may need: Noto Sans Arabic and Noto Sans Thai, where the
|
||||
// checkout has them.
|
||||
// The fallbacks the first frame may need: Noto Sans Arabic, Noto Sans Thai and Noto Sans
|
||||
// Georgian, where the checkout has them.
|
||||
let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect();
|
||||
let expected: Vec<_> = ["Noto Sans Arabic", "Noto Sans Thai"].into_iter().filter(|n| CRAFT_FONTS.iter().any(|f| f.family == *n)).collect();
|
||||
let expected: Vec<_> =
|
||||
["Noto Sans Arabic", "Noto Sans Thai", "Noto Sans Georgian"].into_iter().filter(|n| CRAFT_FONTS.iter().any(|f| f.family == *n)).collect();
|
||||
assert_eq!(startup, expected);
|
||||
}
|
||||
}
|
||||
@@ -1568,8 +1568,16 @@ fn norm(s: &str) -> String {
|
||||
norm_chars(s).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name (400 = regular).
|
||||
/// Weight implied by a style name (400 = regular). Japanese faces number their weights W0–W9
|
||||
/// (Hiragino, Morisawa…), W6 weighing 600 as its weight class does (#2873).
|
||||
pub fn style_weight(style: &str) -> f32 {
|
||||
let numbered = style.split(|c: char| !c.is_ascii_alphanumeric()).find_map(|part| match part.as_bytes() {
|
||||
[b'W' | b'w', d @ b'0'..=b'9'] => Some(f32::from(d - b'0') * 100.0),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(w) = numbered {
|
||||
return w.max(100.0);
|
||||
}
|
||||
let s = norm(style);
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("extralight", 200.0),
|
||||
@@ -1611,6 +1619,12 @@ impl FontDb {
|
||||
Self::new(FontPaths::Fixed(font_dirs))
|
||||
}
|
||||
|
||||
/// Same scan paths as [`FontDb::global`] (platform lister and font folders), for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_system_scan_test() -> Self {
|
||||
Self::new(FontPaths::System)
|
||||
}
|
||||
|
||||
fn new(font_paths: FontPaths) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
// Bundled fonts first, then explicitly supplied craft-fonts faces: Japanese (Mincho
|
||||
|
||||
@@ -557,6 +557,8 @@ mod tests_fit;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_joiners;
|
||||
#[cfg(test)]
|
||||
mod tests_justify;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
|
||||
@@ -392,7 +392,8 @@ pub(crate) fn shape_range(
|
||||
let next_script = shaping_script(c);
|
||||
let strong_script = !matches!(next_script, Script::Common | Script::Inherited);
|
||||
let script_change = strong_script && script != Script::Common && script != next_script;
|
||||
// Combining marks stay with their base.
|
||||
// Combining marks, zero width joiners and non-joiners, and variation selectors stay
|
||||
// with the character before them.
|
||||
if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) {
|
||||
if i > seg.range.start {
|
||||
seg.range.end = i;
|
||||
@@ -580,7 +581,7 @@ struct Shaped {
|
||||
fn is_mark(c: char) -> bool {
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|
||||
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|
||||
|| matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|
||||
}
|
||||
|
||||
/// Shape `seg` and append its glyphs to `out`. Each glyph takes the tracking, kerning, baseline
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! A zero width joiner or non-joiner between characters that the style's font lacks is shaped with
|
||||
//! those characters, in their fallback face. The Unicode Standard describes both joiners under
|
||||
//! Cursive Connection and Ligatures
|
||||
//! (https://www.unicode.org/versions/latest/core-spec/chapter-23/), and Microsoft's OpenType shaping
|
||||
//! specifications for Devanagari and Bengali include them in a syllable after a halant
|
||||
//! (https://learn.microsoft.com/en-us/typography/script-development/devanagari,
|
||||
//! https://learn.microsoft.com/en-us/typography/script-development/bengali).
|
||||
//! No font is committed: the tests use installed faces and print SKIPPED when none is found.
|
||||
use super::*;
|
||||
use kurbo::Point;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
const ZWNJ: char = '\u{200C}';
|
||||
const ZWJ: char = '\u{200D}';
|
||||
|
||||
/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`.
|
||||
/// With `tags`, the fallback face's GSUB script list must contain one of those script tags. Which
|
||||
/// face the fallback gives depends on the faces loaded so far, so the tests compare with that face.
|
||||
fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc<FontFace>, Arc<FontFace>)> {
|
||||
let db = FontDb::global();
|
||||
let opentype = |face: &FontFace| {
|
||||
use skrifa::raw::TableProvider;
|
||||
tags.is_empty()
|
||||
|| face
|
||||
.skrifa()
|
||||
.and_then(|f| f.gsub().ok())
|
||||
.and_then(|gsub| gsub.script_list().ok())
|
||||
.is_some_and(|list| list.script_records().iter().any(|r| tags.contains(&&r.script_tag().to_be_bytes())))
|
||||
};
|
||||
let faces = ["Source Sans 3", "Helvetica", "Arial", "Times New Roman", "Liberation Sans", "DejaVu Sans"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
|
||||
.filter(|primary| primary.covers(ZWNJ) && primary.covers(ZWJ) && !primary.covers(c))
|
||||
.find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| opentype(f)).map(|fallback| (primary, fallback)));
|
||||
if let Some((primary, fallback)) = &faces {
|
||||
let path = fallback.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string());
|
||||
eprintln!("ASSERTIONS RUN: {test}; {} {} with the fallback {} {} ({path})", primary.family, primary.style, fallback.family, fallback.style);
|
||||
} else {
|
||||
eprintln!("SKIPPED: {test}; no installed Latin face maps both joiners and falls back to a face for {c} with the lookups it needs");
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
fn layout_in(face: &FontFace, text: &str, tracking: f64) -> TextLayout {
|
||||
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, tracking, ..CharStyle::default() };
|
||||
layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st))
|
||||
}
|
||||
|
||||
/// `text` set in `primary` is drawn, spaced and stepped through exactly as `text` set in `fallback`.
|
||||
fn assert_shaped_as_in_the_fallback(primary: &FontFace, fallback: &FontFace, text: &str) {
|
||||
for tracking in [0.0, 200.0] {
|
||||
let (mixed, single) = (layout_in(primary, text, tracking), layout_in(fallback, text, tracking));
|
||||
// The caret stops where it stops in the fallback face.
|
||||
assert_eq!(caret_horizontal(&mixed, 0, true), caret_horizontal(&single, 0, true), "{text:?} with tracking {tracking}");
|
||||
// Tracking adds no extra space around the joiner.
|
||||
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
|
||||
assert_eq!(width(&mixed), width(&single), "{text:?} with tracking {tracking}");
|
||||
let glyphs = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.len, g.origin, g.advance)).collect::<Vec<_>>();
|
||||
assert_eq!(glyphs(&mixed), glyphs(&single), "{text:?} with tracking {tracking}");
|
||||
assert!(mixed.glyphs.iter().all(|g| g.font_id == fallback.id()), "{text:?}: {:?}", mixed.glyphs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() {
|
||||
let test = "a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it";
|
||||
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[]) else { return };
|
||||
// Ka + halant + ZWNJ + Ssa: Ka keeps its explicit halant instead of forming the KaSsa conjunct.
|
||||
let s = format!("क्{ZWNJ}ष");
|
||||
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() {
|
||||
let test = "a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it";
|
||||
let Some((primary, fallback)) = joiner_test_faces(test, 'ক', &[]) else { return };
|
||||
let s = format!("ক্{ZWNJ}ষ");
|
||||
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_width_joiner_in_devanagari_asks_for_a_half_form() {
|
||||
let test = "a_zero_width_joiner_in_devanagari_asks_for_a_half_form";
|
||||
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[b"dev2", b"deva"]) else { return };
|
||||
// Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa.
|
||||
let s = format!("क्{ZWJ}ष");
|
||||
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
|
||||
let l = layout_in(&primary, &s, 0.0);
|
||||
let half = l.glyphs.first().map(|g| g.gid);
|
||||
assert!(half.is_some_and(|gid| gid != fallback.glyph_for('क') && gid != fallback.glyph_for('\u{094D}')), "no half form: {:?}", l.glyphs);
|
||||
assert_eq!(l.glyphs.last().map(|g| g.gid), Some(fallback.glyph_for('ष')), "{:?}", l.glyphs);
|
||||
}
|
||||
@@ -332,18 +332,30 @@ fn service_font() -> PathBuf {
|
||||
}
|
||||
|
||||
/// The fonts a font service (Adobe Fonts on Windows, through DirectWrite) loads in place are
|
||||
/// cataloged: named by the platform's lister, read whatever their file is called (#579).
|
||||
/// cataloged: named by the platform's lister, read whatever their file is called (#579), and one it
|
||||
/// activates after a scan is picked up by the next check (#2576). One test: the lister is set once
|
||||
/// per process, so two tests changing what it lists would race.
|
||||
#[test]
|
||||
fn fonts_the_platform_lists_outside_the_font_folders_are_cataloged() {
|
||||
use std::sync::Mutex;
|
||||
static FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let list = |files: Vec<String>| *FILES.lock().unwrap_or_else(|e| e.into_inner()) = files;
|
||||
set_platform_font_files(|| FILES.lock().unwrap_or_else(|e| e.into_inner()).clone());
|
||||
let file = service_font();
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let _ = std::fs::remove_file(&file);
|
||||
// Nothing listed at the first scan; then a font service activates a font.
|
||||
let db = FontDb::for_system_scan_test();
|
||||
assert!(!db.installed_fonts_changed());
|
||||
db.load_system_fonts();
|
||||
assert!(!db.has_family("Service Sans3"));
|
||||
std::fs::write(&file, renamed_to("SourceSans3-Regular.ttf", "Service Sans3")).unwrap();
|
||||
// The lister's files outside the font folders are scanned with them, each once (a collection
|
||||
// lists its file once per face).
|
||||
fn lister() -> Vec<String> {
|
||||
vec![service_font().to_string_lossy().into_owned(); 2]
|
||||
}
|
||||
set_platform_font_files(lister);
|
||||
// A collection lists its file once per face.
|
||||
list(vec![file.to_string_lossy().into_owned(); 2]);
|
||||
assert!(db.installed_fonts_changed(), "a font a font service activated since the scan");
|
||||
db.load_system_fonts();
|
||||
assert!(db.has_family("Service Sans3"));
|
||||
// The lister's files outside the font folders are scanned with them, each once.
|
||||
let dirs = system_font_dirs();
|
||||
assert_eq!(dirs.iter().filter(|d| **d == file).count(), 1, "{dirs:?}");
|
||||
// A file named by itself is read without a font's extension, one in a folder isn't.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook).
|
||||
startup | Noto Sans Arabic | Regular
|
||||
startup | Noto Sans Thai | Regular
|
||||
startup | Noto Sans Georgian | Regular
|
||||
background | Cairo | Regular
|
||||
background | Tajawal | ExtraLight
|
||||
background | Tajawal | Light
|
||||
|
||||
@@ -392,6 +392,16 @@ shown_when = [
|
||||
hotspot = "The centre."
|
||||
designer = false
|
||||
|
||||
[[cursor]]
|
||||
id = "Busy"
|
||||
group = "Navigation"
|
||||
shown_when = [
|
||||
"A file is opening off the UI thread.",
|
||||
"A background save or export is in progress (the status bar shows its name).",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
designer = false
|
||||
|
||||
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
|
||||
@@ -27,6 +27,9 @@ pub struct GestureTool {
|
||||
began: bool,
|
||||
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
|
||||
extend: Option<(NodeId, bool)>,
|
||||
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
|
||||
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
||||
redraw: Option<(NodeId, f64)>,
|
||||
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points.
|
||||
@@ -66,6 +69,7 @@ impl GestureTool {
|
||||
active: false,
|
||||
began: false,
|
||||
extend: None,
|
||||
redraw: None,
|
||||
fidelity,
|
||||
size: 10.0,
|
||||
fill: false,
|
||||
@@ -143,6 +147,18 @@ impl GestureTool {
|
||||
None
|
||||
}
|
||||
|
||||
/// The selected path whose outline is near `p` (Edit Selected Paths, off at 0 pixels).
|
||||
fn find_redraw(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, f64)> {
|
||||
let tol = cx.tol(self.edit_within);
|
||||
if self.edit_within <= 0.0 || !tol.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cx.selection.objects.iter().find_map(|id| {
|
||||
let pd = cx.doc.node(*id).and_then(|n| n.path_data()).filter(|_| cx.doc.is_editable(*id))?;
|
||||
pd.nearest(p).filter(|n| n.4 <= tol).map(|_| (*id, tol))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parameters of `path.freehand` for the current stroke.
|
||||
pub fn freehand_params(&self, closed: bool) -> Value {
|
||||
let mut v = json!({
|
||||
@@ -155,6 +171,8 @@ impl GestureTool {
|
||||
});
|
||||
if let Some((id, start)) = self.extend {
|
||||
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
|
||||
} else if let Some((id, within)) = self.redraw {
|
||||
v["redraw"] = json!({"id": id.0, "within": within});
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -211,8 +229,9 @@ impl Tool for GestureTool {
|
||||
self.active = true;
|
||||
self.began = false;
|
||||
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
|
||||
// A path continued starts at its end; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.redraw = if self.draws_path() && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
|
||||
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.clear();
|
||||
self.push(start, ev.pressure);
|
||||
vec![]
|
||||
@@ -260,6 +279,7 @@ impl Tool for GestureTool {
|
||||
self.began = false;
|
||||
self.clear();
|
||||
self.extend = None;
|
||||
self.redraw = None;
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod guides;
|
||||
pub mod intertwine;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod mirror;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
pub mod pen;
|
||||
@@ -493,6 +494,8 @@ pub enum Cursor {
|
||||
ZoomOut,
|
||||
Eyedropper,
|
||||
NotAllowed,
|
||||
/// A long operation (opening a file, a background save or export): the OS wait cursor.
|
||||
Busy,
|
||||
/// Over the gradient annotator's bar: a click adds a stop.
|
||||
AddStop,
|
||||
/// A gradient stop dragged off the bar: releasing deletes it.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Mirror Repeat's on-canvas axis. Drawing and hit testing share document-space geometry;
|
||||
//! gestures preview the existing Repeat Options command and commit as one undo step.
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, Selection, pattern::RepeatKind};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use crate::{Action, Cursor, Mods, ToolContext};
|
||||
|
||||
pub const HANDLE_RADIUS: f32 = 6.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MirrorAxis {
|
||||
pub id: NodeId,
|
||||
pub center: Point,
|
||||
pub angle: f64,
|
||||
pub ends: [Point; 2],
|
||||
}
|
||||
|
||||
impl MirrorAxis {
|
||||
/// A single editable mirror repeat selected whole. Multi-selection keeps ordinary transforms.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let [id] = selection.objects.as_slice() else { return None };
|
||||
if !selection.anchors.is_empty() || !selection.segments.is_empty() || !doc.is_editable(*id) || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(*id)?;
|
||||
let NodeKind::Repeat(spec) = &n.kind else { return None };
|
||||
let RepeatKind::Mirror { angle, center } = spec.kind else { return None };
|
||||
if !angle.is_finite() || !finite(center) {
|
||||
return None;
|
||||
}
|
||||
let bounds = n.geometric_bounds_with(&doc.symbol_boxes())?;
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
let direction = Vec2::new(cos, sin);
|
||||
let mut extent = 0.0_f64;
|
||||
for p in
|
||||
[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)]
|
||||
{
|
||||
let along = (p - center).dot(direction);
|
||||
if !along.is_finite() {
|
||||
return None;
|
||||
}
|
||||
extent = extent.max(along.abs());
|
||||
}
|
||||
// Keep the center move handle separate from both rotation handles, even on tiny artwork.
|
||||
extent = (extent + 24.0 / zoom).max(36.0 / zoom);
|
||||
let ends = [center - direction * extent, center + direction * extent];
|
||||
ends.iter().all(|p| finite(*p)).then_some(Self { id: *id, center, angle, ends })
|
||||
}
|
||||
|
||||
fn hit(cx: &ToolContext, p: Point) -> Option<(Self, bool)> {
|
||||
if !cx.show_bbox || !finite(p) {
|
||||
return None;
|
||||
}
|
||||
let axis = Self::of(cx.doc, cx.selection, cx.zoom)?;
|
||||
if axis.ends.iter().any(|q| q.distance(p) <= cx.tol(9.0)) {
|
||||
return Some((axis, true));
|
||||
}
|
||||
// The center is its own move handle. Keep it ahead of bounding-box handles when the two
|
||||
// happen to overlap; the rest of the axis line yields to those handles below.
|
||||
if axis.center.distance(p) <= cx.tol(7.0) {
|
||||
return Some((axis, false));
|
||||
}
|
||||
let d = axis.ends[1] - axis.ends[0];
|
||||
let length = d.hypot();
|
||||
if !length.is_finite() || length <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let unit = d / length;
|
||||
let along = (p - axis.ends[0]).dot(unit).clamp(0.0, length);
|
||||
((axis.ends[0] + unit * along).distance(p) <= cx.tol(7.0) && !crate::select::bbox_handle_at(cx, p)).then_some((axis, false))
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite()
|
||||
}
|
||||
|
||||
pub fn cursor(cx: &ToolContext, p: Point) -> Option<Cursor> {
|
||||
let (axis, rotate) = MirrorAxis::hit(cx, p)?;
|
||||
Some(if rotate { Cursor::rotate_about_in_view(axis.center, p, cx.view_angle()) } else { Cursor::Move })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MirrorDrag {
|
||||
axis: MirrorAxis,
|
||||
start: Point,
|
||||
rotate: bool,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl MirrorDrag {
|
||||
pub fn hit(cx: &ToolContext, p: Point, mods: Mods) -> Option<Self> {
|
||||
if mods.cmd || mods.ctrl || mods.alt {
|
||||
return None;
|
||||
}
|
||||
let (axis, rotate) = MirrorAxis::hit(cx, p)?;
|
||||
Some(Self { axis, start: p, rotate, began: false })
|
||||
}
|
||||
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Cursor {
|
||||
if self.rotate { Cursor::rotate_about_in_view(self.axis.center, p, cx.view_angle()) } else { Cursor::Move }
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||||
if !finite(p) || (!self.began && p.distance(self.start) < cx.tol(3.0)) {
|
||||
return vec![];
|
||||
}
|
||||
let (center, angle) = if self.rotate {
|
||||
let from = self.start - self.axis.center;
|
||||
let to = p - self.axis.center;
|
||||
if to.hypot() < cx.tol(2.0) {
|
||||
return vec![];
|
||||
}
|
||||
let mut angle = self.axis.angle + (to.y.atan2(to.x) - from.y.atan2(from.x)).to_degrees();
|
||||
if mods.shift {
|
||||
angle = (angle / 45.0).round() * 45.0;
|
||||
}
|
||||
(self.axis.center, angle.rem_euclid(360.0))
|
||||
} else {
|
||||
(self.axis.center + (p - self.start), self.axis.angle)
|
||||
};
|
||||
if !finite(center) || !angle.is_finite() {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Repeat Options".into()));
|
||||
}
|
||||
out.push(Action::Preview("object.repeat.options".into(), json!({"center": [center.x, center.y], "angle": angle})));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
pub fn cancel(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::{cx, doc_with_rect, paint};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::pattern::RepeatSpec;
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_hit_targets_match_zoom_and_reject_invalid_geometry() {
|
||||
let (mut doc, id) = doc_with_rect();
|
||||
let mut source = doc.node(id).unwrap().clone();
|
||||
source.id = doc.alloc_id();
|
||||
doc.node_mut(id).unwrap().kind = NodeKind::Repeat(RepeatSpec::mirror(vec![Arc::new(source)], 90.0, 10.0));
|
||||
let mut selection = Selection::default();
|
||||
selection.set([id]);
|
||||
let paint = paint();
|
||||
for zoom in [0.1, 1.0, 10.0] {
|
||||
let mut cx = cx(&doc, &selection, &paint);
|
||||
cx.zoom = zoom;
|
||||
let axis = MirrorAxis::of(&doc, &selection, zoom).unwrap();
|
||||
assert_eq!(cursor(&cx, axis.center), Some(Cursor::Move));
|
||||
for end in axis.ends {
|
||||
assert!(MirrorAxis::hit(&cx, end).unwrap().1);
|
||||
}
|
||||
assert!(MirrorAxis::hit(&cx, axis.center + Vec2::new(6.0 / zoom, 0.0)).is_some());
|
||||
assert!(MirrorAxis::hit(&cx, axis.center + Vec2::new(10.0 / zoom, 0.0)).is_none());
|
||||
let mut drag = MirrorDrag::hit(&cx, axis.center, Mods::default()).unwrap();
|
||||
assert!(drag.drag(&cx, Point::new(f64::NAN, 0.0), Mods::default()).is_empty());
|
||||
assert!(!drag.adjusting());
|
||||
cx.show_bbox = false;
|
||||
assert!(MirrorAxis::hit(&cx, axis.center).is_none());
|
||||
}
|
||||
for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(MirrorAxis::of(&doc, &selection, zoom).is_none());
|
||||
}
|
||||
let NodeKind::Repeat(spec) = &mut doc.node_mut(id).unwrap().kind else { panic!() };
|
||||
spec.kind = RepeatKind::Mirror { angle: f64::NAN, center: Point::ZERO };
|
||||
assert!(MirrorAxis::of(&doc, &selection, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,8 @@ enum State {
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a live ellipse's pie widget.
|
||||
Pie(PieDrag),
|
||||
/// Move or rotate a live mirror axis.
|
||||
Mirror(crate::mirror::MirrorDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type `from` by a click on its out port; `press`: where the
|
||||
@@ -294,6 +296,12 @@ fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
|
||||
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
|
||||
}
|
||||
|
||||
/// Whether the current selection's bounding-box handle is under `p`, using the same geometry as
|
||||
/// the Selection tool's press handling.
|
||||
pub(crate) fn bbox_handle_at(cx: &ToolContext, p: Point) -> bool {
|
||||
cx.show_bbox && selection_box(cx).is_some_and(|b| matches!(box_hit(cx, &b, p), Some(BoxHit::Handle(_))))
|
||||
}
|
||||
|
||||
/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides
|
||||
/// to label it while hovering.
|
||||
const HOVER_REACH: f64 = 6.0;
|
||||
@@ -390,6 +398,12 @@ impl Tool for SelectionTool {
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape
|
||||
&& matches!(self.state, State::Mirror(_))
|
||||
&& let State::Mirror(d) = std::mem::take(&mut self.state)
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
@@ -400,7 +414,7 @@ impl Tool for SelectionTool {
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Loaded { .. } => key == ToolKey::Escape,
|
||||
State::Loaded { .. } | State::Mirror(_) => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -410,6 +424,7 @@ impl Tool for SelectionTool {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
State::Corner(c) => c.adjusting(),
|
||||
State::Pie(d) => d.adjusting(),
|
||||
State::Mirror(d) => d.adjusting(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -420,6 +435,30 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return out;
|
||||
}
|
||||
// Mirror controls take precedence over art, bounding handles and ruler guides.
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(cx, ev.pos, ev.mods);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::Mirror(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::Mirror(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
@@ -693,6 +732,17 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
if let State::Mirror(d) = &self.state {
|
||||
return d.cursor(cx, p);
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& !m.cmd
|
||||
&& !m.ctrl
|
||||
&& !m.alt
|
||||
&& let Some(cursor) = crate::mirror::cursor(cx, p)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
match self.state {
|
||||
State::Rotating { center, .. } => return Cursor::rotate_about_in_view(center, p, cx.view_angle()),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle - cx.view_angle()),
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
|
||||
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
|
||||
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
|
||||
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
|
||||
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//! baseline (`text.createInPath`). While type is being edited, a click that lands on no type only
|
||||
//! ends the editing, and the next click makes new type. Point type placed by a click and left
|
||||
//! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new
|
||||
//! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//!
|
||||
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
|
||||
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
|
||||
@@ -271,14 +272,21 @@ impl TypeTool {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The button released after a press at `start` (new type goes at `at`).
|
||||
/// The button released after a press at `start` (new type goes at `at`). While type is being
|
||||
/// edited, a click that lands on no type only ends the editing; the next click makes new type
|
||||
/// (#2871). A drag that draws an area-type frame makes it either way.
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
}
|
||||
let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some());
|
||||
let mut out = self.finish(cx);
|
||||
let drag = self.drag.take();
|
||||
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
if was_editing && area.is_none() {
|
||||
return out;
|
||||
}
|
||||
// Area Type / Type on a Path: click a path.
|
||||
if self.mode != Mode::Type && drag.is_none() {
|
||||
let on_path = self.mode == Mode::OnPath;
|
||||
@@ -292,7 +300,6 @@ impl TypeTool {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let mut params = match area {
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
None => json!({"x": at.x, "y": at.y, "text": ""}),
|
||||
|
||||
@@ -401,6 +401,108 @@ fn escape_returns_to_previous_tool_when_set() {
|
||||
assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into())));
|
||||
}
|
||||
|
||||
/// A click from `tool` at (x, y) → the actions of the press and the release.
|
||||
fn click(tool: &mut TypeTool, c: &ToolContext, x: f64, y: f64) -> Vec<Action> {
|
||||
let mut acts = tool.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
acts.extend(tool.pointer(c, &PointerEvent::new(PointerKind::Up, x, y)));
|
||||
acts
|
||||
}
|
||||
|
||||
fn creates(acts: &[Action]) -> bool {
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, _) if cmd == "text.create" || cmd == "text.createInPath"))
|
||||
}
|
||||
|
||||
/// While type is being edited, a click on blank canvas ends the editing and makes no new type;
|
||||
/// the Type tool stays, and the next click makes point type (#2871, #2854). The same holds when
|
||||
/// the editing began with a double-click from a selection tool.
|
||||
#[test]
|
||||
fn a_click_away_from_the_type_being_edited_only_ends_editing() {
|
||||
for prev in [None, Some("selection")] {
|
||||
let (d, _, mut tool) = editing("Hello");
|
||||
if let Some(prev) = prev {
|
||||
tool.set_option("prevTool", &json!(prev));
|
||||
}
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let acts = click(&mut tool, &c, 400.0, 400.0);
|
||||
assert!(!creates(&acts), "{prev:?}: {acts:?}");
|
||||
assert!(!acts.iter().any(|a| matches!(a, Action::SwitchTool(_))), "{prev:?}: {acts:?}");
|
||||
assert_eq!((tool.id(), tool.editing), ("type", None));
|
||||
assert_eq!(tool.prev_tool.as_deref(), prev);
|
||||
let acts = click(&mut tool, &c, 400.0, 400.0);
|
||||
assert!(
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 400.0 && v.get("area").is_none())),
|
||||
"{acts:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type a click placed and left empty is still discarded by the click away, and that click
|
||||
/// places no other type.
|
||||
#[test]
|
||||
fn a_click_away_from_empty_new_type_discards_it_and_makes_none() {
|
||||
let (d, id, mut tool) = editing("");
|
||||
tool.fresh = Some(id);
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let acts = click(&mut tool, &cx(&d, &sel, &p), 400.0, 400.0);
|
||||
assert_eq!(acts, vec![Action::Exec("text.discardEmpty".into(), json!({"id": id.0}))]);
|
||||
}
|
||||
|
||||
/// Area Type and Type on a Path: a click on a path while other type is being edited ends the
|
||||
/// editing only; the next click turns the path into a frame or a baseline.
|
||||
#[test]
|
||||
fn a_click_on_a_path_while_editing_only_ends_editing() {
|
||||
let (mut d, rect) = crate::testutil::doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(300.0, 400.0), "Hello", CharStyle { size: 20.0, ..Default::default() });
|
||||
d.insert(Some(l), 1, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
for (tool_id, at) in [("areaType", (100.0, 150.0)), ("typeOnPath", (150.0, 100.0))] {
|
||||
let mut tool = TypeTool::new(tool_id);
|
||||
tool.start_editing(id, 0);
|
||||
let acts = click(&mut tool, &c, at.0, at.1);
|
||||
assert!(!creates(&acts), "{tool_id}: {acts:?}");
|
||||
assert_eq!(tool.editing, None);
|
||||
let acts = click(&mut tool, &c, at.0, at.1);
|
||||
assert!(
|
||||
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.createInPath" && v["path"] == json!(rect.0))),
|
||||
"{tool_id}: {acts:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// While type is being edited, a drag that draws an area-type frame still makes it, and a click
|
||||
/// on other type edits that type at once.
|
||||
#[test]
|
||||
fn a_drag_or_a_click_on_other_type_while_editing_acts_at_once() {
|
||||
let (mut d, id, mut tool) = editing("Hello");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let acts = {
|
||||
let c = cx(&d, &sel, &p);
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 400.0, 400.0));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 400.0, 400.0))
|
||||
};
|
||||
let area = json!({"width": 100.0, "height": 100.0});
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["area"] == area)), "{acts:?}");
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(100.0, 300.0), "Other", CharStyle { size: 20.0, ..Default::default() });
|
||||
d.insert(Some(l), 1, Node::new(other, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let c = cx(&d, &sel, &p);
|
||||
let t = TypeTool::text(&c, other).unwrap().clone();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
let (top, bot) = vectorcraft_text::caret_position(&lay, 2);
|
||||
let at = t.xf * top.midpoint(bot);
|
||||
tool.start_editing(id, 0);
|
||||
let acts = click(&mut tool, &c, at.x, at.y);
|
||||
assert!(!creates(&acts), "{acts:?}");
|
||||
assert!(acts.contains(&Action::Exec("select.set".into(), json!({"ids": [other.0]}))), "{acts:?}");
|
||||
assert_eq!(tool.editing, Some(other));
|
||||
}
|
||||
|
||||
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
|
||||
/// only the Type tools (#2323); text that fits shows none.
|
||||
#[test]
|
||||
|
||||
+348
-12
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
|
||||
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
|
||||
|
||||
use crate::opening;
|
||||
use crate::state::View;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
|
||||
@@ -503,6 +504,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.ui.view.edges && !revolve_edit {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||||
mirror_axis_overlay(app, &painter, &xf);
|
||||
if app.ui.view.text_threads {
|
||||
thread_overlay(app, &painter, &xf);
|
||||
}
|
||||
@@ -517,6 +519,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
|
||||
// at the canvas center, the point the view turns about.
|
||||
if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::RotateView { .. })) {
|
||||
rotate_view_dial(&painter, rect.center(), v.rotation, &t);
|
||||
}
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
if let Some(origin) = origin_preview {
|
||||
@@ -535,6 +542,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
artboard_name_editor(app, ui, &xf);
|
||||
// Cursor.
|
||||
if resp.hovered() {
|
||||
if opening::long_operation_busy(app) {
|
||||
ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy));
|
||||
return;
|
||||
}
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
|
||||
@@ -637,6 +648,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ZoomOut => C::ZoomOut,
|
||||
Cursor::Eyedropper => C::Crosshair,
|
||||
Cursor::NotAllowed => C::NotAllowed,
|
||||
Cursor::Busy => C::Progress,
|
||||
Cursor::AddStop => C::Copy,
|
||||
Cursor::RemoveStop => C::NotAllowed,
|
||||
Cursor::Slice => C::Crosshair,
|
||||
@@ -2266,6 +2278,52 @@ fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// High-contrast live controls, using the same geometry as the Selection tool's hit testing.
|
||||
fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let ends = axis.ends.map(|q| xf.to_screen(q));
|
||||
let center = xf.to_screen(axis.center);
|
||||
if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) {
|
||||
p.line_segment([a, b], Stroke::new(5.0, t.measure_bg));
|
||||
p.line_segment([a, b], Stroke::new(2.0, t.selection));
|
||||
}
|
||||
for end in ends {
|
||||
p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg);
|
||||
p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||||
}
|
||||
let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0));
|
||||
p.rect_filled(handle, 2.0, t.selection);
|
||||
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
/// The radius of the Rotate View dial, in screen points.
|
||||
const DIAL_RADIUS: f32 = 40.0;
|
||||
|
||||
/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a
|
||||
/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the
|
||||
/// angle under the center, rounded to whole degrees as in the status bar.
|
||||
fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) {
|
||||
// `dir(deg)` is the screen direction `deg` degrees clockwise from straight up.
|
||||
let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos());
|
||||
p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border));
|
||||
for i in 0..24u8 {
|
||||
let len = if i % 6 == 0 { 8.0 } else { 4.0 };
|
||||
let d = dir(f32::from(i) * 15.0);
|
||||
p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent));
|
||||
p.circle_filled(c, 2.5, t.accent);
|
||||
p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text);
|
||||
}
|
||||
|
||||
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||||
@@ -2385,16 +2443,16 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||||
}
|
||||
// Point type: baseline marker (area type shows its frame instead, which may not be a
|
||||
// rectangle, and type on a path its path).
|
||||
// Point type: each line's baseline, where a click selects it (`hit`), whatever its
|
||||
// alignment and rotation, and the anchor (area type shows its frame instead, which may
|
||||
// not be a rectangle, and type on a path its path).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||||
{
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
let b = n.geometric_bounds().unwrap_or_default();
|
||||
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
|
||||
p.line_segment([o, e], Stroke::new(1.0, color));
|
||||
p.circle_filled(o, 2.5, color);
|
||||
for (a, b) in tx.baselines() {
|
||||
p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color));
|
||||
}
|
||||
p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color);
|
||||
}
|
||||
}
|
||||
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
|
||||
@@ -3309,6 +3367,73 @@ mod tests {
|
||||
assert!(wrong.is_empty(), "drawn outside {clip:?}:\n{}", wrong.join("\n"));
|
||||
}
|
||||
|
||||
/// A group of four clip groups, each a placed image larger than the rounded square that clips
|
||||
/// it: one click with the Selection tool selects the group, which shows the four squares'
|
||||
/// outlines and no image's rectangle, and a bounding box that is the union of the squares.
|
||||
#[test]
|
||||
fn a_group_of_clipped_images_shows_its_masks_and_their_bounds() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let img = image::RgbaImage::from_pixel(160, 160, image::Rgba([40, 120, 200, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
// Masks 100 × 100 at x = 20, 150, 280, 410; each image reaches 30 past its mask on every side.
|
||||
let masks: Vec<Rect> = (0..4).map(|i| Rect::new(20.0 + 130.0 * i as f64, 100.0, 120.0 + 130.0 * i as f64, 200.0)).collect();
|
||||
let mut clips = vec![];
|
||||
for m in &masks {
|
||||
app.run("file.place", json!({"name": "photo.png", "dataBase64": data, "link": false, "at": [m.center().x, m.center().y]})).unwrap();
|
||||
let image = json!(app.session.active().unwrap().selection.objects[0].0);
|
||||
let mask = app.run("shape.rectangle", json!({"x": m.x0, "y": m.y0, "width": 100, "height": 100, "radius": 16})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [image, mask]})).unwrap();
|
||||
clips.push(app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone());
|
||||
}
|
||||
app.run("select.set", json!({"ids": clips})).unwrap();
|
||||
let group = vectorcraft_doc::NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let union = masks.iter().copied().reduce(|a, b| a.union(b)).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
// One click on a visible part of the second image selects the whole group.
|
||||
app.select_tool("selection");
|
||||
let view = app.view_info();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
app.session.pointer(&PointerEvent::new(kind, 200.0, 150.0), view).unwrap();
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![group]);
|
||||
// The bounding box, the bounds and the transform box fit the masks, not the images.
|
||||
let close =
|
||||
|r: Option<Rect>| r.is_some_and(|r| [r.x0 - union.x0, r.y0 - union.y0, r.x1 - union.x1, r.y1 - union.y1].iter().all(|d| d.abs() < 1e-9));
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(close(st.doc.node(group).unwrap().geometric_bounds()), "the group's bounds");
|
||||
assert!(close(app.selection_box().map(|b| b.rect)), "the bounding box");
|
||||
// Everything drawn in the layer's colour lies on a mask: on its outline, its anchors or the
|
||||
// bounding box round them (8 px for the anchors and the box's handles).
|
||||
let color = c32(app.session.active().unwrap().doc.layer_color(group));
|
||||
let screen = |r: Rect| egui::Rect::from_two_pos(xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1)));
|
||||
let drawn = drawn_in(&mut app, &ctx, color, 1.0);
|
||||
assert!(!drawn.is_empty());
|
||||
let near_a_mask = |q: &Pos2| masks.iter().any(|m| screen(*m).expand(8.0).contains(*q));
|
||||
let on_the_box = |q: &Pos2| {
|
||||
let b = screen(union);
|
||||
b.expand(8.0).contains(*q) && !b.shrink(8.0).contains(*q)
|
||||
};
|
||||
let stray: Vec<_> = drawn.iter().filter(|q| !near_a_mask(q) && !on_the_box(q)).collect();
|
||||
assert!(stray.is_empty(), "drawn off the masks: {stray:?}");
|
||||
// Each mask's outline is drawn: points on its left and right edges.
|
||||
for m in &masks {
|
||||
let r = screen(*m);
|
||||
let on = |x: f32| drawn.iter().any(|q| (q.x - x).abs() < 1.0 && q.y > r.min.y + 16.0 && q.y < r.max.y - 16.0);
|
||||
assert!(on(r.min.x) && on(r.max.x), "the outline of {m:?}");
|
||||
}
|
||||
// No image's rectangle: nothing along the images' top edge, 30 points above the masks.
|
||||
let top = xf.to_screen(Point::new(0.0, union.y0 - 30.0)).y;
|
||||
assert!(!drawn.iter().any(|q| (q.y - top).abs() < 0.5), "the images' top edge at y {top}");
|
||||
}
|
||||
|
||||
/// #2422: a clip group whose hidden art has more anchors than the overlay draws (#525) still
|
||||
/// shows its clipping path's anchors and hover highlight.
|
||||
#[test]
|
||||
@@ -3443,6 +3568,42 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The baseline marker of selected point type runs under the line, wherever its alignment
|
||||
/// puts the anchor: under centred and right-aligned type too, not from the anchor rightwards
|
||||
/// (#2879).
|
||||
#[test]
|
||||
fn point_type_baselines_run_under_the_line_whatever_its_alignment() {
|
||||
for justify in ["left", "center", "right"] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let made = app.run("text.create", json!({"x": 300, "y": 150, "text": "Baseline marker", "size": 24, "justify": justify})).unwrap();
|
||||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let st = app.session.active().unwrap();
|
||||
let b = st.doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
let color = c32(st.doc.layer_color(id));
|
||||
let y = xf.to_screen(Point::new(0.0, 150.0)).y;
|
||||
let (left, right) = (xf.to_screen(Point::new(b.x0, 150.0)).x, xf.to_screen(Point::new(b.x1, 150.0)).x);
|
||||
let on: Vec<f32> = shapes(&mut app, &ctx)
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == color => Some(points),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|q| (q.y - y).abs() < 0.5)
|
||||
.map(|q| q.x)
|
||||
.collect();
|
||||
let (lo, hi) = on.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
|
||||
assert!((lo - left).abs() < 3.0 && (hi - right).abs() < 3.0, "{justify}: the baseline runs {lo}..{hi}, the type {left}..{right}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A click with the Selection tool inside an artboard makes it the active one, and Paste in
|
||||
/// Place then pastes onto it, where the objects were on their own artboard (#693).
|
||||
#[test]
|
||||
@@ -4866,6 +5027,115 @@ mod tests {
|
||||
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_tracks_selection_options_and_view_without_editing_art() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||||
let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let axis = |app: &mut VectorcraftApp| {
|
||||
let color = Tokens::get(&ctx).selection;
|
||||
shapes(app, &ctx)
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let lines = axis(&mut app);
|
||||
assert_eq!(lines.len(), 1);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01);
|
||||
assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01);
|
||||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before);
|
||||
app.ui.view.edges = false;
|
||||
assert!(axis(&mut app).is_empty());
|
||||
app.ui.view.edges = true;
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
assert!(axis(&mut app).is_empty());
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(axis(&mut app), lines);
|
||||
app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap();
|
||||
let horizontal = axis(&mut app);
|
||||
assert_eq!(horizontal.len(), 1);
|
||||
assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&mut app), lines);
|
||||
app.view_mut().unwrap().rotation = 45.0;
|
||||
let rotated = axis(&mut app);
|
||||
let d = rotated[0][1] - rotated[0][0];
|
||||
assert!((d.x.abs() - d.y.abs()).abs() < 0.01);
|
||||
app.run("object.repeat.release", json!({})).unwrap();
|
||||
assert!(axis(&mut app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
app.view_mut().unwrap().zoom = 1.5;
|
||||
app.view_mut().unwrap().rotation = 30.0;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let axis = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap()
|
||||
};
|
||||
let initial = axis(&app);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let from = xf.to_screen(initial.center);
|
||||
let to = from + vec2(40.0, 15.0);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag");
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&app).center, initial.center);
|
||||
// Both rotation handles remain a usable size at this zoom and view angle.
|
||||
let drawn = shapes(&mut app, &ctx);
|
||||
for end in initial.ends {
|
||||
assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c)
|
||||
if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_bounding_box_handle_scales_a_default_mirror_repeat() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
|
||||
let initial = app.selection_bounds().unwrap();
|
||||
let bx = app.selection_box().unwrap();
|
||||
let top = bx.to_doc() * vectorcraft_tools::bbox::Handle::Top.pos(bx.rect);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let from = xf.to_screen(top);
|
||||
let to = from + vec2(0.0, -30.0);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
|
||||
let scaled = app.selection_bounds().unwrap();
|
||||
assert!(scaled.height() > initial.height() + 10.0, "Top handle should scale the repeat: {initial:?} -> {scaled:?}");
|
||||
assert!((scaled.width() - initial.width()).abs() < 0.01, "Top handle should not scale width: {initial:?} -> {scaled:?}");
|
||||
}
|
||||
|
||||
/// #1236: Curvature edits points, so existing paths show one circular marker per point and
|
||||
/// no direction handles; switching back to Direct Selection keeps its normal handles.
|
||||
#[test]
|
||||
@@ -5881,6 +6151,59 @@ mod tests {
|
||||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotate_view_drag_shows_a_dial_with_the_angle() {
|
||||
use egui::{Event, Modifiers};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool("rotateView");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
// `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's
|
||||
// direction in whole degrees clockwise from straight up, and the angle text.
|
||||
let dial = |app: &mut VectorcraftApp| {
|
||||
let s = shapes(app, &ctx);
|
||||
let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None });
|
||||
let needle = s.iter().find_map(|s| match s {
|
||||
Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => {
|
||||
Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let text =
|
||||
s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None });
|
||||
(circle, needle, text)
|
||||
};
|
||||
assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag");
|
||||
// The Rotate View tool: a drag from 0° to 30° about the canvas center.
|
||||
let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0));
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(right)]);
|
||||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]);
|
||||
assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string())));
|
||||
frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]);
|
||||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||||
assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view());
|
||||
// A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°.
|
||||
app.select_tool("rectangle");
|
||||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]);
|
||||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(below)]);
|
||||
assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string())));
|
||||
assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view());
|
||||
// Presentation Mode turned on during the drag hides the dial.
|
||||
let mode = app.ui.screen_mode;
|
||||
app.ui.screen_mode = 3;
|
||||
assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode");
|
||||
app.ui.screen_mode = mode;
|
||||
frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]);
|
||||
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]);
|
||||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
|
||||
use egui::{Event, Modifiers};
|
||||
@@ -6472,11 +6795,12 @@ mod tests {
|
||||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(300.0, 250.0)));
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0)));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||||
@@ -6484,17 +6808,29 @@ mod tests {
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||||
// told to drop its composition.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
// told to drop its composition. A press on other type edits that type at once.
|
||||
let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
assert_eq!(plain(&app, id), "しょうこ");
|
||||
assert!(!app.session.tool_composing());
|
||||
assert!(ime.expect("editing the new text").should_interrupt_composition);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
|
||||
assert!(ime.expect("editing the other text").should_interrupt_composition);
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||||
typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
// Interrupted once, not every frame.
|
||||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||||
assert!(!app.take_ime_discard());
|
||||
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
|
||||
// for no IME, and the host tells the system IME to drop its composition.
|
||||
let blank = xf.to_screen(Point::new(150.0, 200.0));
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
|
||||
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
|
||||
assert!(!app.session.tool_composing() && !app.session.tool_wants_text());
|
||||
assert!(ime.is_none());
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -390,14 +390,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
|
||||
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
|
||||
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
|
||||
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String {
|
||||
let mode = if d.doc.mask_edit.is_some() {
|
||||
format!("<{0}>/{0}", tl!("Opacity Mask"))
|
||||
} else {
|
||||
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
|
||||
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
|
||||
};
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
|
||||
}
|
||||
|
||||
/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows,
|
||||
@@ -435,7 +435,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size);
|
||||
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
|
||||
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
|
||||
})
|
||||
@@ -653,11 +653,17 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
|
||||
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
let print = app.session.prefs.display_print_size;
|
||||
let shown = crate::state::zoom_display_percent(zoom, print);
|
||||
egui::ComboBox::from_id_salt("zoom-combo")
|
||||
.selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5))
|
||||
.width(76.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for z in ZOOM_STOPS.iter().rev() {
|
||||
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
|
||||
if ui
|
||||
.selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print))
|
||||
.clicked()
|
||||
{
|
||||
app.run("view.setZoom", json!({"zoom": z})).ok();
|
||||
}
|
||||
}
|
||||
@@ -668,8 +674,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if ui.selectable_label(false, tl!("Fit All")).clicked() {
|
||||
app.run("view.fitAll", json!({})).ok();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
|
||||
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(
|
||||
ui,
|
||||
@@ -1139,6 +1144,16 @@ mod tests {
|
||||
assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// storytold/vectorcraft#2719: status-bar 100% matches Actual Size when Display Print Size is on.
|
||||
#[test]
|
||||
fn set_zoom_100_matches_actual_size_with_display_print_size() {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
|
||||
app.run("view.setZoom", json!({"zoom": 100})).unwrap();
|
||||
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from
|
||||
/// the first artboard disabled.
|
||||
#[test]
|
||||
@@ -1413,7 +1428,7 @@ mod tests {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false);
|
||||
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false, false);
|
||||
assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s));
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
assert!(title(&s).ends_with("(<Opacity Mask>/Opacity Mask)"), "{}", title(&s));
|
||||
|
||||
@@ -797,6 +797,7 @@ mod tests {
|
||||
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
|
||||
}
|
||||
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
|
||||
assert!(rasterize(Cursor::Busy, 1.0).is_none());
|
||||
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
|
||||
|
||||
@@ -743,7 +743,7 @@ mod tests {
|
||||
let fr = crate::i18n::Lang::from_code("fr").unwrap();
|
||||
let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB");
|
||||
assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}");
|
||||
for code in ["de", "es", "fr", "hu", "it", "pl", "ru", "sv"] {
|
||||
for code in ["de", "es", "fr", "hu", "it", "ka", "pl", "ru", "sv"] {
|
||||
let lang = crate::i18n::Lang::from_code(code).unwrap();
|
||||
for s in [
|
||||
"every search thread stopped on an internal error (please report this bug)",
|
||||
|
||||
@@ -241,10 +241,12 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
|
||||
let (w, h, preset, units) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"), unit(d));
|
||||
for s in &list {
|
||||
// The cards show the chosen orientation (square sizes have none) and the units chosen
|
||||
// in Preset Details; choosing one keeps both.
|
||||
// The built-in presets' cards show the chosen orientation (square sizes have none)
|
||||
// and the units chosen in Preset Details; choosing one keeps both. Recent and Saved
|
||||
// show each entry as it was made, so a portrait and a landscape A3 are two cards
|
||||
// (#2860).
|
||||
let mut s = DocSettings { units, ..s.clone() };
|
||||
if (w > h && s.height > s.width) || (h > w && s.width > s.height) {
|
||||
if cat > 1 && ((w > h && s.height > s.width) || (h > w && s.width > s.height)) {
|
||||
std::mem::swap(&mut s.width, &mut s.height);
|
||||
}
|
||||
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
|
||||
@@ -636,6 +638,56 @@ mod tests {
|
||||
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Recent shows each entry as it was made: a portrait and a landscape A3 are two cards of
|
||||
/// their own orientations, and only the chosen one is selected (#2860).
|
||||
#[test]
|
||||
fn recent_cards_keep_the_orientation_they_were_made_in() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let a3 = vectorcraft_engine::cmd::newdoc::find(&app.session, "A3").unwrap();
|
||||
let landscape = vectorcraft_engine::cmd::newdoc::DocSettings { width: a3.height, height: a3.width, ..a3.clone() };
|
||||
app.session.prefs.recent_new_docs = vec![landscape, a3];
|
||||
app.run("file.newDialog", json!({})).unwrap();
|
||||
assert_eq!(field(&app, "category"), json!("Recent"));
|
||||
// Frames half a second apart on one context: the first lays the dialog out, and its fade
|
||||
// in is over by the last.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut time = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp| {
|
||||
time += 0.5;
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
|
||||
let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
run(&mut app);
|
||||
run(&mut app);
|
||||
let out = run(&mut app);
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::Shape>) {
|
||||
match s {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
s => out.push(s.clone()),
|
||||
}
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut shapes));
|
||||
// The page thumbnails (white, 60 on their long side) and the selected card's outline.
|
||||
let thumbs: Vec<egui::Vec2> = shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
egui::Shape::Rect(r) if r.fill == egui::Color32::WHITE && (r.rect.width().max(r.rect.height()) - 60.0).abs() < 0.01 => {
|
||||
Some(r.rect.size())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!((thumbs.iter().filter(|s| s.x > s.y).count(), thumbs.iter().filter(|s| s.y > s.x).count()), (1, 1), "{thumbs:?}");
|
||||
let selected =
|
||||
shapes.iter().filter(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.width == 2.0 && r.rect.size() == egui::vec2(146.0, 150.0))).count();
|
||||
assert_eq!(selected, 1, "one card chosen");
|
||||
}
|
||||
|
||||
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
|
||||
#[test]
|
||||
fn a_double_clicked_preset_creates_the_document() {
|
||||
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user