Compare commits

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

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

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

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

The type's transform is now pushed inside the form that marks a fill
or stroke of type to overprint. The exporter checks what that form
holds through the type's transform, as it checks the same fill or
stroke without overprint.
2026-10-12 15:41:45 +07:00
BFlat 42b355c21c Merge pull request #1364 from storytold/fix/1264-ai-guides
Part of #1264: an image on %_ lines of a .ai file's editing data is read as an image
2026-10-12 15:40:11 +07:00
BFlat a48ed5e253 Merge pull request #1192 from FredApps/fix/polar-grid-click-dialog-20261011
Add a Polar Grid canvas-click dialog
2026-10-12 15:39:37 +07:00
bflatastic aca313f10a Part of #1264: an image on %_ lines of a .ai file's editing data is read as an image
Its samples follow a %%BeginData comment that comes after the %_ prefix, where the lexer
took it for a comment later in a line and read the samples as tokens: the rest of the file
was misread ("U closes something it didn't open") and the file opened from its PDF part.
2026-10-12 15:30:11 +07:00
bflatastic 62bf8c862c Polar Grid dialog: Hungarian and Korean rows for its two labels 2026-10-12 15:30:11 +07:00
FredApps 1dc61af4d1 Add a Polar Grid canvas-click dialog
A click with the Polar Grid tool opens the shared shape dialog (width, height, concentric and
radial dividers), as the Rectangular Grid tool does, and keeps the divider counts as the tool's
options.
2026-10-12 15:30:11 +07:00
BFlat 3c3c249bc0 Merge pull request #1239 from zakariayacine/fix-corner-widget-direct
Live Corners: with Direct Selection, a click on a widget selects its corner
2026-10-12 15:27:23 +07:00
BFlat 1909827299 Merge pull request #1250 from haranishi/feat/issue-1212-ruler-origin
feat: 定規の原点を変更・リセットする #1212
2026-10-12 15:27:03 +07:00
BFlat cbea134e9c Merge pull request #1252 from haranishi/fix/issue-1227-artboard-from-object
fix: 図形のクリックからアートボードを作成する #1227
2026-10-12 15:26:46 +07:00
BFlat 8115d58c3f Merge pull request #1347 from dotCooCoo/feat/align-menu-follows-align-to
Align: Object › Align follows Align To, and the choice is kept across restarts
2026-10-12 15:26:31 +07:00
BFlat 617b78b35f Merge pull request #1308 from haranishi/fix/issue-1293-pathfinder-names
fix: Pathfinderの名前とSVG IDを保持する #1293
2026-10-12 15:26:20 +07:00
BFlat 5a09dfec81 Merge pull request #1335 from haranishi/fix/issue-1307-divide-open-lines
fix(pathfinder): 開いた切断線で図形を分割する (#1307)
2026-10-12 15:26:05 +07:00
BFlat 6944497076 Merge pull request #1339 from haranishi/fix/issue-1326-numeric-drag-selection
fix(ui): 数値欄のフォーカス時にドラッグ選択を保つ (#1326)
2026-10-12 15:25:53 +07:00
BFlat 82db346d52 Merge pull request #1318 from haranishi/fix/issue-1299-dxf-closed-splines
fix(cad): DXFの制御点SPLINEの閉鎖を読み込みに反映する (#1299)
2026-10-12 15:25:38 +07:00
zakariayacineandClaude Fable 5.1 84336342f3 Live Corners: with Direct Selection, a click on a widget selects its corner
Rounding a single corner is done by selecting that corner, then
dragging its widget: a drag on a path selected whole rounds every
corner with both tools, as before. What was missing is selecting the
corner on the widget itself. With the Direct Selection tool a plain
click on a corner widget (press and release, no drag) now selects that
corner, as a click on the corner's anchor does; Shift-click toggles it.
The widget is then the only one showing, and the next drag on it rounds
that corner alone.

Unchanged: Alt-click cycles the corner kind, the Selection tool's click
on a widget does nothing, corners picked by their anchors edit together.

Test: corners::tests::a_click_on_a_widget_with_direct_selection_selects_its_corner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:17:28 +07:00
bflatastic 23ec8d9e67 i18n: Hungarian and Korean rows for the ruler origin commands 2026-10-12 15:17:26 +07:00
Ikki Nishihara 29ecab8c48 feat: change and reset the ruler origin (#1212) 2026-10-12 15:17:24 +07:00
Ikki Nishihara 932c127323 fix: the Artboard tool creates an artboard from a clicked shape (#1227) 2026-10-12 15:17:12 +07:00
dotCooCoo 44685d8798 Align: Object › Align follows Align To, and the choice is kept across restarts
Object › Align ran object.align without to, so it aligned to the key
object while there was one and otherwise to the selection. With Align
to Artboard chosen it aligned two objects to the selection's bounds,
and it left a single selected object where it was, while the align
buttons of the Align panel, the Control bar and the Properties panel
move that object to the active artboard. The Align To choice was kept
in egui's temporary memory, so it went back to Align to Selection at
each launch, and ui.inspect did not report it.

The choice is now the UiState field align_to, which the desktop app
saves with its preferences and ui.inspect reports. A saved value other
than "artboard" reads as Selection, and the other preferences still
load. Object › Align now sends the to that the align buttons send: the
artboard when one object is selected, else the key object while there
is one, else the Align To choice. With anchor points selected it sends
its parameters as before, and the points align to their own bounds.
While one object is selected, the Align To row shows Artboard. Agents'
object.align calls without to keep the engine's default.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-12 15:17:06 +07:00
Ikki Nishihara c85570c8b2 fix: Pathfinder results keep the source's name and SVG id (#1293) 2026-10-12 15:17:01 +07:00
Ikki Nishihara 18e538ec94 fix(pathfinder): Divide splits shapes with an open cutting line (#1307) 2026-10-12 15:16:57 +07:00
Ikki Nishihara 6ab08dd407 fix(ui): numeric fields keep a drag selection on focus (#1326) 2026-10-12 15:16:55 +07:00
Ikki Nishihara 0d244440a9 fix(cad): a closed control-point DXF SPLINE is read as closed (#1299) 2026-10-12 15:16:43 +07:00
BFlat 55e0459e21 Merge pull request #1350 from aguo890/fix/394-black-on-screen
Part of #394: Appearance of Black › On Screen shows pure CMYK black as RGB black
2026-10-12 15:10:34 +07:00
BFlat 9c41d78787 Merge pull request #1354 from dotCooCoo/feat/direct-selection-pattern-tiles
Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles
2026-10-12 15:10:16 +07:00
BFlat bdbcf8e1c5 Merge pull request #1353 from osaidahmed/perf/linear-deselect-uneditable
perf: deselect hidden and locked objects in linear time
2026-10-12 15:09:58 +07:00
BFlat 7046c52520 Merge pull request #1355 from dotCooCoo/feat/moves-into-hidden-layers-deselect
Layers: art moved into a hidden or locked layer, or hidden by a data set, is deselected
2026-10-12 15:09:30 +07:00
BFlat 8a7bf7920b Merge pull request #1345 from dotCooCoo/feat/pdf-editing-data
Part of #868: PDFs saved with the reference app's editing data open from it, as .ai files do
2026-10-12 15:09:15 +07:00
BFlat 43c988f099 Merge pull request #1349 from aguo890/fix/1266-pdf-bleed
PDF/.ai import: a .ai opens on its artboard with its bleed round it (#1266)
2026-10-12 15:08:52 +07:00
BFlat 4f280f0e1a Merge pull request #1351 from aguo890/fix/1304-shape-builder-clip-gradient
PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)
2026-10-12 15:08:30 +07:00
Aaron GuoandClaude Opus 5.5 b29e271b4a Part of #394: Appearance of Black › On Screen shows pure CMYK black as RGB black
Preferences › Appearance of Black › On Screen was stored and never read.
Display All Blacks Accurately (the default) shows pure CMYK black, black
ink alone at 100%, through the working CMYK profile, a dark grey, as the
canvas always did. Display All Blacks as Rich Black now shows it on the
canvas as RGB black: fills, strokes, type, gradient stops, effect colours
and pattern tiles, and in CMYK documents drawn by ink planes a pixel
whose inks come out as black ink alone. Rich blacks, black tints and
placed images show the same either way; exports and printing are
unchanged (Printing / Exporting is a separate option).

The renderer gets RenderOptions::rich_black, painted with a new
Ink::RichBlack (screen colours with pure black as RGB black) and applied
by ink::compose. The canvas sets it from blackOnScreen and keeps it in
its cache key, so changing the preference redraws.

A headless frame test reads the canvas raster: K100 shows its profile
grey by default, RGB black with rich, a rich black unchanged, and grey
again with accurate. A render test covers the opaque and ink-plane paths
and checks that rich blacks, tints and images don't change.

Docs: docs/mcp.md (blackOnScreen), docs/target-app-parity.md,
docs/gaps.md, docs/ui-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:50:00 +07:00
dotCooCoo ae061eacfb Part of #394: art moved whole with direct-selected anchors follows Transform Pattern Tiles
With anchors of one path direct-selected and another object selected
whole, the arrow keys (object.nudge), path.moveAnchors and a Direct
Selection drag of the selected anchors moved that object without its
pattern tiles, whatever General › Transform Pattern Tiles was set to.
When the same object was selected on its own, the arrow keys and a
Direct Selection drag followed the preference. path.moveAnchors left
the tiles in place in that case too.

path.moveAnchors now follows the preference for the objects it moves
whole, as the transforms, Align and Distribute do. path.moveAnchors
and object.nudge also take a `patterns` param for one call. When those
objects use a pattern, the journal records the choice, and a recorded
action plays back with it. Partly selected paths still move only their
selected anchors.

Docs: docs/mcp.md (transformPatternTiles), docs/target-app-parity.md.
2026-10-12 14:50:00 +07:00
Omar Saidahmed 497172bc97 deselect hidden and locked objects in linear time 2026-10-12 14:50:00 +07:00
dotCooCoo 307a5a6298 Layers: art moved into a hidden or locked layer, or hidden by a data set, is deselected
These commands moved selected objects into a hidden or locked layer,
or hid them, and left them selected, with the key object still set
when it was one of them:

- a row dragged into a hidden layer or group (layer.move);
- node.move into a hidden or locked layer or group;
- Merge Selected into a hidden or locked layer;
- Flatten Artwork into a locked layer;
- Collect in New Layer, when the topmost row it collects is in a
  hidden or locked layer;
- applying a data set that hides bound objects (dataset.select,
  dataset.next, dataset.prev).

They now call Selection::deselect_uneditable after the change, as
layer.setProps, layer.hideOthers and layer.lockOthers do. Every
selected object left hidden or locked is deselected, and the key
object is cleared when it is one of them. Undo restores the objects
and selects them again with the key object. A row drag into a hidden
layer and node.move into a hidden or locked layer still succeed. The
params text of layer.move, node.move, layer.merge, layer.flatten,
layer.collectInNew and dataset.select describes the deselection.

The Layers panel section of docs/mcp.md said that node.move has the
same rules as layer.move; it now says that node.move also moves a row
into a locked layer or group, which layer.move refuses.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-12 14:50:00 +07:00
dotCooCoo 1e76e389d2 Part of #868: PDFs saved with the reference app's editing data open from it, as .ai files do
A PDF saved with the reference app's editing data keeps that data in
streams named AIPDFPrivateData1, AIPDFPrivateData2, ..., where a .ai
file uses AIPrivateData1, AIPrivateData2, .... illustrator_data read
only the .ai names, so such a PDF opened like any other PDF: plain
groups opened ungrouped, and hidden objects and guides were missing. The
import gave no warning that the PDF's editing data went unread.

illustrator_data now reads the AIPDFPrivateData streams when the file
has no AIPrivateData1, and the PDF opens from its editing data as a .ai
file does. editingData: false still opens it like any other PDF.
has_private_data still looks for AIPrivateData keys only.

layered_ai also returns whether it read the layers. When it did, art
reaching past a PDF's page opens whole, and through_editing_data drops
the PDF importer's note that such art was clipped (PAST_PAGE_NOTE, now
public) as it drops the off-artboard note. When it didn't, both notes
stay. On main, a .ai whose editing data can't be decoded or has no
layers opened from its PDF part without the off-artboard note.

testkit::ai::pdf writes the .ai file of testkit::ai::ai with the PDF
key names. The editing-data tests open it as a PDF, and the import fuzz
test opens the damaged sample as a PDF too.

Docs: the document.open params, docs/mcp.md,
docs/file-format-parity.md, docs/gaps.md (G9: the export direction of
#868 remains), one evidence line in docs/target-app-parity.md, and #868
moved from layer structure to editing data in the File formats row of
docs/target-app-parity.md and in docs/roadmap.md.
2026-10-12 14:50:00 +07:00
Aaron GuoandClaude Opus 5.5 a99b1200ff PDF/.ai import: a .ai opens on its artboard with its bleed round it (#1266)
A PDF-compatible .ai with a bleed writes its PDF part's page as the
artboard grown by the bleed: the crop box takes the bleed in, the trim
box is the artboard and the bleed box the bleed. The importer made the
crop box the artboard, so the bleed opened as more white page with no
bleed guide; and since the editing data's artboard no longer matched
that page, its layers weren't read either ("its editing data is for a
different page").

Opening a PDF-compatible .ai with the crop box (the default) now takes
its trim boxes as the artboards. Any import on the trim boxes (Crop To
› Trim, for a PDF too) sets the document's bleed to how far the bleed
boxes reach past them on each side (ISO 32000-1, 14.11.2), kept to
0–72 pt, and the document read from the editing data keeps it. Art in
the bleed shows outside the artboard, within the bleed guide. Plain
PDFs still open on their crop box by default, and Place is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:50:00 +07:00
Omar Saidahmed 88e9381de6 move selection tests into their own module 2026-10-12 14:50:00 +07:00
Aaron Guo 3428a224bb Contributors: add aguo890 (Aaron Guo) 2026-10-12 14:49:59 +07:00
Aaron GuoandClaude Opus 5.5 563ff62aa0 PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)
A PDF, and the PDF part of a PDF-compatible .ai, writes a shape filled
with a gradient as a clip followed by a shading fill: the shape, W n,
then sh, which fills the clip's bounds. The importer kept that as a
clip group of the shape (a clipping path) and a rectangle the size of
its bounds filled with the gradient. The Shape Builder skips clipping
paths, so it built its pieces from the rectangles: two overlapping
gradient-filled diamonds gave three rectangles. The Fill swatch showed
? for such a selection, since it held groups.

A clip whose only content is one rectangle covering the clip, filled
with a gradient placed on the page and nothing else, now comes in as
one path: the clip's outline, its subpaths closed, with the
rectangle's fill, opacity, blend mode and mask. It draws as before and
edits like a gradient-filled path drawn in the app. Clips holding
anything else (a flat colour, several objects, a gradient that doesn't
cover the clip, a shading that doesn't extend past its ends) stay clip
groups.

Tests: a diamond clipping a shading imports as one closed path of four
anchors with the linear gradient where it was, and a gradient covering
only part of the diamond or a flat colour stay clipped. Two overlapping
diamonds in a hand-written PDF, clicked on their overlap with the Shape
Builder, give what is left of each (9000 pt² each) and the overlap
diamond (3000 pt²), each with its gradient; main gave three rectangles
of 12000 pt².

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:49:59 +07:00
BFlat 21c580a934 Merge pull request #1352 from Johau91/feat/korean-ui
G16: Korean interface (ko)
2026-10-12 14:45:08 +07:00
Johau91andClaude Opus 5.5 976d1bb428 G16: Korean interface (ko)
Add a Korean catalog (crates/ui-egui/src/i18n/ko.tsv, 4,151 rows) covering
every menu string, every tl! literal and the status and error messages, and
register `ko` in i18n::LANGUAGES (plural_none; every ko-* locale resolves).

- tests: korean_reads_as_korean, ko locale tags, ko in KEEPS_MENU_NAMES and
  COMPLETE_MESSAGES; is_cjk covers Hangul so the bundled-font glyph check
  leaves Hangul to installed fonts, as it does CJK without craft-fonts.
- Hangul is drawn with an installed system font on the desktop; the web
  build still needs a Korean UI face in craft-fonts.
- docs: localization-parity (Korean row, 12-language score 54% -> 63%,
  60-100 h), ROADMAP, README, development.md, gaps.md G16,
  target-app-parity.

Clean-room: written from the meaning of the English strings in ordinary
Korean vocabulary; no other product's Korean localisation was used.
AI-drafted; native-speaker review welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:44:39 +07:00
BFlat 96a783e716 Merge pull request #1324 from skylartaylor/codex/craft-print-adoption
Adopt craft-print for desktop printing, preserving Windows 7
2026-10-12 14:35:18 +07:00
skylar taylor-barrick 8cb5fa0607 Print: clarify automation results and discovery failures 2026-10-12 14:20:48 +07:00
skylar taylor-barrick e74c2f113c Print: adopt craft-print while retaining Windows 7 support 2026-10-12 14:20:48 +07:00
BFlat 5432821480 Merge pull request #1359 from storytold/fix/1286-ai-alpha-mask
Part of #1286: constant alpha masks are drawn white
2026-10-12 14:17:45 +07:00
111 changed files with 8020 additions and 668 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
name: Desktop printing
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests_printps.rs"
- ".github/workflows/print.yml"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
windows:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: desktop-print
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
- run: cargo test --locked -p vectorcraft
- name: Check pending submission and queue refusal feedback
run: cargo test --locked -p vectorcraft-ui-egui tests_printps
+12 -1
View File
@@ -1,11 +1,22 @@
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
name: Windows 7 x64 compatibility
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests_printps.rs"
- "packaging/windows/windows7.ps1"
- ".github/workflows/windows7.yml"
schedule:
- cron: "23 5 * * 1" # weekly
workflow_dispatch:
Generated
+36 -27
View File
@@ -806,6 +806,14 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -4482,11 +4490,12 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"block2 0.6.2",
"clipboard-win",
"craft-print",
"eframe",
"egui",
"image",
@@ -4515,7 +4524,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4526,7 +4535,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4538,7 +4547,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4554,7 +4563,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4575,7 +4584,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4587,7 +4596,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4604,7 +4613,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4619,7 +4628,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4654,7 +4663,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4674,7 +4683,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"proptest",
@@ -4688,7 +4697,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4700,7 +4709,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -4719,7 +4728,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4733,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4745,7 +4754,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"aes",
"flate2",
@@ -4774,7 +4783,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
@@ -4788,7 +4797,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"gif",
@@ -4813,7 +4822,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4837,7 +4846,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4854,7 +4863,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4874,14 +4883,14 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4896,7 +4905,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4909,7 +4918,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"craft-ui",
"egui",
@@ -4942,7 +4951,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -6052,7 +6061,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
+1 -1
View File
@@ -217,7 +217,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+4 -3
View File
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 54% (measured) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
@@ -106,7 +106,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Indonesian | `id` | none | 0% |
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | none | 0% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | none | 0% |
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
@@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
+4
View File
@@ -34,6 +34,10 @@ webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
craft-print = { workspace = true }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
+27 -179
View File
@@ -1,127 +1,54 @@
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
//! the PDF viewer.
use std::path::PathBuf;
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
//! Windows.Data.Pdf is unavailable there.
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
/// The system's printing.
pub struct SystemPrint;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
mod windows7;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
use windows7 as system;
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
system::printers()
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let r = system::print(job, &file);
let result = system::print(job);
let to = job.printer.unwrap_or("the default printer");
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
result.map(|note| format!("Sent “{}” to {to}{note}", job.title))
}
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
Some(std::sync::Arc::new(|job| SystemPrint.print(job)))
}
fn has_setup(&self) -> bool {
true
}
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
system::setup(printer)
}
}
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
#[cfg(any(test, not(windows)))]
fn lpstat_names(text: &str) -> Vec<String> {
let mut names: Vec<String> = vec![];
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
/// default destination").
#[cfg(any(test, not(windows)))]
fn lpstat_default(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.contains(':'))?;
let name = line.rsplit_once(':')?.1.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
#[cfg(any(test, windows))]
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
#[cfg(any(test, windows))]
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
#[cfg(not(windows))]
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
mod system {
use super::*;
pub fn printers() -> Vec<Printer> {
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
let mut c = Command::new("lp");
if let Some(p) = job.printer {
c.args(["-d", p]);
}
c.args(["-t", job.title]).arg(file);
let r = output(&mut c);
// `lp` has spooled its own copy (or failed).
let _ = std::fs::remove_file(file);
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
pub fn print(job: &PrintJob) -> Result<String, String> {
// Copies, layout, marks and colour management are already in the PDF. Preserve its
// physical sheet size and the queue's duplex default without printing copies twice.
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
}
pub fn setup(printer: Option<&str>) -> Result<(), String> {
#[cfg(windows)]
{
let _ = printer;
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
}
#[cfg(target_os = "macos")]
{
let _ = printer;
@@ -131,92 +58,13 @@ mod system {
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(windows, target_os = "macos")))]
{
if Command::new("system-config-printer").spawn().is_ok() {
return Ok(());
}
// CUPS' own pages.
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
}
#[cfg(windows)]
mod system {
use super::*;
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
#[cfg(feature = "windows7")]
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
#[cfg(not(feature = "windows7"))]
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
#[cfg(feature = "windows7")]
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
#[cfg(not(feature = "windows7"))]
let mut command = {
let mut command = Command::new("explorer");
command.arg("ms-settings:printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lpstat_lists_names_and_the_default() {
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
assert_eq!(lpstat_default("no system default destination\n"), None);
}
#[test]
fn powershell_lines_become_printers() {
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
let _ = std::fs::remove_file(path);
}
}
+129
View File
@@ -0,0 +1,129 @@
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, Printer};
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
// truncate an existing path, and remove incomplete files or refused launches immediately.
use std::io::Write as _;
let mut file =
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
drop(file);
let _ = std::fs::remove_file(&path);
return Err(format!("can't write {}: {e}", path.display()));
}
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new(
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
c.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
])
.creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
pub fn print(job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let result = submit(job, &file);
if result.is_err() {
// No viewer accepted the launch, so no asynchronous reader needs this document.
let _ = std::fs::remove_file(file);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn powershell_lines_and_quotes_are_preserved() {
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf_for_the_async_viewer() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
std::fs::remove_file(path).unwrap();
}
}
+5 -4
View File
@@ -25,10 +25,6 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
@@ -85,3 +81,8 @@ sync_github_name = false
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.aguo890]
real_name = "Aaron Guo"
display_name = "Aaron Guo"
sync_github_name = false
+2 -2
View File
@@ -14,7 +14,7 @@ Affinity files.
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
@@ -33,7 +33,7 @@ Everything else is reported in the import warnings, one line per kind with a cou
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round, stars with rounded points, several fills or strokes on one object (the
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
+60 -35
View File
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
@@ -165,22 +181,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+36
View File
@@ -166,3 +166,39 @@ fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// 10×20 box: relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
read(vec![shape(100, b"ShNR", fields)], 100)
};
let unlocked = corners(Some(false));
assert!(unlocked.warnings.is_empty(), "{:?}", unlocked.warnings);
let sub = &path(&unlocked).subpaths[0];
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let locked = corners(None);
let sub = &path(&locked).subpaths[0];
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
@@ -20,7 +20,7 @@ patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2,
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
+8 -1
View File
@@ -473,7 +473,14 @@ fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
curve::nurbs(degree, &knots, &ctrl, weights)
.map(|mut path| {
if flags & 1 != 0 {
path.close_path();
}
path
})
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
}
/// Reads a hatch's boundary groups in order.
+47
View File
@@ -270,6 +270,53 @@ fn nurbs_splines_stay_within_a_hundredth() {
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
}
#[test]
fn control_point_splines_honor_closed_flags_without_changing_their_curves() {
for returns in [false, true] {
for rational in [false, true] {
let mut original_curves = None;
for closed in [false, true] {
let flags = 8 | if rational { 4 } else { 0 } | i32::from(closed);
let mut e = vec![(0, "SPLINE".to_string()), (70, flags.to_string()), (71, "3".into()), (72, "11".into()), (73, "7".into())];
for k in [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2] {
e.push((40, k.to_string()));
}
let end = if returns { (20.0, 20.0) } else { (50.0, 25.0) };
for ((x, y), weight) in [(20.0, 20.0), (80.0, 0.0), (80.0, 80.0), (20.0, 80.0), (0.0, 80.0), (0.0, 20.0), end]
.into_iter()
.zip([1.0, 0.7, 0.7, 1.0, 0.9, 0.9, 1.0])
{
e.extend([(10, x.to_string()), (20, y.to_string()), (30, "0".into())]);
if rational {
e.push((41, weight.to_string()));
}
}
let e: Vec<_> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
let doc = open(&entities(&e), &points()).document;
let [n] = &leaves(&doc)[..] else { panic!("one spline") };
let path = n.path_data().unwrap();
assert_eq!(path.subpaths.len(), 1);
assert_eq!(path.subpaths[0].closed, closed, "flags {flags}, returns {returns}");
let curves: Vec<_> = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).copied().collect();
assert!(!curves.is_empty());
if let Some(original) = &original_curves {
assert_eq!(&curves, original, "closure preserves the rational/non-rational curves");
} else {
original_curves = Some(curves);
}
}
}
}
for flags in [8, 9] {
let flags_text = flags.to_string();
let e =
[(0, "SPLINE"), (70, flags_text.as_str()), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
let doc = open(&entities(&e), &points()).document;
let [n] = &leaves(&doc)[..] else { panic!("one fit-point spline") };
assert_eq!(n.path_data().unwrap().subpaths[0].closed, flags & 1 != 0, "fit-point fallback stays closed/open");
}
}
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
vec![
+54
View File
@@ -175,6 +175,60 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
let make_doc = |keep_closure: bool| {
doc_with(|d| {
cases
.iter()
.map(|&(closed, returns)| {
let start = Point::new(20.0, 20.0);
let mut b = kurbo::BezPath::new();
b.move_to(start);
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
if returns {
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
}
if closed {
if keep_closure {
b.close_path();
} else if !returns {
// The open reference has the same final straight segment.
b.line_to(start);
}
}
path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 0.0)), 2.0)
})
.collect()
})
};
let closed = make_doc(true);
let open = make_doc(false);
for version in DxfVersion::ALL {
let out = export(&closed, &DxfOptions { version, ..opts(&closed) }).unwrap();
let actual = entities(&out);
if version == DxfVersion::R12 {
let polys: Vec<_> = actual.iter().filter(|e| kind(e) == "POLYLINE").collect();
assert_eq!(polys.len(), cases.len());
for (poly, &(closed, _)) in polys.iter().zip(&cases) {
assert_eq!(get(poly, 70), Some(if closed { "1" } else { "0" }));
}
assert!(!actual.iter().any(|e| kind(e) == "SPLINE"));
continue;
}
let reference = entities(&export(&open, &DxfOptions { version, ..opts(&open) }).unwrap());
assert_eq!(actual.len(), cases.len());
for ((spline, reference), &(closed, _)) in actual.iter().zip(&reference).zip(&cases) {
assert_eq!(kind(spline), "SPLINE");
assert_eq!(get(spline, 70), Some(if closed { "9" } else { "8" }), "{version:?}");
let mut normalized = spline.clone();
normalized.iter_mut().filter(|p| p.0 == 70).for_each(|p| p.1 = "8".into());
assert_eq!(&normalized, reference, "all other entity properties, knots and controls: {version:?}");
}
}
}
#[test]
fn colours_map_to_the_index_palette_or_true_colour() {
let d = shapes_doc();
+4
View File
@@ -551,6 +551,9 @@ pub struct Document {
pub template: bool,
#[serde(default)]
pub units: Unit,
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
#[serde(default, skip_serializing_if = "skip::is_default")]
pub ruler_origin: Point,
#[serde(default)]
pub color_mode: ColorMode,
pub artboards: Vec<Artboard>,
@@ -706,6 +709,7 @@ impl Document {
title: "Untitled-1".into(),
template: false,
units: Unit::Points,
ruler_origin: Point::ZERO,
color_mode: mode,
artboards: vec![Artboard {
id: 1,
+13 -135
View File
@@ -1,6 +1,6 @@
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
@@ -165,9 +165,17 @@ impl Selection {
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let out: Vec<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
for o in out {
self.remove(o);
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
if uneditable.is_empty() {
return;
}
self.target = None;
self.slices.clear();
self.objects.retain(|o| !uneditable.contains(o));
self.anchors.retain(|id, _| !uneditable.contains(id));
self.segments.retain(|id, _| !uneditable.contains(id));
if self.key.is_some_and(|k| uneditable.contains(&k)) {
self.key = None;
}
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
@@ -208,134 +216,4 @@ impl Selection {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_ops() {
let mut s = Selection::default();
s.add(NodeId(1));
s.add(NodeId(1));
s.toggle(NodeId(2));
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
s.toggle(NodeId(1));
assert_eq!(s.objects, vec![NodeId(2)]);
s.clear();
assert!(s.is_empty());
}
#[test]
fn guides_are_selected_on_their_own() {
let mut s = Selection::default();
s.add(NodeId(1));
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
s.set_guides([0, 1]);
s.prune(&d);
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
}
mod tests;
+219
View File
@@ -0,0 +1,219 @@
use std::sync::Arc;
use super::*;
use crate::Node;
#[test]
fn basic_ops() {
let mut s = Selection::default();
s.add(NodeId(1));
s.add(NodeId(1));
s.toggle(NodeId(2));
assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]);
s.toggle(NodeId(1));
assert_eq!(s.objects, vec![NodeId(2)]);
s.clear();
assert!(s.is_empty());
}
#[test]
fn guides_are_selected_on_their_own() {
let mut s = Selection::default();
s.add(NodeId(1));
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
s.set_guides([0, 1]);
s.prune(&d);
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
fn below(r: &mut u64, n: u64) -> u64 {
*r ^= *r << 13;
*r ^= *r >> 7;
*r ^= *r << 17;
*r % n.max(1)
}
fn random_group(d: &mut Document, r: &mut u64, depth: u64, ids: &mut Vec<NodeId>) -> Arc<Node> {
let id = d.alloc_id();
ids.push(id);
let count = if depth == 0 { 0 } else { 1 + below(r, 4) };
let children = (0..count)
.map(|_| {
let depth = below(r, depth);
random_group(d, r, depth, ids)
})
.collect();
let mut group = Node::group(id, children);
group.locked = below(r, 6) == 0;
group.visible = below(r, 6) != 0;
Arc::new(group)
}
fn random_document(r: &mut u64) -> (Document, Vec<NodeId>) {
let mut d = Document::new(100.0, 100.0);
let mut ids = vec![];
for _ in 0..3 {
let mut layer = Node::layer(d.alloc_id(), "L", crate::LayerColor::Preset(0));
let children = (0..1 + below(r, 4)).map(|_| random_group(&mut d, r, 3, &mut ids)).collect();
if let Some(c) = layer.children_mut() {
*c = children;
}
layer.locked = below(r, 6) == 0;
layer.visible = below(r, 6) != 0;
d.layers.push(Arc::new(layer));
}
(d, ids)
}
fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection {
let mut s = Selection::default();
let missing = (below(r, 2) == 0).then_some(NodeId(u64::MAX));
s.set(ids.iter().copied().filter(|_| below(r, 2) == 0).chain(missing));
let k = below(r, s.objects.len() as u64) as usize;
s.objects.rotate_left(k);
for &o in &s.objects {
match below(r, 3) {
0 => {
s.anchors.insert(o, BTreeSet::from([(0, 0)]));
}
1 => {
s.anchors.insert(o, BTreeSet::from([(0, 1)]));
s.segments.insert(o, BTreeSet::from([(0, 0)]));
}
_ => {}
}
}
s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied();
s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0);
s.slices = ids.iter().copied().take(below(r, 3) as usize).collect();
s.guides = vec![1, 0];
s
}
#[test]
fn deselect_uneditable_matches_removing_one_at_a_time() {
let (mut unchanged, mut partial) = (0, 0);
for seed in 1..400u64 {
let mut r = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
let (d, mut ids) = random_document(&mut r);
if seed % 4 == 0 {
ids.retain(|id| d.is_editable(*id));
}
let s = random_selection(&mut r, &ids);
let mut expected = s.clone();
for o in s.objects.iter().copied().filter(|o| !d.is_editable(*o)) {
expected.remove(o);
}
let mut actual = s.clone();
actual.deselect_uneditable(&d);
assert_eq!(actual, expected, "seed {seed}");
unchanged += usize::from(actual == s);
partial += usize::from(!actual.objects.is_empty() && actual.objects.len() < s.objects.len());
}
assert!(unchanged > 10 && partial > 100, "{unchanged} {partial}");
}
+49 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_geom::{Affine, Vec2};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::*;
@@ -37,6 +37,25 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_units
),
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
cmd!(
"document.setRulerOrigin",
"Ruler Origin",
[],
None,
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
cmd!(
"document.resetRulerOrigin",
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
has_doc,
reset_ruler_origin
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -180,6 +199,35 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
change_ruler_origin(s, origin, "Ruler Origin")
}
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.resetRulerOrigin";
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
};
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
}
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
}
if s.doc()?.doc.ruler_origin != origin {
s.edit(label, |d, _| {
d.ruler_origin = origin;
Ok(())
})?;
}
Ok(json!(origin))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
+10 -7
View File
@@ -148,21 +148,24 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
// The note that the art outside the artboard is lost is the reason the layers weren't read.
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
}
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
// to the page.
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
(doc, warnings)
}
+1 -1
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
+5 -2
View File
@@ -96,7 +96,9 @@ impl LoadOptions {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
}
/// The PDF import options: `pages` resolved against the file's page count.
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
/// `.ai` asked for its crop box opens on its trim boxes, its editor's artboards, with the bleed
/// round them (its crop box takes the bleed in).
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
let pages = match &self.pages {
None => None,
@@ -105,9 +107,10 @@ impl LoadOptions {
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
let is_ai = self.crop == CropTo::Crop && vectorcraft_pdf::has_illustrator_data(bytes, self.password.as_deref());
Ok(ImportOptions {
pages,
crop: self.crop,
crop: if is_ai { CropTo::Trim } else { self.crop },
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
+88 -3
View File
@@ -1,6 +1,6 @@
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
//! opens as before, with a warning saying why.
//! Illustrator EPS, `.ai` and PDF files open from the editing data they carry: layers, groups,
//! names, hidden objects, artboards, colours and images as they were; what the reader doesn't read
//! yet opens as before, with a warning saying why.
use std::io::Write as _;
use std::sync::Arc;
@@ -8,6 +8,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_testkit::ai;
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use super::*;
@@ -81,6 +82,50 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
#[test]
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
let mut s = Session::new();
let file = ai::pdf(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.pdf", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("pdf"), &json!([])), "{r}");
check_sample(&s);
// Without it: the PDF part.
let r = open(&mut s, "art.pdf", &file, json!({"editingData": false})).unwrap();
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "{r}");
assert_ne!(names(layers), ["Art"]);
}
/// Art crossing the page's edge opens whole from a PDF's editing data, without the note that the
/// PDF's art was clipped to the page; without the editing data it is clipped, with the note.
#[test]
fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n", ai::rect(150.0, 20.0, 250.0, 80.0))));
let file = ai::pdf(data.as_bytes(), "0 g 150 20 100 60 re f");
let mut s = Session::new();
let r = open(&mut s, "past.pdf", &file, json!({})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"]);
assert!(doc.art_bounds().is_some_and(|b| b.x1 > 200.5), "{:?}", doc.art_bounds());
let r = open(&mut s, "past.pdf", &file, json!({"editingData": false})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
}
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
#[test]
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
let damaged = b"%AI12_CompressedData not zlib";
let mut s = Session::new();
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
@@ -157,3 +202,43 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data.
fn bled_ai(bleed: f64) -> Vec<u8> {
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
let page = PdfPage {
bleed: Some([0.0, 0.0, w, h]),
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
};
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
}
#[test]
fn an_ai_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
let bleed = 10.0 * 72.0 / 25.4;
let file = bled_ai(bleed);
let mut s = Session::new();
// From the editing data, and from the PDF part alone.
for params in [json!({}), json!({"editingData": false})] {
let r = open(&mut s, "bled.ai", &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 1, "{params}: {r}");
let ab = doc.artboards[0].rect;
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{params}: {ab:?} {r}");
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{params}: {:?}", doc.setup.bleed);
// The square reaches into the bleed, whole.
let art = doc.art_bounds().unwrap();
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{params}: {art:?}");
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{params}: {art:?}");
}
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "editingData: false reads the PDF part");
open(&mut s, "bled.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers are read");
}
+10 -4
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Collect in New Layer",
["Window", "Layers"],
None,
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer), deselecting every selected object left hidden or locked (the key object too) → {id}",
has_doc,
collect
),
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Rows",
[],
None,
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. Every selected object left hidden or locked, such as one moved into a hidden layer or group, is deselected (the key object too). One undo step → {ids}",
has_doc,
move_rows
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder",
[],
None,
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers), deselecting every selected object left hidden or locked (the key object too)",
has_doc,
move_node
),
@@ -589,6 +589,7 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
d.move_node(*id, Some(lid), usize::MAX)?;
}
sel.prune(d);
sel.deselect_uneditable(d);
Ok(lid)
})?;
let st = s.doc_mut()?;
@@ -713,6 +714,7 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
index += 1;
}
sel.prune(d);
sel.deselect_uneditable(d);
Ok(out)
})?;
let st = s.doc_mut()?;
@@ -730,7 +732,11 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
if !accepts(d, parent, n) {
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
}
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
s.edit("Reorder", |d, sel| {
d.move_node(id, parent, index)?;
sel.deselect_uneditable(d);
Ok(())
})?;
ok()
}
+4 -2
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge Selected",
["Window", "Layers"],
None,
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. Every selected object left hidden or locked, such as one merged into a hidden or locked layer, is deselected (the key object too). One undo step → {id}",
has_doc,
merge
),
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Artwork",
["Window", "Layers"],
None,
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. Every selected object left hidden or locked, such as one flattened into a locked layer, is deselected (the key object too). One undo step → {id, discarded}",
has_doc,
flatten
),
@@ -288,6 +288,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Merge Selected", |d, sel| {
merge_into(d, into, &others)?;
sel.prune(d);
sel.deselect_uneditable(d);
Ok(())
})?;
let st = s.doc_mut()?;
@@ -318,6 +319,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
d.drop_guides_of_deleted_layers(sel);
merge_into(d, into, &others)?;
sel.prune(d);
sel.deselect_uneditable(d);
Ok(())
})?;
let st = s.doc_mut()?;
+2 -2
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
has_selection_or_guides,
nudge
),
@@ -337,7 +337,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") }));
}
if s.doc()?.selection.is_empty() {
let copy = bool_or(p, "copy", false);
+7 -3
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Anchors",
[],
None,
"{dx, dy} move direct-selected anchors (whole paths if fully selected)",
"{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)",
has_selection,
move_anchors
),
@@ -172,6 +172,10 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let sel = st.selection.clone();
let scale_strokes = s.prefs.scale_strokes;
let _ = scale_strokes;
// Objects selected whole (with no entry in `sel.anchors`) move as objects, and their pattern
// tiles move with them when `patterns` (default: General › Transform Pattern Tiles) is on.
let whole: Vec<NodeId> = sel.objects.iter().copied().filter(|id| !sel.anchors.contains_key(id)).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
s.edit("Move", |doc, _| {
for id in &sel.objects {
match sel.anchors.get(id) {
@@ -185,7 +189,7 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(vectorcraft_geom::Affine::translate(d), false);
n.transform(vectorcraft_geom::Affine::translate(d), sc);
}
}
}
+22 -5
View File
@@ -310,13 +310,21 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let label = pf_label(op);
let ids = s.edit(label, |d, sel| {
// Build the stack (back → front) with a style per entry.
// Build the stack (back → front); names are separate from paint/style identity.
let mut styles: Vec<Style> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
let mut push = |path: PathData, rule: FillRule, st: Style| {
let mut names: Vec<Option<String>> = vec![];
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
if path.is_empty() {
return;
}
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
// Filled open contours keep their implicit closing edge. A zero-area line
// stays a cutter; normalised area also catches zero-signed-area self crossings.
for sub in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
sub.closed = po::encloses_area(sub) || po::area(&PathData::single(sub.clone()), rule) > 0.0;
}
}
let key = match styles.iter().position(|x| *x == st) {
Some(k) => k,
None => {
@@ -325,6 +333,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
}
};
shapes.push(po::Shape::new(path, rule, key as u64));
names.push(name);
};
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
@@ -340,11 +349,12 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
// A group acts as one shape (the union of its leaves) with its top leaf's style.
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
push(po::unite_all(&refs), FillRule::NonZero, style_of(top));
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name);
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
push(p, r, style_of(l));
push(p, r, style_of(l), n.name.clone().or_else(|| l.name.clone()));
}
}
}
@@ -364,7 +374,14 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let r =
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
let style = styles.get(r.key as usize).ok_or_else(|| EngineError::Other(format!("{label}: lost track of a shape's style")))?;
shape_node_styled(d, r.path, style)
let mut n = shape_node_styled(d, r.path, style);
n.name = match op {
// A subtraction keeps its surviving source's identity, even when unnamed.
PathfinderOp::MinusFront => names.first().cloned().flatten(),
PathfinderOp::MinusBack => names.last().cloned().flatten(),
_ => names.into_iter().flatten().next(),
};
n
} else {
let mut children = vec![];
for r in results {
+4 -2
View File
@@ -379,7 +379,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
let (applied, skipped) = s.edit("Apply Data Set", |d, sel| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
// text variables on the same object land the later one's value and both are counted.
@@ -424,6 +424,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
}
}
d.variables.active_dataset = Some(name.to_string());
// Every selected object left hidden or locked, such as one the row hid, is deselected.
sel.deselect_uneditable(d);
Ok((applied, skipped))
})?;
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
@@ -569,7 +571,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data Set",
["Window", "Variables"],
None,
"{name} apply a dataset in one undo step → {applied, skipped}",
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped}",
has_doc,
dataset_select
),
+9 -4
View File
@@ -556,10 +556,15 @@ fn strip_replay_only(id: &str, p: &mut Value) {
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
d.artboards.iter().all(|a| ok(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
d.ruler_origin.is_finite()
&& d.ruler_origin.x.abs() <= MAX_COORD
&& d.ruler_origin.y.abs() <= MAX_COORD
&& d.artboards.iter().all(|a| ok(a.rect))
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
&& {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
}
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
+42
View File
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
@@ -172,6 +173,47 @@ fn shape_builder_tool_drives_the_command() {
assert_eq!(layer_children(&s), 1);
}
/// The first and last stop colours of a gradient paint.
fn gradient_ends(p: &Paint) -> Option<([f32; 3], [f32; 3])> {
let Paint::Gradient(g) = p else { return None };
Some((g.gradient.stops.first()?.color.to_rgb(), g.gradient.stops.last()?.color.to_rgb()))
}
#[test]
fn shape_builder_cuts_gradient_shapes_from_a_pdf_along_their_outlines() {
// Two overlapping diamonds (x 100–300 and 200–400, y 40–160), each written as gradient-filled
// shapes are in a PDF or a PDF-compatible .ai: the diamond as a clip, then a shading (#1304).
let shading = |x0: u32, c0: &str, c1: &str| {
format!(
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{x0} 0 {} 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [{c0}] /C1 [{c1}] /N 1 >> >>",
x0 + 200
)
};
let diamond = |x0: u32| format!("q {x0} 100 m {} 160 l {} 100 l {} 40 l h W n", x0 + 100, x0 + 200, x0 + 100);
let page = PdfPage {
resources: format!("/Shading << /Sh0 {} /Sh1 {} >>", shading(100, "1 0 0", "1 1 0"), shading(200, "0 0 1", "0 1 1")),
..PdfPage::new(500.0, 200.0, &format!("{} /Sh0 sh Q {} /Sh1 sh Q", diamond(100), diamond(200)))
};
let bytes = pdf(&[page], None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "diamonds.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[250, 100]]})).unwrap();
let mut pieces: Vec<(f64, Rect, Paint)> =
ids(&r).into_iter().map(|i| (area(&s, i), node(&s, i).geometric_bounds().unwrap(), node(&s, i).appearance.fill_paint())).collect();
pieces.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.01);
let [(left, lb, lp), (mid, mb, mp), (right, rb, rp)] = pieces.as_slice() else { panic!("three pieces: {pieces:?}") };
// What is left of each diamond, and the diamond where they overlap: not their bounding boxes.
assert!((left - 9000.0).abs() < 1.0 && (mid - 3000.0).abs() < 1.0 && (right - 9000.0).abs() < 1.0, "{pieces:?}");
assert!(near(*lb, Rect::new(100.0, 40.0, 250.0, 160.0)), "{lb:?}");
assert!(near(*mb, Rect::new(200.0, 70.0, 300.0, 130.0)), "{mb:?}");
assert!(near(*rb, Rect::new(250.0, 40.0, 400.0, 160.0)), "{rb:?}");
// Each keeps its gradient; the overlap takes the front diamond's.
let (first, second) = (Some(([1.0, 0.0, 0.0], [1.0, 1.0, 0.0])), Some(([0.0, 0.0, 1.0], [0.0, 1.0, 1.0])));
assert_eq!((gradient_ends(lp), gradient_ends(mp), gradient_ends(rp)), (first, second, second));
}
/// A red 100 × 100 square crossed by a stroke-only vertical line at x = 50, both selected.
fn square_and_line(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
+116
View File
@@ -451,6 +451,29 @@ fn moves_are_validated() {
assert_eq!(parent(&s, sub), None);
}
/// `node.move` into a hidden or locked layer deselects the object it moves, here the key object.
/// Undo puts the object back and selects it again as the key object.
#[test]
fn node_move_into_a_hidden_or_locked_layer_deselects_the_object() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
let other = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setProps", json!({"ids": [other.0], prop: prop == "locked"}));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "select.key", json!({"id": b.0}));
run(&mut s, "node.move", json!({"id": b.0, "parent": other.0, "index": 0}));
assert_eq!(parent(&s, b), Some(other), "{prop}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a], None), "{prop}: b, the key object, is deselected");
run(&mut s, "edit.undo", json!({}));
assert_eq!(parent(&s, b), Some(layer), "{prop}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a, b], Some(b)), "{prop}: undo selects b again as the key object");
}
}
#[test]
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
let mut s = session();
@@ -467,6 +490,33 @@ fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
}
/// The selected art in rows dragged into a hidden layer or group is deselected, the key object
/// included. Undo puts the rows back and selects the art again.
#[test]
fn rows_dragged_into_a_hidden_layer_or_group_deselect_their_art() {
let mut s = session();
let (layer, sub, a, g, b, c) = tree(&mut s);
let hidden = id(&run(&mut s, "layer.new", json!({"visible": false})));
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "select.key", json!({"id": a.0}));
// The sublayer, holding a and the group of b, goes into the hidden layer.
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": hidden.0}));
assert_eq!(parent(&s, sub), Some(hidden));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![c], None), "a, the key object, and b are hidden");
run(&mut s, "edit.undo", json!({}));
assert_eq!(parent(&s, sub), Some(layer));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a, b, c], Some(a)), "undo selects them again");
// c goes into the group once the group is hidden.
run(&mut s, "object.setProps", json!({"ids": [g.0], "visible": false}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, c], "b is in the hidden group");
run(&mut s, "layer.move", json!({"ids": [c.0], "target": g.0, "place": "inside"}));
assert_eq!(parent(&s, c), Some(g));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![a], None), "c is hidden, and a single selected object has no key object");
}
#[test]
fn delete_and_duplicate_rows() {
let mut s = session();
@@ -526,6 +576,72 @@ fn collect_merge_flatten_and_reverse() {
assert_eq!(undos(&s), before + 1);
}
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
/// object included, and undo selects it again.
#[test]
fn collect_in_new_layer_inside_a_hidden_or_locked_layer_deselects_the_art_it_moves() {
for prop in ["visible", "locked"] {
let mut s = session();
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
let top = id(&run(&mut s, "layer.new", json!({})));
let y = rect(&mut s, 200.0);
run(&mut s, "layer.setProps", json!({"ids": [top.0], prop: prop == "locked"}));
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
let col = id(&run(&mut s, "layer.collectInNew", json!({"ids": [x.0, y.0]})));
assert_eq!((parent(&s, col), children(&s, col)), (Some(top), vec![x, y]), "{prop}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![z], None), "{prop}: x, the key object, is deselected");
run(&mut s, "edit.undo", json!({}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "{prop}: undo selects x again as the key object");
}
}
/// Merge Selected into a hidden or locked layer deselects the selected art it moves there, the key
/// object included, and undo selects it again.
#[test]
fn merge_selected_into_a_hidden_or_locked_layer_deselects_the_art_it_moves() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
let into = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setProps", json!({"ids": [into.0], prop: prop == "locked"}));
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
run(&mut s, "layer.merge", json!({"ids": [x.0, into.0]}));
assert_eq!((parent(&s, x), parent(&s, z)), (Some(into), Some(layer)), "{prop}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![z], None), "{prop}: x, the key object, is deselected");
run(&mut s, "edit.undo", json!({}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "{prop}: undo selects x again as the key object");
}
}
/// Flatten Artwork into a locked layer deselects the selected art it moves there, the key object
/// included, and undo selects it again.
#[test]
fn flatten_artwork_into_a_locked_layer_deselects_the_art_it_moves() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let (x, z) = (rect(&mut s, 0.0), rect(&mut s, 100.0));
let into = id(&run(&mut s, "layer.new", json!({"locked": true})));
run(&mut s, "select.set", json!({"ids": [x.0, z.0]}));
run(&mut s, "select.key", json!({"id": x.0}));
run(&mut s, "layer.flatten", json!({"id": into.0}));
assert_eq!((parent(&s, x), parent(&s, z)), (Some(into), Some(into)));
assert!(s.doc().unwrap().doc.node(layer).is_none(), "Layer 1 was merged into the locked layer");
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![], None), "x and z are locked");
run(&mut s, "edit.undo", json!({}));
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![x, z], Some(x)), "undo selects them again with the key object");
}
#[test]
fn release_to_layers_sequence_and_build() {
let mut s = session();
+241
View File
@@ -205,6 +205,161 @@ fn unite_with_group() {
assert!((area_of(&s, r[0]) - (100.0 + 100.0 + 200.0 - 25.0 - 25.0)).abs() < 1.0);
}
fn name_sources(s: &mut Session, a: NodeId, b: NodeId, names: [Option<&str>; 2]) {
s.edit("Name sources", |d, _| {
for (id, name) in [(a, names[0]), (b, names[1])] {
d.node_mut(id).unwrap().name = name.map(str::to_owned);
}
Ok(())
})
.unwrap();
}
fn named_nodes<'a>(n: &'a vectorcraft_doc::Node, name: &str, out: &mut Vec<&'a vectorcraft_doc::Node>) {
if n.name.as_deref() == Some(name) {
out.push(n);
}
if let Some(children) = n.children() {
for c in children {
named_nodes(c, name, out);
}
}
}
fn check_svg_name(s: &Session, id: NodeId, name: &str) {
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert_eq!(svg.matches(&format!("id=\"{name}\"")).count(), 1, "{svg}");
let imported = vectorcraft_svg::import(&svg).unwrap();
let mut matches = vec![];
for layer in &imported.layers {
named_nodes(layer, name, &mut matches);
}
assert_eq!(matches.len(), 1, "{name}: {svg}");
assert!(close(matches[0].geometric_bounds().unwrap(), bounds(s, id), 1e-2));
assert_eq!(matches[0].appearance.fill_paint(), node(s, id).appearance.fill_paint());
}
#[test]
fn pathfinder_shape_names_follow_paint_order_without_changing_output_or_undo() {
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
let untouched = rect(&mut s, 300.0, 300.0, 30.0, 20.0);
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
s.edit("Source opacity and blend", |d, _| {
d.node_mut(a).unwrap().opacity = 0.4;
d.node_mut(b).unwrap().opacity = 0.8;
d.node_mut(b).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let cmd = format!("object.pathfinder.{op}");
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
let expected = if op == "minusBack" { "blade" } else { "base-plate" };
let mut result = node(&s, id);
assert_eq!(result.name.as_deref(), Some(expected), "{op}");
assert_eq!(node(&s, untouched), *before.node(untouched).unwrap());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
check_svg_name(&s, id, expected);
let after = s.doc().unwrap().doc.clone();
let after_selection = s.doc().unwrap().selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *after);
assert_eq!(s.doc().unwrap().selection, after_selection);
s.execute("edit.undo", &json!({})).unwrap();
name_sources(&mut s, a, b, [None, None]);
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
result.name = None;
assert_eq!(result, node(&s, bare_id), "names must not change geometry or appearance: {op}");
}
}
#[test]
fn pathfinder_shape_names_do_not_borrow_a_cutters_name_for_an_unnamed_source() {
for names in [[None, Some("blade")], [Some("base-plate"), None], [None, None]] {
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
name_sources(&mut s, a, b, names);
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
let expected = match op {
"minusFront" => names[0],
"minusBack" => names[1],
_ => names[0].or(names[1]),
};
let id = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap())[0];
assert_eq!(node(&s, id).name.as_deref(), expected, "{op}: {names:?}");
}
}
}
#[test]
fn pathfinder_shape_names_keep_group_and_compound_identity_once() {
for (compound, count, root_name) in
[(false, 1, Some("root-identity")), (false, 2, Some("root-identity")), (true, 2, Some("root-identity")), (false, 2, None)]
{
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
let mut children = vec![a.0];
if count == 2 {
children.push(rect(&mut s, 60.0, 0.0, 40.0, 100.0).0);
}
s.execute("select.set", &json!({"ids": children})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.edit("Name grouped source", |d, _| {
d.node_mut(a).unwrap().name = Some("leaf-identity".into());
let appearance = d.node(a).unwrap().appearance.clone();
let n = d.node_mut(group).unwrap();
n.name = root_name.map(str::to_owned);
if compound {
n.kind = NodeKind::Compound { children: n.children().unwrap().to_vec(), rule: FillRule::NonZero };
n.appearance = appearance;
}
Ok(())
})
.unwrap();
let cutter = rect(&mut s, 35.0, -10.0, 30.0, 120.0);
name_sources(&mut s, group, cutter, [root_name, Some("blade")]);
s.execute("select.set", &json!({"ids": [cutter.0, group.0]})).unwrap();
let id = ids(&s.execute("object.pathfinder.minusFront", &json!({})).unwrap())[0];
let n = node(&s, id);
let expected = root_name.unwrap_or("leaf-identity");
assert_eq!(n.name.as_deref(), Some(expected));
assert!((area_of(&s, id) - 3500.0 * f64::from(count)).abs() < 1.0);
assert!(n.children().is_none_or(|children| children.iter().all(|c| c.name.is_none())));
check_svg_name(&s, id, expected);
}
}
#[test]
fn pathfinder_fragment_names_do_not_change_style_merging() {
for op in ["divide", "trim", "merge", "crop", "outline"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
set_fill(&mut s, a, Color::rgb(0.0, 0.0, 1.0));
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
let cmd = format!("object.pathfinder.{op}");
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
let result = node(&s, id);
assert!(result.name.is_none());
assert!(result.children().unwrap().iter().all(|c| c.name.is_none()));
if op == "merge" {
assert_eq!(result.children().unwrap().len(), 1);
}
s.execute("edit.undo", &json!({})).unwrap();
name_sources(&mut s, a, b, [None, None]);
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
assert_eq!(result, node(&s, bare_id), "{op}");
}
}
#[test]
fn offset_rect_grows_bounds() {
let mut s = session();
@@ -584,3 +739,89 @@ fn commands_reject_bad_input() {
let _ = s.execute(c, &p);
}
}
#[test]
fn divide_cutting_lines_keep_appearance_unrelated_art_and_one_undo() {
for (line_has_fill, curved) in [(false, false), (true, false), (false, true)] {
let mut s = session();
let base = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let cutter =
NodeId(s.execute("path.create", &json!({"anchors": [{"x": -10, "y": 50}, {"x": 110, "y": 50}]})).unwrap()["id"].as_u64().unwrap());
let kept = rect(&mut s, 200.0, 0.0, 30.0, 30.0);
s.edit("divide fixture", |d, sel| {
let n = d.node_mut(base).unwrap();
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.7)), Paint::solid(Color::BLACK), 3.0).into();
n.opacity = 0.65;
n.blend = vectorcraft_color::BlendMode::Multiply;
let n = d.node_mut(cutter).unwrap();
n.appearance =
Appearance::basic(if line_has_fill { Paint::solid(Color::BLACK) } else { Paint::None }, Paint::solid(Color::rgb(0.8, 0.2, 0.1)), 2.0)
.into();
if curved {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((-10.0, 50.0));
bp.curve_to((30.0, 15.0), (70.0, 85.0), (110.0, 50.0));
n.kind = NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false };
}
sel.set([cutter, base]);
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
let selected = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let result = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap())[0];
let group = node(&s, result);
let pieces = group.children().unwrap();
assert_eq!(pieces.len(), 2, "line fill {line_has_fill}, curved {curved}");
let source = before.node(base).unwrap();
assert!(pieces.iter().all(|n| n.appearance == source.appearance && n.opacity == source.opacity && n.blend == source.blend));
let total: f64 = pieces.iter().map(|n| area_of(&s, n.id)).sum();
assert!((total - 10000.0).abs() < 0.01);
assert_eq!(node(&s, kept), *before.node(kept).unwrap());
assert!(s.doc().unwrap().doc.node(base).is_none() && s.doc().unwrap().doc.node(cutter).is_none());
assert_eq!(s.doc().unwrap().selection.objects, vec![result]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
let after = s.doc().unwrap().doc.clone();
let result_selection = s.doc().unwrap().selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().selection, selected);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
assert_eq!(s.doc().unwrap().selection, result_selection);
}
}
#[test]
fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
for anchors in [
json!([{"x": 10, "y": 10}, {"x": 50, "y": 90}, {"x": 90, "y": 10}]),
json!([{"x": 0, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}, {"x": 100, "y": 0}]),
] {
let mut s = session();
let filled = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let cutter =
NodeId(s.execute("path.create", &json!({"anchors": [{"x": -10, "y": 150}, {"x": 110, "y": 150}]})).unwrap()["id"].as_u64().unwrap());
s.edit("open fill fixture", |d, sel| {
let mut appearance = Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.2)), Paint::None, 0.0);
// An empty top fill must not hide the nonempty fill below it.
appearance.items.insert(0, vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::None)));
d.node_mut(filled).unwrap().appearance = appearance.into();
d.node_mut(cutter).unwrap().appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into();
sel.set([cutter, filled]);
Ok(())
})
.unwrap();
let source = node(&s, filled);
assert!(source.path_data().unwrap().subpaths.iter().all(|sp| !sp.closed));
let expected = area_of(&s, filled);
assert!(expected > 3000.0);
let result = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap())[0];
let group = node(&s, result);
let pieces = group.children().unwrap();
let actual: f64 = pieces.iter().map(|n| area_of(&s, n.id)).sum();
assert!((actual - expected).abs() < 0.01, "{actual} != {expected}");
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
}
}
+53
View File
@@ -699,6 +699,59 @@ fn art_moved_with_its_artboard_follows_transform_pattern_tiles() {
}
}
/// General › Transform Pattern Tiles (#394) applies to the objects selected whole that move with
/// direct-selected anchors (an arrow key, `path.moveAnchors` or a Direct Selection drag of an
/// anchor): off, the tiles of a pattern-filled square stay in place; on, they move with it, and
/// `patterns: false` keeps them in place for one call.
#[test]
fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let filled = patterned_square(&mut s);
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
s.select_tool("directSelection", v).unwrap();
// The square is selected whole with the top-left anchor of `other`, as a click on the square's
// fill and a Shift-click on that anchor select them. Each move takes both 10 pt right: Shift
// with an arrow key, `path.moveAnchors`, and a drag of the anchor (no params).
for (cmd, p) in [
("object.nudge", Some(json!({"dx": 1, "dy": 0, "big": true}))),
("path.moveAnchors", Some(json!({"dx": 10, "dy": 0}))),
("path.moveAnchors", None),
] {
let run = |s: &mut Session, p: Option<&Value>| {
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
match p {
Some(p) => {
s.execute(cmd, p).unwrap();
}
None => gesture(s, v, &[(Down, 300.0, 100.0), (Drag, 305.0, 100.0), (Drag, 310.0, 100.0), (Up, 310.0, 100.0)]),
}
assert_eq!(top_left(s, filled), (110.0, 100.0), "{cmd}: the square moves whole");
let anchors = &s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors;
assert_eq!((anchors[0].p.x, anchors[1].p.x), (310.0, 350.0), "{cmd}: the selected anchor moves alone");
assert_eq!(s.journal.last().unwrap().0, cmd);
};
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
if let Some(mut p) = p {
p["patterns"] = json!(false);
run(&mut s, Some(&p));
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
}
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
+23
View File
@@ -526,6 +526,29 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() {
s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap();
}
/// A row that hides selected objects deselects them, the key object included. Undo shows them
/// and selects them again with the key object.
#[test]
fn a_row_that_hides_selected_objects_deselects_them() {
let mut s = session();
let (r, q) = (rect(&mut s), rect(&mut s));
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Show": false}})).unwrap();
s.execute("select.set", &json!({"ids": [r.0, q.0]})).unwrap();
s.execute("select.key", &json!({"id": r.0})).unwrap();
s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![q], None), "r, the key object, is hidden");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(r).unwrap().visible);
let sel = s.doc().unwrap().selection.clone();
assert_eq!((sel.objects, sel.key), (vec![r, q], Some(r)), "undo selects r again as the key object");
}
/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of
/// the same name, and which dataset is active, alone.
#[test]
+1
View File
@@ -23,6 +23,7 @@ const FILES: &[(&str, f64)] = &[
("patchy-group.af", 0.25), // 0.00
("patchy-transform.af", 1.0), // 0.19
("patchy-vector.af", 1.0), // 0.12
("patchy-shapes.af", 1.0), // 0.24: per-corner radii, a straight corner
("patchy-lazy-placed.af", 1.0), // 0.50: upstream byte-derived lazy-image regression
("patchy-embedded-jpeg.af", 1.0), // 0.50
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
+130
View File
@@ -0,0 +1,130 @@
//! Artboard clicks fit the object without changing its art or selection, and are one undo step.
#![allow(clippy::unwrap_used)]
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::{Session, ViewInfo};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
fn fixture(x: f64, y: f64) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
s.select_tool("artboard", ViewInfo::default()).unwrap();
(s, NodeId(id))
}
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
#[test]
fn clicks_inside_and_outside_an_artboard_fit_the_visible_object_in_one_undo_step() {
for (x, y) in [(100.0, 100.0), (650.0, 700.0)] {
let (mut s, id) = fixture(x, y);
let before = s.doc().unwrap().doc.clone();
let sel = s.doc().unwrap().selection.clone();
let history = s.doc().unwrap().history.undo.len();
let at = (x + 40.0, y + 25.0);
pointer(&mut s, PointerKind::Down, at.0, at.1);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a press alone does not edit the document");
pointer(&mut s, PointerKind::Drag, at.0 + 1.0, at.1);
pointer(&mut s, PointerKind::Up, at.0 + 1.0, at.1);
let st = s.doc().unwrap();
assert_eq!(st.doc.artboards.len(), 2);
let rect = Rect::new(x - 5.0, y - 5.0, x + 105.0, y + 65.0);
assert_eq!(st.doc.artboards[1].rect, rect, "includes the visible stroke, like Fit to Selected Art");
assert_eq!(st.doc.bounds_of(&[id], true), Some(rect));
assert_eq!(json!(st.doc.layers), json!(before.layers));
assert_eq!(json!(st.selection), json!(sel));
assert_eq!(st.history.undo.len(), history + 1);
assert_eq!(s.tool_options()["active"], 1);
pointer(&mut s, PointerKind::Up, at.0, at.1);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2, "one release creates once");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
assert_eq!(json!(s.doc().unwrap().selection), json!(sel));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, rect);
}
}
#[test]
fn the_topmost_group_is_fitted_instead_of_the_selection_or_its_clicked_child() {
let (mut s, bottom) = fixture(100.0, 100.0);
let a = s.execute("shape.rectangle", &json!({"x": 120, "y": 110, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 120, "width": 40, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let group = s.doc().unwrap().selection.objects[0];
let expected = s.doc().unwrap().doc.bounds_of(&[group], true).unwrap();
s.execute("select.set", &json!({"ids": [bottom.0]})).unwrap();
let layers = json!(s.doc().unwrap().doc.layers);
pointer(&mut s, PointerKind::Down, 135.0, 125.0);
pointer(&mut s, PointerKind::Up, 135.0, 125.0);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, expected);
assert_eq!(s.doc().unwrap().selection.objects, vec![bottom]);
assert_eq!(json!(s.doc().unwrap().doc.layers), layers);
}
#[test]
fn cancelled_clicks_do_not_create_an_artboard_or_leak_into_the_next_gesture() {
for switch in [false, true] {
let (mut s, _) = fixture(100.0, 100.0);
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
if switch {
s.select_tool("selection", ViewInfo::default()).unwrap();
s.select_tool("artboard", ViewInfo::default()).unwrap();
} else {
s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
s.select_tool("artboard", ViewInfo::default()).unwrap();
}
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
assert_eq!(s.doc().unwrap().history.undo.len(), history);
pointer(&mut s, PointerKind::Down, 300.0, 300.0);
pointer(&mut s, PointerKind::Up, 300.0, 300.0);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "empty clicks do not use a stale object");
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
}
}
#[test]
fn locked_hidden_modifier_handle_and_drag_gestures_keep_their_existing_meaning() {
for command in ["object.lock", "object.hide"] {
let (mut s, _) = fixture(100.0, 100.0);
s.execute(command, &json!({})).unwrap();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
pointer(&mut s, PointerKind::Down, 140.0, 125.0);
pointer(&mut s, PointerKind::Up, 140.0, 125.0);
assert_eq!(json!(s.doc().unwrap().doc), json!(before));
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
for mods in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] {
let (mut s, _) = fixture(100.0, 100.0);
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 140.0, 125.0).with_mods(mods), ViewInfo::default()).unwrap();
}
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
let (mut s, _) = fixture(0.0, 0.0);
pointer(&mut s, PointerKind::Down, 0.0, 0.0);
pointer(&mut s, PointerKind::Up, 0.0, 0.0);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "an artboard handle wins over art beneath it");
for (x, y) in [(100.0, 100.0), (650.0, 700.0)] {
let (mut s, _) = fixture(x, y);
pointer(&mut s, PointerKind::Down, x + 40.0, y + 25.0);
pointer(&mut s, PointerKind::Drag, x + 80.0, y + 50.0);
pointer(&mut s, PointerKind::Drag, x + 40.0, y + 25.0);
pointer(&mut s, PointerKind::Up, x + 40.0, y + 25.0);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a drag returning to its start does not become an object click");
}
}
+12 -1
View File
@@ -485,6 +485,15 @@ fn mutate_text(src: &str, cut: usize, edits: &[(usize, char)]) -> String {
chars.into_iter().collect()
}
/// Type shifted two billion points down exports to PDF: an input of
/// `svg_hostile_markup_never_panics`, cut down to its type.
#[test]
fn svg_type_shifted_two_billion_points_exports_to_pdf() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><text baseline-shift="-2147483649">f</text></svg>"#;
let d = vectorcraft_svg::import(svg).unwrap();
assert!(vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default()).is_ok());
}
proptest! {
#![proptest_config(config())]
@@ -2083,7 +2092,7 @@ proptest! {
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
/// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
use vectorcraft_testkit::ai;
@@ -2098,6 +2107,8 @@ proptest! {
bytes.truncate(cut.max(4));
let file = ai::ai(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
let file = ai::pdf(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.pdf", &file).ok().map(|l| l.doc))?;
let file = ai::eps(&bytes, ai::page_ps());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
}
+99
View File
@@ -0,0 +1,99 @@
//! #1212: ruler coordinates are saved metadata, independent of the artwork's coordinates.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_format::{SaveOptions, load, save, save_with};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 125})).unwrap();
s
}
fn origin(s: &mut Session) -> Value {
s.execute("document.rulerOrigin", &json!({})).unwrap()
}
#[test]
fn changing_origin_preserves_artwork_and_selection_and_is_one_undo_step() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
let p = json!({"x": 125.5, "y": -30.25});
s.execute("document.setRulerOrigin", &p).unwrap();
assert_eq!(origin(&mut s), p);
assert_eq!(s.doc().unwrap().selection, selection);
let mut changed = s.execute("document.json", &json!({})).unwrap();
changed.as_object_mut().unwrap().remove("ruler_origin");
assert_eq!(changed, serde_json::to_value(&*before).unwrap(), "only the origin changed");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let revision = s.doc().unwrap().revision;
s.execute("document.setRulerOrigin", &p).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "same origin is a no-op");
assert_eq!(s.doc().unwrap().revision, revision);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(&*s.doc().unwrap().doc, &*before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(origin(&mut s), p);
}
#[test]
fn reset_uses_the_requested_artboards_top_left_and_ignores_noops() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
s.execute("document.setRulerOrigin", &json!({"x": 25, "y": 50})).unwrap();
let boards = s.doc().unwrap().doc.artboards.clone();
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
assert_eq!(origin(&mut s), json!({"x": 700.0, "y": -250.0}));
assert_eq!(s.doc().unwrap().doc.artboards, boards);
let undo = s.doc().unwrap().history.undo.len();
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
for p in [json!({"artboard": 9}), json!({"artboard": -1}), json!({"artboard": "first"})] {
assert!(s.execute("document.resetRulerOrigin", &p).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("document.resetRulerOrigin", &json!({})).unwrap();
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
}
#[test]
fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero() {
let mut s = session();
let legacy = String::from_utf8(save(&s.doc().unwrap().doc, false)).unwrap().replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
assert!(serde_json::to_value(load(legacy.as_bytes()).unwrap()).unwrap().get("ruler_origin").is_none());
let p = json!({"x": -12.5, "y": 27.75});
s.execute("document.setRulerOrigin", &p).unwrap();
let doc = &s.doc().unwrap().doc;
for (version, compress) in [(1, false), (2, false), (3, false), (4, false), (4, true)] {
let bytes = save_with(doc, &SaveOptions { version, compress, ..Default::default() }).unwrap();
let back = load(&bytes).unwrap();
assert_eq!(serde_json::to_value(&back).unwrap()["ruler_origin"], p, "v{version}, compression {compress}");
assert_eq!(back.layers, doc.layers);
assert_eq!(back.guides, doc.guides);
}
}
#[test]
fn origin_rejects_invalid_coordinates_without_changing_absolute_command_coordinates() {
let mut s = session();
s.execute("document.setRulerOrigin", &json!({"x": 200, "y": 150})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for p in
[json!({"x": 4_000_001, "y": 0}), json!({"x": 0, "y": -4_000_001}), json!({"x": "10", "y": 0}), json!({"x": 0}), json!({"x": null, "y": 0})]
{
assert!(s.execute("document.setRulerOrigin", &p).is_err(), "{p}");
assert_eq!(&*s.doc().unwrap().doc, &*before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("object.setBounds", &json!({"x": 80, "y": 40, "reference": 0})).unwrap();
let bounds = s.doc().unwrap().doc.bounds_of(&s.doc().unwrap().selection.objects, false).unwrap();
assert_eq!((bounds.x0, bounds.y0), (80.0, 40.0), "engine coordinates stay absolute");
assert_eq!(origin(&mut s), json!({"x": 200.0, "y": 150.0}));
}
+19 -11
View File
@@ -113,20 +113,24 @@ impl<'a> Lexer<'a> {
continue;
}
if b == b'%' {
if self.at_line_start() {
if self.rest().starts_with(b"%_") {
self.pos += 2;
self.hidden = true;
continue;
}
let line = self.line();
if let Some(t) = self.special(line) {
return Some(t);
}
let start = self.at_line_start();
if start && self.rest().starts_with(b"%_") {
self.pos += 2;
self.hidden = true;
continue;
}
// Right after `%_` a section still starts (a hidden image's data, #1264).
let prefixed = self.hidden && self.pos >= 2 && self.src.get(self.pos - 2..self.pos) == Some(b"%_");
let line = self.line();
if (start || prefixed)
&& let Some(t) = self.special(line)
{
return Some(Token { hidden: prefixed, ..t });
}
if start {
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
}
// A comment later in a line.
self.line();
continue;
}
let hidden = self.hidden;
@@ -310,6 +314,10 @@ mod tests {
let toks = all(src);
assert!(toks.contains(&t(Tok::Data(b"(%)A"), false)), "{toks:?}");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"XH"), false)));
// #1264: on `%_` lines too, where the samples would otherwise be read as tokens.
let toks = all(b"%_[1 0 0 1 0 0] XI\r%_%%BeginData: 7\rXI\n(U)A%%EndData\r%_XH\ru\r");
assert!(toks.contains(&t(Tok::Data(b"(U)A"), true)), "{toks:?}");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"u"), false)), "{toks:?}");
// A section of hex lines is comments.
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
+14 -11
View File
@@ -1080,29 +1080,32 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
}
}
/// The `.ai` `visible` (read as its PDF part) read through its editing copy (`private`, the joined
/// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the
/// file, its artboards, and the art that lies outside them, which its PDF part doesn't have.
/// The `.ai` `visible` (read as its PDF part), or a PDF saved with the editing data, read through
/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead,
/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the
/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have.
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
/// `outlined`: the PDF part's type was read as outlines (a file whose type shows then comes in as
/// its PDF part).
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
/// The flag returned is `true` when the document is the one read from the layers; else the document
/// is `visible`, its pages moved by `place_pages` when the layers can't be read. `outlined`: the
/// PDF part's type was read as outlines (a file whose type shows then comes in as its PDF part).
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>, bool) {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings, false) };
if !has_layers(&data) {
return (visible, warnings);
return (visible, warnings, false);
}
match build(&data, Some(&visible), Page::Artboard, outlined) {
Ok((done, notes)) => {
Ok((mut done, notes)) => {
done.setup.bleed = visible.setup.bleed;
let mut all = warnings;
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
(done, all)
(done, all, true)
}
Err(why) => {
let mut all = warnings;
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
let mut visible = visible;
place_pages(&ai::artboards(&data), &mut visible);
(visible, all)
(visible, all, false)
}
}
}
+11 -10
View File
@@ -215,8 +215,9 @@ fn pdf_part(x: f64, y: f64) -> Document {
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
assert!(read);
assert_eq!(warnings, ["a note"], "nothing else to say");
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}");
@@ -227,12 +228,12 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
let pdf = pdf_part(10.0, 86.0);
// No editing data this reads.
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
assert_eq!(d.layers.len(), pdf.layers.len());
assert!(w.is_empty());
assert!(w.is_empty() && !read);
// Editing data that isn't for this page.
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
assert_eq!(names(&d), names(&pdf));
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
@@ -266,8 +267,8 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(page);
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
let moved = squares(&d);
@@ -275,7 +276,7 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
// Artboards that don't match the pages leave them in a row.
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
}
}
@@ -597,7 +598,7 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
let art = native(&format!("{dots}{ground}"));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
@@ -682,12 +683,12 @@ fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
let (d, w) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
// Type only on a hidden layer: the layers come in.
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
}
+82
View File
@@ -100,6 +100,18 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
if op == PathfinderOp::Unite {
return unite();
}
// Open paths are cutting edges for Divide; the filled-only operations keep their
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
if op == PathfinderOp::Divide && shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) {
return crate::shape_builder(shapes, false)
.regions
.into_iter()
.filter_map(|r| {
let key = shapes.get(r.top()?)?.key;
Some(Shape::new(r.path, FillRule::NonZero, key))
})
.collect();
}
let Some(arr) = arrangement(shapes) else { return Vec::new() };
let p = &arr.tidy;
match op {
@@ -564,4 +576,74 @@ mod tests {
same_as_one_mask_at_a_time(&v);
}
}
#[test]
fn divide_open_paths_cut_faces_without_taking_the_cutters_paint() {
let line = |a: (f64, f64), b: (f64, f64)| shapes::line(Point::from(a), Point::from(b));
let mut cases = vec![
("horizontal", vec![line((-10.0, 50.0), (110.0, 50.0))], 2),
("reverse", vec![line((110.0, 50.0), (-10.0, 50.0))], 2),
("vertical", vec![line((50.0, -10.0), (50.0, 110.0))], 2),
("diagonal", vec![line((-10.0, -10.0), (110.0, 110.0))], 2),
("crossed", vec![line((-10.0, 50.0), (110.0, 50.0)), line((50.0, -10.0), (50.0, 110.0))], 4),
("outside", vec![line((-10.0, 150.0), (110.0, 150.0))], 1),
("dangling", vec![line((-10.0, 50.0), (50.0, 50.0))], 1),
("boundary", vec![line((-10.0, 0.0), (110.0, 0.0))], 1),
("tangent", vec![line((-10.0, 10.0), (10.0, -10.0))], 1),
];
let mut curve = BezPath::new();
curve.move_to((-10.0, 50.0));
curve.curve_to((30.0, 15.0), (70.0, 85.0), (110.0, 50.0));
cases.push(("curved", vec![PathData::from_bezpath(&curve)], 2));
let mut bent = BezPath::new();
bent.move_to((-10.0, 25.0));
bent.line_to((50.0, 75.0));
bent.line_to((110.0, 25.0));
cases.push(("bent", vec![PathData::from_bezpath(&bent)], 2));
for (label, cutters, count) in cases {
let mut inputs = vec![Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7)];
inputs.extend(cutters.into_iter().map(|p| Shape::new(p, FillRule::NonZero, 99)));
let divided = pathfinder(PathfinderOp::Divide, &inputs);
assert_eq!(divided.len(), count, "{label}");
assert!(divided.iter().all(|s| s.key == 7 && s.path.subpaths.iter().all(|sp| sp.closed)), "{label}");
let total: f64 = divided.iter().map(|s| crate::area(&s.path, s.rule)).sum();
assert!((total - 10000.0).abs() < 0.01, "{label}: area {total}");
for p in [Point::new(20.0, 31.0), Point::new(79.0, 37.0), Point::new(81.0, 68.0), Point::new(17.0, 85.0)] {
assert_eq!(divided.iter().filter(|s| s.path.to_bezpath().winding(p) != 0).count(), 1, "{label}: {p:?}");
}
}
}
#[test]
fn divide_open_paths_preserve_frontmost_fills_and_holes() {
let cutter = || Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(160.0, 50.0)), FillRule::NonZero, 99);
let inputs = [
Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7),
Shape::new(rect(50.0, 0.0, 150.0, 100.0), FillRule::NonZero, 8),
cutter(),
];
let faces = pathfinder(PathfinderOp::Divide, &inputs);
assert_eq!(faces.len(), 6);
assert_eq!(faces.iter().filter(|s| s.key == 7).count(), 2);
assert_eq!(faces.iter().filter(|s| s.key == 8).count(), 4);
let total: f64 = faces.iter().map(|s| crate::area(&s.path, s.rule)).sum();
assert!((total - 15000.0).abs() < 1e-6);
let mut ring = rect(0.0, 0.0, 100.0, 100.0);
ring.subpaths.extend(rect(30.0, 30.0, 70.0, 70.0).subpaths);
let faces = pathfinder(PathfinderOp::Divide, &[Shape::new(ring, FillRule::EvenOdd, 7), cutter()]);
assert_eq!(faces.len(), 2);
assert!(faces.iter().all(|s| s.key == 7 && s.path.to_bezpath().winding(Point::new(50.0, 40.0)) == 0));
let total: f64 = faces.iter().map(|s| crate::area(&s.path, s.rule)).sum();
assert!((total - 8400.0).abs() < 1e-6);
}
#[test]
fn divide_open_paths_retains_the_existing_segment_cap() {
let mut inputs = vec![Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7)];
inputs.extend((0..4093).map(|_| Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0)), FillRule::NonZero, 99)));
let faces = pathfinder(PathfinderOp::Divide, &inputs);
assert_eq!(faces.len(), 1, "4097 segments fall back to filled areas");
assert_eq!(faces[0].key, 7);
assert!((crate::area(&faces[0].path, faces[0].rule) - 10000.0).abs() < 1e-6);
}
}
+139 -40
View File
@@ -14,7 +14,7 @@ use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::text::{GlyphId, KrillaGlyph};
use krilla::text::{Glyph, GlyphId, KrillaGlyph};
use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
@@ -229,8 +229,10 @@ impl Writer {
if sheet.negative {
cover(&mut s, paper, 255, 1.0);
}
// A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank.
let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO);
s.push_transform(&xf(sheet.view));
let window = sheet.window.and_then(|r| to_path(&r.to_path(0.1)));
let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1)));
if let Some(clip) = &window {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
@@ -239,7 +241,7 @@ impl Writer {
s.push_transform(&xf(sheet.place));
}
let r = sheet.area;
let clip = sheet.clip.then(|| to_path(&r.to_path(0.1))).flatten();
let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten();
if let Some(clip) = &clip {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
@@ -248,15 +250,17 @@ impl Writer {
// at the top of a page is put in a non-isolated group of its own, whose blending space
// `cmyk_blending` rewrites.
let doc = ex.doc;
let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency();
let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency();
if page_group {
s.push_isolated();
} else if cmyk_page_group {
let all = constant_mask(&mut s, r, 1.0);
s.push_mask(all);
}
ex.children(&mut s, &doc.layers, r);
if fits {
ex.children(&mut s, &doc.layers, r);
}
if page_group || cmyk_page_group {
s.pop();
}
@@ -266,7 +270,7 @@ impl Writer {
if placed {
s.pop();
}
if let Some(art) = sheet.marks {
if fits && let Some(art) = sheet.marks {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let knockout = std::mem::replace(&mut ex.knockout, false);
@@ -525,12 +529,43 @@ struct GlyphLine {
/// Most pixels along a side of a freeform gradient's image (its colour field is smooth).
const MAX_FIELD_PX: f64 = 512.0;
/// The largest magnitude a coordinate, length or transform entry may have in the content stream it
/// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left
/// out. A PDF/A-2 file may hold only integers from -2,147,483,648 to 2,147,483,647 (ISO 19005-2,
/// 6.1.13). The PDF writer writes a whole number in the `i32` range as an integer, and in builds
/// with overflow checks it panics on -2,147,483,648.
const MAX_COORD: f64 = 1e9;
fn xf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
fn to_path(bp: &BezPath) -> Option<Path> {
/// The current transform of surface `s` in the content stream it draws into, without that stream's
/// root transform (a page's y flip).
fn ctm(s: &Surface) -> Affine {
let t = s.ctm();
Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from))
}
/// Are `values` finite and within [`MAX_COORD`]?
fn in_range(values: &[f64]) -> bool {
values.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
}
/// Can art spanning `b`, in the space `space` maps into the one `s` draws in, be written: `b`, the
/// transform of `s` followed by `space`, and `b` through that transform all within [`MAX_COORD`]?
fn writable(s: &Surface, space: Affine, b: Rect) -> bool {
let t = ctm(s) * space;
let to = t.transform_rect_bbox(b);
in_range(&t.as_coeffs()) && in_range(&[b.x0, b.y0, b.x1, b.y1, to.x0, to.y0, to.x1, to.y1])
}
/// `bp` as a path to draw on `s`; `None` when it is empty or reaches beyond [`MAX_COORD`] there.
fn to_path(s: &Surface, bp: &BezPath) -> Option<Path> {
if !writable(s, Affine::IDENTITY, bp.control_box()) {
return None;
}
let mut pb = PathBuilder::new();
for el in bp.elements() {
match *el {
@@ -597,7 +632,7 @@ fn color(c: &Color) -> krilla::color::Color {
/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask
/// backdrops.
fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) {
let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return };
let Some(p) = to_path(s, &page.inflate(1.0, 1.0).to_path(0.1)) else { return };
s.set_stroke(None);
s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
@@ -718,14 +753,15 @@ impl Exporter<'_> {
}
}
/// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer).
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
self.paint_in(p, bounds, Affine::IDENTITY)
/// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the
/// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint.
fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
self.paint_in(s, p, bounds, Affine::IDENTITY)
}
/// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds`
/// is) to the one drawn in.
fn paint_in(&mut self, p: &Paint, bounds: Rect, space: Affine) -> Option<krilla::paint::Paint> {
fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option<krilla::paint::Paint> {
match p {
Paint::None => None,
Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()),
@@ -761,6 +797,9 @@ impl Exporter<'_> {
}
match g.gradient.kind {
GradientKind::Linear => {
if !writable(s, space, Rect::from_points(geom.start, geom.end)) {
return None;
}
let (s, mut e) = (geom.start, geom.end);
if s.distance(e) < 1e-9 {
e = s + Vec2::new(1.0, 0.0);
@@ -785,6 +824,10 @@ impl Exporter<'_> {
let (cx, cy) = (geom.start.x as f32, geom.start.y as f32);
// An off-centre focal point: a two-point radial shading from it.
let f = t.inverse() * geom.focal_point();
let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f);
if !writable(s, space * t, circles) {
return None;
}
Some(
RadialGradient {
fx: f.x as f32,
@@ -968,7 +1011,7 @@ impl Exporter<'_> {
// The region every output clips to; nothing to clip by hides the clipped art. The
// clipping path's fill paints behind the clipped art and its stroke over it, unclipped.
if let Some((clip, rest)) = children.split_first()
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r)))
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r)))
{
let paint = clip.clip_paint();
s.push_clip_path(&p, &rule(r));
@@ -1020,7 +1063,7 @@ impl Exporter<'_> {
}
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
let Some(path) = to_path(bp) else { return };
let Some(path) = to_path(s, bp) else { return };
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
@@ -1033,7 +1076,7 @@ impl Exporter<'_> {
if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
return;
}
let Some(paint) = ex.paint(&fl.paint, bounds) else { return };
let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
@@ -1166,14 +1209,18 @@ impl Exporter<'_> {
// The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as
// Outline Stroke makes it, painted with the pattern's tiles or the freeform's image.
let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath();
if let Some(clip) = to_path(&region) {
if let Some(clip) = to_path(s, &region) {
let area = region.bounding_box();
self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area);
}
return;
}
let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return };
let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return };
let w = stroke::for_writer(bp, st);
// A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out.
if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) {
return;
}
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
@@ -1189,7 +1236,7 @@ impl Exporter<'_> {
// (miter spikes included) plus the path, even-odd.
let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(&outside) {
if let Some(p) = to_path(s, &outside) {
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
pushes += 1;
}
@@ -1203,7 +1250,11 @@ impl Exporter<'_> {
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
StrokeDash { array: pat, offset: d.offset as f32 }
// An offset beyond [`MAX_COORD`] is written as its remainder after whole
// periods of the pattern.
let period: f64 = pat.iter().copied().map(f64::from).sum();
let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) };
StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } }
});
s.set_fill(None);
s.set_stroke(Some(Stroke {
@@ -1230,7 +1281,7 @@ impl Exporter<'_> {
// A gradient along or across the stroke: slices clipped to its outlines, under
// the stroke's opacity as a group.
if let Some(ws) = stroke::written_slices(bp, r, st, outlines)
&& let Some(clip) = to_path(&ws.clip)
&& let Some(clip) = to_path(s, &ws.clip)
{
self.warn("gradients along or across strokes are exported as slices of linear gradients");
if st.opacity < 1.0 {
@@ -1241,7 +1292,7 @@ impl Exporter<'_> {
s.set_stroke(None);
for (shape, paint) in &ws.slices {
let b = shape.bounding_box();
if let (Some(paint), Some(p)) = (self.paint(paint, b), to_path(shape)) {
if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) {
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
}
@@ -1260,8 +1311,10 @@ impl Exporter<'_> {
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
for p in outlines.iter().filter_map(to_path) {
s.draw_path(&p);
for outline in outlines {
if let Some(p) = to_path(s, outline) {
s.draw_path(&p);
}
}
s.set_fill(None);
}
@@ -1315,14 +1368,16 @@ impl Exporter<'_> {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(&all).map(|p| (all, p))
to_path(s, &all).map(|p| (all, p))
});
let all = all.flatten();
if let Some((bp, path)) = &all {
self.text_items(s, below, bp, path, page, tb);
}
s.push_transform(&xf(t.xf));
// Real text: the characters' fills as text in embedded fonts.
// Real text: the characters' fills as text in embedded fonts. The type's transform is
// pushed for each fill and stroke of the characters, inside its form when the fill or
// stroke overprints.
let text_space = xf(t.xf);
let plain = (!self.outline_text).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
@@ -1338,6 +1393,7 @@ impl Exporter<'_> {
bp.extend(run_bars.iter());
let fill = &run.style.fill;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
s.push_transform(&text_space);
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => {
@@ -1348,26 +1404,31 @@ impl Exporter<'_> {
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
if let Some(path) = to_path(&outlines)
if let Some(path) = to_path(s, &outlines)
&& !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
&& let Some(paint) = ex.paint(fill, layout.bounds)
&& let Some(paint) = ex.paint(s, fill, layout.bounds)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
s.pop();
});
if run.style.has_stroke()
&& let Some(path) = to_path(&bp)
{
s.push_transform(&text_space);
let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten();
s.pop();
if let Some(path) = path {
// Character strokes are drawn in text space, with their cap, join and dashes.
let st = run.style.stroke_layer();
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds));
self.overprinting(s, st.overprint, &st.paint, |ex, s| {
s.push_transform(&text_space);
ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds);
s.pop();
});
}
}
s.set_fill(None);
s.set_stroke(None);
s.pop();
if self.inline_depth < MAX_INLINE_DEPTH {
self.inline_depth += 1;
for ig in &layout.inlines {
@@ -1393,7 +1454,7 @@ impl Exporter<'_> {
match item {
AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
&& let Some(paint) = ex.paint(&fl.paint, tb)
&& let Some(paint) = ex.paint(s, &fl.paint, tb)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
@@ -1468,9 +1529,9 @@ impl Exporter<'_> {
}
/// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where
/// `bounds` is).
/// `bounds` is). A line that would be written with a pen position, size or spacing (in
/// thousandths of an em) beyond [`MAX_COORD`] is left out.
fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) {
let Some(paint) = self.paint_in(fill, bounds, line.frame.inverse()) else { return };
let size = line.size;
let glyphs: Vec<KrillaGlyph> = line
.glyphs
@@ -1481,10 +1542,18 @@ impl Exporter<'_> {
KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None)
})
.collect();
let pens =
line.glyphs.iter().fold(Rect::new(0.0, -size, 0.0, size), |r, (_, x, advance, _)| r.union_pt((*x, 0.0)).union_pt((x + advance, 0.0)));
let spacing: Vec<f64> = glyphs.iter().map(|g| f64::from(g.x_advance(1.0)) * 1000.0).collect();
if !(writable(s, line.frame, pens) && in_range(&spacing)) {
return;
}
s.push_transform(&xf(line.frame));
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false);
if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false);
}
s.pop();
}
@@ -1558,6 +1627,10 @@ impl Exporter<'_> {
}
fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) {
// An image placed or sized beyond [`MAX_COORD`] is left out.
if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) {
return;
}
// The size it is placed at: its pixel grid's sides through its transform.
let [a, b, c, d, ..] = im.xf.as_coeffs();
let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d));
@@ -1586,3 +1659,29 @@ fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image>
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writable_bounds_values_the_transform_and_values_through_it() {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap()));
let mut s = page.surface();
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
assert!(writable(&s, Affine::IDENTITY, r));
assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0)));
assert!(!writable(&s, Affine::IDENTITY, Rect::new(f64::NAN, 0.0, 10.0, 10.0)));
assert!(!writable(&s, Affine::translate((0.0, 2e9)), r));
assert!(!writable(&s, Affine::scale(2e9), Rect::ZERO));
// `writable` also checks the surface's own transform: with the surface moved to
// x = MAX_COORD, a point at the origin fits and a 10 by 10 box does not.
s.push_transform(&xf(Affine::translate((MAX_COORD, 0.0))));
assert!(writable(&s, Affine::IDENTITY, Rect::ZERO));
assert!(!writable(&s, Affine::IDENTITY, r));
s.pop();
s.finish();
page.finish();
}
}
+13 -3
View File
@@ -22,7 +22,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -136,7 +136,7 @@ pub const OFF_ARTBOARD_NOTE: &str =
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
/// Warning: art reaching past the page box was clipped to it.
pub(crate) const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it";
pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it";
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
@@ -200,6 +200,13 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let (mut order, mut known) = (vec![], HashSet::new());
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// Artboards on the trim boxes have the bleed boxes round them as the document's bleed
// (the widest of the pages' on each side), so the art there shows and prints as bleed.
if opts.crop == CropTo::Trim {
for (side, b) in doc.setup.bleed.iter_mut().zip(crate::pages::bleed(page)) {
*side = side.max(b);
}
}
// The chosen box sits at (x, 0); the page draws round it.
let (init, frame) = crate::pages::frame(page, opts.crop);
let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height());
@@ -774,7 +781,7 @@ fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
}
/// `p` with every subpath closed: how a fill reads it.
fn closed_subpaths(p: &BezPath) -> BezPath {
pub(crate) fn closed_subpaths(p: &BezPath) -> BezPath {
let mut out = BezPath::new();
let mut open = false;
for el in p.elements() {
@@ -1062,6 +1069,9 @@ impl<'p> Builder<'p> {
// A rectangle around all of its art (a form's box) clips nothing.
grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g));
None
} else if let Some(shape) = gradient_shape(&clip, &f.children) {
// One path, so tools such as the Shape Builder see the shape, not its box (#1304).
Some(shape)
} else {
let mut ch = vec![Arc::new(*clip)];
ch.extend(f.children);
+27 -5
View File
@@ -1,7 +1,8 @@
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
//! and triangle meshes become gradient meshes, a gradient drawn through a luminosity mask that is
//! the same gradient in grey (how stop opacity is written) gets its opacity stops back, and a shape
//! clipping a gradient that fills it becomes one path with the gradient.
use std::sync::Arc;
@@ -10,11 +11,12 @@ use hayro_interpret::shading::{ShadingFunction, ShadingType};
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::live::{GradientMesh, lerp_color};
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
use vectorcraft_geom::PathData;
use crate::import::round3;
use crate::import::{closed_subpaths, round3};
use crate::import_color::{Colors, Native};
use crate::import_mask::luminance;
use crate::import_mask::{contains, is_rectangle, luminance};
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
/// colour.
@@ -266,3 +268,23 @@ pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<No
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
}
/// A shape filled with a gradient, as files write it (the shape as a clip, then `sh` filling the
/// clip's bounds), as one path: the clip's outline with `content`'s look. `None` unless `content`
/// is one rectangle covering the clip, filled with a gradient placed on the page and nothing else.
pub(crate) fn gradient_shape(clip: &Node, content: &[Arc<Node>]) -> Option<Node> {
let ([only], NodeKind::Path { path: outline, rule, .. }) = (content, &clip.kind) else { return None };
let NodeKind::Path { path: area, clipping: false, guide: false, .. } = &only.kind else { return None };
let [AppearanceItem::Fill(f)] = only.appearance.items.as_slice() else { return None };
let Paint::Gradient(g) = &f.paint else { return None };
let (area, outline) = (area.to_bezpath(), outline.to_bezpath());
let covers = is_rectangle(&area) && contains(area.bounding_box().inflate(0.01, 0.01), outline.bounding_box());
if g.geom.is_none() || !covers {
return None;
}
let mut shape = (**only).clone();
// A clip closes its subpaths, as a fill does.
let path = PathData::from_bezpath(&closed_subpaths(&outline));
shape.kind = NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false };
Some(shape)
}
+3 -2
View File
@@ -69,8 +69,8 @@ mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use import::{OFF_ARTBOARD_NOTE, import, import_with_report};
pub use pages::{PageInfo, PdfInfo, illustrator_data, info, is_postscript};
pub use import::{OFF_ARTBOARD_NOTE, PAST_PAGE_NOTE, import, import_with_report};
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
@@ -148,6 +148,7 @@ settings::choice! {
Art = "art", "Art";
/// The visible page area (what viewers show).
Crop = "crop", "Crop";
/// The finished page: an import takes the bleed box round it as the document's bleed.
Trim = "trim", "Trim";
Bleed = "bleed", "Bleed";
/// The whole sheet.
+21 -2
View File
@@ -5,6 +5,7 @@ use hayro_syntax::object::{Dict, Stream};
use hayro_syntax::page::Page;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
use vectorcraft_doc::setup::MAX_BLEED;
use crate::{Choice, CropTo, ImportOptions, PdfError};
@@ -128,6 +129,15 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
}
/// How far `page`'s bleed box reaches past its trim box as the page shows (rotated): the bleed
/// `[top, bottom, left, right]` in points, kept to 0–[`MAX_BLEED`].
pub(crate) fn bleed(page: &Page<'_>) -> [f64; 4] {
let init = Affine::new(page.initial_transform(true).as_coeffs());
let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed)));
[trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1]
.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
}
/// The `PieceInfo` key such files keep their private data under.
const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use
@@ -135,20 +145,29 @@ const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files
const MAX_PRIVATE: usize = 256 << 20;
/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData`
/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read).
/// streams in order, joined. A PDF saved with the editing data has its streams under
/// `AIPDFPrivateData1`, `AIPDFPrivateData2`, … instead: those are read when there is no
/// `AIPrivateData1`. `None` for a PDF without either (or with a stream that can't be read).
/// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`).
pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>> {
let pdf = open(bytes, password).ok()?;
let page = pdf.pages().first()?;
let private = page.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(b"Private")?;
let key = if private.contains_key(b"AIPrivateData1") { "AIPrivateData" } else { "AIPDFPrivateData" };
let mut data = vec![];
for n in 1..=100_000u32 {
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{n}").as_bytes()) else { break };
let Some(stream) = private.get::<Stream<'_>>(format!("{key}{n}").as_bytes()) else { break };
data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?);
}
(!data.is_empty()).then_some(data)
}
/// Does the PDF's first page carry an editor's private data (a PDF-compatible `.ai`)? Without
/// reading the data.
pub fn has_illustrator_data(bytes: &[u8], password: Option<&str>) -> bool {
open(bytes, password).is_ok_and(|pdf| pdf.pages().first().is_some_and(has_private_data))
}
/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's
/// `decoded()`, which has no limit, so a stream of zeros can't grow to the size of the memory. Only a
/// stream that is plain or only deflated is read (the private data is no other); else, or when it
+74 -1
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject,
};
@@ -268,6 +268,79 @@ fn objects_outside_artboard_are_culled() {
assert_eq!(leaves(&roundtrip(&d)).len(), 1);
}
/// A line from (10, `y`) to (90, `y`).
fn line(y: f64) -> PathData {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((10.0, y));
bp.line_to((90.0, y));
PathData::from_bezpath(&bp)
}
/// Art that the PDF would hold a value beyond a billion points for is left out: a path that far,
/// a gradient placed that far, a stroke so wide that its group's box reaches that far, and an
/// image placed that far. Each would be written with -2,147,483,648, which the PDF writer panics
/// on in builds with overflow checks.
#[test]
fn art_reaching_beyond_a_billion_points_is_left_out() {
let far = -2_147_483_649.0;
let shaded = |kind, start, end| {
let mut n = rect_node(Rect::new(60.0, 10.0, 90.0, 50.0), Color::BLACK);
let geom = GradientGeom { start, end, aspect: 1.0, focal: None };
let g = GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient { kind, ..Gradient::default() }) };
n.appearance.set_fill(Paint::Gradient(Box::new(g)));
n
};
let mut wide = Node::path(NodeId(0), line(0.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4_294_967_296.0));
wide.opacity = 0.5;
let image = ImageObject {
key: "img1".into(),
width: 4,
height: 2,
xf: Affine::new([5.5e8, 0.0, 0.0, 10.0, far, 60.0]),
link: None,
placement: Default::default(),
};
for (what, n) in [
("path", rect_node(Rect::new(far, 60.0, 90.0, 90.0), Color::BLACK)),
("linear gradient", shaded(GradientKind::Linear, Point::new(far, 0.0), Point::new(90.0, 0.0))),
("radial gradient", shaded(GradientKind::Radial, Point::new(far, 30.0), Point::new(far + 100.0, 30.0))),
("stroke", wide),
("image", Node::new(NodeId(0), NodeKind::Image(Box::new(image)))),
] {
let mut d = doc(100.0, 100.0);
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
add(&mut d, n);
assert_eq!(leaves(&roundtrip(&d)).len(), 1, "{what}");
}
}
/// A dash offset beyond a billion points is written as its remainder after whole periods of the
/// pattern: -2,147,483,649 (-2,147,483,648 as an f32, which the PDF writer panics on in builds
/// with overflow checks) is 1 in a pattern of 5 and 5.
#[test]
fn dash_offsets_beyond_a_billion_points_wrap_into_their_pattern() {
let mut d = doc(100.0, 100.0);
let mut n = Node::path(NodeId(0), line(50.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
n.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![5.0, 5.0], offset: -2_147_483_649.0, align_corners: false });
add(&mut d, n);
assert!(uncompressed(&d).contains("[5 5]1 d"));
}
/// The page of an artboard beyond a billion points is left blank: what is on it would be placed
/// through -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
#[test]
fn artboards_beyond_a_billion_points_export_blank_pages() {
let mut d = doc(100.0, 100.0);
d.artboards[0].rect = Rect::new(2_147_483_649.0, 0.0, 2_147_483_749.0, 100.0);
let mut n = rect_node(Rect::new(2_147_483_659.0, 10.0, 2_147_483_699.0, 50.0), Color::BLACK);
n.opacity = 0.5;
add(&mut d, n);
let bytes = export(&d, &PdfOptions::default()).unwrap();
assert_eq!(page_count(&bytes), 1);
assert!(leaves(&import(&bytes).unwrap()).is_empty());
}
#[test]
fn opacity_and_blend_modes() {
let mut d = doc(200.0, 200.0);
+34 -2
View File
@@ -1,6 +1,7 @@
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
//! and shading Extend flags; strokes stay live strokes.
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity,
//! shading Extend flags and shapes filled with a shading → gradient-filled paths; strokes stay
//! live strokes.
use std::sync::Arc;
@@ -352,6 +353,37 @@ fn a_shading_that_does_not_extend_paints_only_between_its_ends() {
assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}");
}
/// A diamond (10–90 × 10–90) clipping `paint`, which fills its bounds: how a shape filled with a
/// gradient is written (`W n`, then `sh`).
fn clipped_diamond(paint: &str) -> Document {
let shading = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [10 0 90 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>";
let resources = format!("/Shading << /Sh0 {shading} >> /Pattern << /P0 << /PatternType 2 /Shading {shading} >> >>");
open(&one_page(&format!("q 10 50 m 50 90 l 90 50 l 50 10 l h W n {paint} Q"), &resources, &[]))
}
#[test]
fn a_shape_filled_with_a_shading_becomes_one_path_with_the_gradient() {
let d = clipped_diamond("/Sh0 sh");
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "no group");
let objs = objects(&d);
let [n] = objs.as_slice() else { panic!("one object: {objs:#?}") };
let p = n.path_data().unwrap();
assert!(p.is_closed() && p.anchor_count() == 4, "the diamond: {p:?}");
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", n.geometric_bounds());
let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) else { panic!("a gradient fill: {:?}", n.appearance.items) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
assert_eq!(g.gradient.stops.first().map(|s| s.color.to_rgb()), Some([1.0, 0.0, 0.0]));
assert_eq!(g.gradient.stops.last().map(|s| s.color.to_rgb()), Some([0.0, 0.0, 1.0]));
let geom = g.geom.as_ref().unwrap();
assert!((geom.start.x - 10.0).abs() < 0.01 && (geom.end.x - 90.0).abs() < 0.01, "placed as before: {geom:?}");
assert!(n.appearance.stroke().is_none());
// A gradient that doesn't cover the clip, and a flat colour, stay clipped.
for paint in ["/Pattern cs /P0 scn 40 40 20 20 re f", "1 0 0 rg 0 0 100 100 re f"] {
let d = clipped_diamond(paint);
assert!(all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "{paint}");
}
}
#[test]
fn appearance_items_are_untouched_for_plain_art() {
// Plain art keeps importing as before: no masks, groups or clips appear.
+16
View File
@@ -408,3 +408,19 @@ fn an_ai_files_private_data_is_its_streams_joined() {
let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>");
assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none());
}
/// A PDF saved with the editing data has its streams under `AIPDFPrivateData` keys instead (#868);
/// a file with both kinds is read from its `AIPrivateData` streams.
#[test]
fn a_pdfs_private_data_is_its_pdf_streams_joined() {
let (a, b, c) = (first_extra(1), first_extra(1) + 1, first_extra(1) + 2);
let page = |keys: String| PdfPage {
entries: format!("/PieceInfo << /Illustrator << /Private << {keys} /NumBlock 2 >> >> >>"),
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let (one, two, other) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"), stream("", "%AI24_ZStandard_Data other"));
let pdf = pdf_with_catalog(&[page(format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R"))], &[&one, &two], "", None);
assert_eq!(illustrator_data(&pdf, None).unwrap(), b"%AI24_ZStandard_Data first second");
let both = format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R /AIPrivateData1 {c} 0 R");
assert_eq!(illustrator_data(&pdf_with_catalog(&[page(both)], &[&one, &two, &other], "", None), None).unwrap(), b"%AI24_ZStandard_Data other");
}
+39
View File
@@ -52,6 +52,45 @@ fn each_crop_box_sizes_the_artboard_and_places_the_art_in_it() {
assert_eq!(ImportOptions::default().crop, CropTo::Crop);
}
/// 10 mm in points.
const BLEED: f64 = 10.0 * 72.0 / 25.4;
/// A 300 × 200 sheet with a 200 × 100 trim box, the bleed box 10 mm round it, and a red rectangle
/// reaching 20 pt past the trim box's left and bottom edges into the bleed.
fn bled_page() -> PdfPage {
PdfPage {
bleed: Some([50.0 - BLEED, 50.0 - BLEED, 250.0 + BLEED, 150.0 + BLEED]),
trim: Some([50.0, 50.0, 250.0, 150.0]),
..PdfPage::new(300.0, 200.0, "1 0 0 rg 30 30 100 60 re f")
}
}
/// The same within the precision of a box read from a file (32-bit floats).
fn near(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
}
#[test]
fn trim_box_artboards_take_the_bleed_box_round_them_as_the_document_bleed() {
let bytes = pdf(&[bled_page()], None);
let trim = ImportOptions { crop: CropTo::Trim, ..Default::default() };
let d = open(&bytes, trim.clone());
let ab = d.artboards[0].rect;
assert!(close(ab, Rect::new(0.0, 0.0, 200.0, 100.0)), "{ab:?}");
assert!(near(d.setup.bleed, [BLEED; 4]), "{:?}", d.setup.bleed);
// The rectangle comes in whole, reaching into the bleed (y down: the bottom bleed is below).
assert!(close(art(&d), Rect::new(-20.0, 60.0, 80.0, 120.0)), "{:?}", art(&d));
assert!(d.setup.bleed_rect(ab).contains_rect(art(&d)));
// Each side as the page shows it: PDF space is y up, and a rotated page turns its bleed.
let uneven = PdfPage { bleed: Some([49.0, 48.0, 253.0, 154.0]), ..bled_page() };
let turned = PdfPage { rotate: 90, ..uneven.clone() };
assert!(near(open(&pdf(&[uneven], None), trim.clone()).setup.bleed, [4.0, 2.0, 1.0, 3.0]));
assert!(near(open(&pdf(&[turned], None), trim).setup.bleed, [1.0, 3.0, 2.0, 4.0]));
// On the crop box (the sheet here) there is no bleed round the artboard.
let d = open(&bytes, ImportOptions::default());
assert_eq!((d.artboards[0].rect.width(), d.setup.bleed), (300.0, [0.0; 4]));
}
#[test]
fn missing_boxes_fall_back_to_the_crop_box_and_boxes_are_clipped_to_the_media_box() {
let page = PdfPage { crop: Some([10.0, 10.0, 290.0, 390.0]), art: Some([-50.0, 0.0, 100.0, 100.0]), ..PdfPage::new(300.0, 400.0, "") };
+52
View File
@@ -110,6 +110,58 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// A run shifted two billion points off its baseline is left out, as real text or outlines, and
/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted
/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
#[test]
fn a_run_shifted_two_billion_points_is_left_out() {
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
t.runs = vec![
TextRun { text: "in".into(), style: style(12.0), inline: None },
TextRun { text: "out".into(), style: CharStyle { baseline_shift: -2_147_483_649.0, ..style(12.0) }, inline: None },
];
let mut d = doc(vec![t]);
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)), ["in"]);
let b = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
assert!(b.y0 > 0.0 && b.y1 < 50.0, "{b:?}");
}
/// An overprinting run is written, or left out, as the same run is without overprint: the form
/// marked to overprint the run holds the type's transform. For type moved 2,147,483,689 points down
/// with its run shifted back onto a page 50 points high, a form placed through that transform would
/// sit at y = -2,147,483,648, which the PDF writer panics on in builds with overflow checks. For
/// type scaled 3 times, a form without that transform would hold a run shifted 500,000,000 points
/// up, and the export would write the run 1,500,000,000 points up.
#[test]
fn overprinting_runs_are_written_or_left_out_as_other_runs_are() {
let moved = |overprint_fill| {
let back = CharStyle { baseline_shift: 2_147_483_649.0, overprint_fill, ..style(12.0) };
TextObject::point(Point::new(5.0, 2_147_483_689.0), "moved", back)
};
let scaled = |overprint_fill| {
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
t.xf *= Affine::scale(3.0);
let up = CharStyle { baseline_shift: 500_000_000.0, overprint_fill, ..style(12.0) };
t.runs = vec![TextRun { text: "in".into(), style: style(12.0), inline: None }, TextRun { text: "out".into(), style: up, inline: None }];
t
};
let written = |mut t: TextObject| {
// The export culls type by the laid-out bounds that the engine keeps in `cached_bounds`.
t.cached_bounds = Some(vectorcraft_text::layout(FontDb::global(), &t).bounds);
let mut d = doc(vec![t]);
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
pdf(&d, false).bytes
};
let text_of = |bytes: &[u8]| texts(&import_as(bytes, TextAs::Text));
let (on, off) = (written(moved(true)), written(moved(false)));
assert!(String::from_utf8_lossy(&on).contains("/VCop gs"), "an overprinting form");
assert_eq!(text_of(&on), ["moved"]);
assert_eq!(text_of(&off), ["moved"]);
assert_eq!(text_of(&written(scaled(true))), ["in"]);
assert_eq!(text_of(&written(scaled(false))), ["in"]);
}
/// A named instance of a variable font is real text in that instance: its embedded subset draws
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
#[test]
+2 -2
View File
@@ -684,8 +684,8 @@ impl Renderer {
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display => effects::pixel::Channels::Rgb,
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
+19 -9
View File
@@ -40,13 +40,16 @@ pub(crate) enum Ink {
Cmy,
/// The complemented K ink, as a grey.
K,
/// Screen colours, with pure CMYK black as RGB black ([`RenderOptions::rich_black`]).
RichBlack,
}
impl Ink {
/// The three values painted for colour `c`.
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
match self {
Ink::Display => c.to_rgb(),
match (self, *c) {
(Ink::RichBlack, Color::Cmyk { c, m, y, k }) if is_pure_black([c, m, y, k]) => [0.0; 3],
(Ink::Display | Ink::RichBlack, _) => c.to_rgb(),
_ => self.plane(inks(c)),
}
}
@@ -65,7 +68,7 @@ impl Ink {
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
match self {
Ink::Display => [r, g, b, a],
Ink::Display | Ink::RichBlack => [r, g, b, a],
_ => {
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
let [r, g, b] = self.plane(inks(&Color::Lab { l: lab.l, a: lab.a, b: lab.b })).map(q);
@@ -83,6 +86,11 @@ fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Black ink alone at 100%: what Appearance of Black calls pure CMYK black.
fn is_pure_black([c, m, y, k]: [f32; 4]) -> bool {
c <= 0.0 && m <= 0.0 && y <= 0.0 && k >= 1.0
}
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
/// in Outline view or Separations Preview (which paints plates).
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
@@ -168,11 +176,12 @@ fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> (Arc<T>, u64) {
}
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where
/// only the frame's `background` shows it keeps its exact screen colour.
pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<u8> {
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile (pure
/// black as RGB black with [`RenderOptions::rich_black`]). Where only the `background` of `opts`
/// shows it keeps its exact screen colour.
pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions) -> Vec<u8> {
let (lut, _) = cached(&DISPLAY, InkLut::build);
let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
let bg = opts.background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
let mut out = Vec::with_capacity(cmy.len());
// Runs of one colour convert once.
let mut last: Option<([u8; 8], [u8; 4])> = None;
@@ -185,7 +194,8 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<
_ => {
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
let a = p[3];
let rgb = lut.apply(planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3])));
let inks = planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3]));
let rgb = if opts.rich_black && is_pure_black(inks) { [0.0; 3] } else { lut.apply(inks) };
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
last = Some((key, v));
@@ -245,7 +255,7 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option<Pixmap> {
/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as
/// painted with `ink`, cached with the images, and their key.
pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
if ink == Ink::Display {
if matches!(ink, Ink::Display | Ink::RichBlack) {
return (key.clone(), pm.clone());
}
let (luts, generation) = cached(&SEPARATE, |cms| {
+7 -2
View File
@@ -127,6 +127,9 @@ pub struct RenderOptions {
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
pub image_mips: bool,
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
pub rich_black: bool,
}
/// The opacity of the art around an isolated group or layer.
@@ -201,6 +204,7 @@ impl Default for RenderOptions {
precise: false,
supersample: false,
image_mips: false,
rich_black: false,
}
}
}
@@ -549,7 +553,8 @@ impl Renderer {
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: screen };
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
@@ -564,7 +569,7 @@ impl Renderer {
let stats = self.stats;
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
self.stats = stats;
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) }
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts) }
} else {
self.draw_frame(&frame, w, h)
};
+1 -1
View File
@@ -38,7 +38,7 @@ struct TileEntry {
struct PatternCache {
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [FrameCache<String, TileEntry>; 3],
tiles: [FrameCache<String, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
}
+31
View File
@@ -131,3 +131,34 @@ fn placed_images_are_separated_not_blackened() {
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
}
/// Appearance of Black's Rich Black (`rich_black`, #394): pure CMYK black (black ink alone at
/// 100%) shows as RGB black, in an opaque CMYK document (screen colours) and in one drawn by ink
/// planes (Multiply); a rich black, a black tint and placed images show as they do without it.
#[test]
fn rich_black_shows_black_ink_alone_as_rgb_black() {
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
let centre_with = |d: &Document, rich_black| {
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, rich_black, ..Default::default() };
Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)
};
for blend in [Blend::Normal, Blend::Multiply] {
let black = doc(ColorMode::Cmyk, None, k(1.0), blend, 1.0);
let accurate = centre_with(&black, false);
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0]), 1) && accurate[0] > 10, "{blend:?}: accurate {accurate:?}");
assert_eq!(centre_with(&black, true), [0, 0, 0, 255], "{blend:?}: rich");
for c in [Color::cmyk(0.6, 0.4, 0.4, 1.0), k(0.5)] {
let d = doc(ColorMode::Cmyk, None, c, blend, 1.0);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "{blend:?}: {c:?}");
}
}
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
add(&mut d, n);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
}
+16 -3
View File
@@ -1,6 +1,6 @@
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
//! EPS and `.ai` files that carry it, for import tests. Nothing here comes from files another app
//! wrote.
//! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another
//! app wrote, except the key names that [`pdf`] uses.
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
/// marks are the page), `body` (setup and layers), the trailer.
@@ -104,6 +104,19 @@ pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
/// two streams.
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPrivateData")
}
/// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under
/// `AIPDFPrivateData1` and `AIPDFPrivateData2`. These key names were seen in the PDF attached to
/// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too
/// (<https://exiftool.org/TagNames/PDF.html>).
pub fn pdf(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPDFPrivateData")
}
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`.
fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
let (first, second) = private.split_at(private.len() / 2);
let stream = |part: &[u8]| {
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
@@ -117,7 +130,7 @@ pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
/PieceInfo << /{PIECE} << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
/PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>"
)
.into_bytes(),
stream(page.as_bytes()),
+56 -1
View File
@@ -238,6 +238,17 @@ pub struct CornerDrag {
/// The pointer went past fully round: the dragged corner is at its largest radius.
at_limit: bool,
at: Point,
/// Direct Selection: what a click (no drag) on the pressed widget selects.
click: Option<CornerClick>,
}
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
/// (Shift: toggles), as a click on the corner's anchor does.
#[derive(Clone, Debug)]
struct CornerClick {
id: NodeId,
anchors: Vec<(usize, usize)>,
shift: bool,
}
impl CornerDrag {
@@ -247,6 +258,14 @@ impl CornerDrag {
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
let widget = widgets.hit(p, cx.tol(5.0))?;
let radius = widgets.widget(widget.0, widget.1)?.radius;
let click = any_path
.then(|| {
let path = widgets.paths.get(widget.0)?;
let corner = path.widgets.get(widget.1)?.corner.index;
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
})
.flatten();
let outlines = widgets
.paths
.iter()
@@ -257,7 +276,7 @@ impl CornerDrag {
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
@@ -323,6 +342,11 @@ impl CornerDrag {
vec![Action::Commit]
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
} else if let Some(c) = self.click {
// Direct Selection: the click selects the widget's corner, so the next drag on this
// widget rounds that corner alone (its widget is then the only one showing).
let mode = if c.shift { "toggle" } else { "set" };
vec![Action::Exec("select.anchors".into(), json!({"id": c.id.0, "anchors": c.anchors, "mode": mode}))]
} else {
vec![]
}
@@ -531,6 +555,37 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
}
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
/// toggles the corner. The Selection tool's click still does nothing.
#[test]
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let w = pts(&CornerWidgets::of(&d, &s, 1.0, true).unwrap())[2];
// The bottom-right corner of a rounded rectangle is drawn with two anchors.
let anchors: Vec<(usize, usize)> = LiveCorners::of(d.node(id).unwrap()).unwrap().anchors_of(&[2].into()).into_iter().collect();
assert_eq!(anchors.len(), 2);
let mut t = crate::create("directSelection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
let shift = Mods { shift: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
// With that corner selected, its widget alone shows, and a drag rounds it alone
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
let mut part = selected(id);
part.anchors.insert(id, anchors.iter().copied().collect());
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
}
#[test]
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
+34 -5
View File
@@ -1,6 +1,8 @@
//! Artboard tool (Shift+O).
//!
//! Click an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds
//! Click an object to create an artboard fitted to its visible bounds; drag still moves the
//! containing artboard or draws one on the pasteboard. Click an empty part of an artboard to
//! make it active (dashed bounds, 8 handles and its name); Shift-click adds
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
//! all of them (#1139). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
@@ -60,6 +62,8 @@ pub struct ArtboardTool {
/// artboard's proportions.
pub scale_art: bool,
drag: Option<Drag>,
/// The pressed object can be fitted on release; a drag, modifier or cancellation takes over.
click_art: Option<(Point, Rect)>,
preview: Option<Rect>,
/// Smart Guide targets, gathered when a move or resize starts.
targets: Option<Targets>,
@@ -79,6 +83,7 @@ impl Default for ArtboardTool {
move_art: true,
scale_art: false,
drag: None,
click_art: None,
preview: None,
targets: None,
copy_targets: None,
@@ -178,6 +183,7 @@ impl ArtboardTool {
fn end_drag(&mut self) {
self.drag = None;
self.click_art = None;
self.preview = None;
self.targets = None;
self.copy_targets = None;
@@ -189,6 +195,13 @@ impl ArtboardTool {
/// An artboard's shortest side (as `artboard.setProps` keeps it).
const MIN_SIDE: f64 = 1.0;
fn create_artboard(r: Rect) -> Vec<Action> {
vec![
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
Action::Notify("created".into()),
]
}
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
/// proportional resize keeps its proportions where the command would lengthen only the short side.
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
@@ -220,14 +233,26 @@ impl Tool for ArtboardTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let m = ev.mods;
if ev.kind == PointerKind::Drag && self.click_art.is_some_and(|(start, _)| m != Mods::default() || p.distance(start) >= cx.tol(3.0)) {
self.click_art = None;
}
match (ev.kind, self.drag) {
(PointerKind::Down, _) => {
self.click_art = None;
if let Some(r) = self.active_rect(cx)
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
{
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
return vec![];
}
if m == Mods::default() {
self.click_art = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())
.and_then(|h| cx.doc.bounds_of(&[h.top_object(cx.isolation)], true))
// Match Fit to Selected Art, and do not stretch zero-sized art to the
// artboard command's minimum side or pass non-finite bounds to it.
.filter(|r| r.width() >= MIN_SIDE && r.height() >= MIN_SIDE && [r.x0, r.y0, r.x1, r.y1].iter().all(|n| n.is_finite()))
.map(|r| (p, r));
}
if let Some(i) = cx.doc.artboard_at(p)
&& let Some(a) = cx.doc.artboards.get(i)
{
@@ -299,7 +324,14 @@ impl Tool for ArtboardTool {
vec![]
}
(PointerKind::Up, Some(d)) => {
let click_art = self.click_art;
self.end_drag();
if m == Mods::default()
&& let Some((start, r)) = click_art
&& p.distance(start) < cx.tol(3.0)
{
return create_artboard(r);
}
match d {
Drag::Move { began: true, copy, many, .. } => {
// The copies, last in the previewed document, become the selection (the
@@ -318,10 +350,7 @@ impl Tool for ArtboardTool {
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
return vec![];
}
vec![
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
Action::Notify("created".into()),
]
create_artboard(r)
}
_ => vec![],
}
+133 -10
View File
@@ -185,9 +185,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
ruler_guides(app, ui, full, rect, &xf);
}
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
panel_drop(app, ui, &resp, &xf);
context_menu(app, &resp, &xf);
let painter = ui.painter_at(rect);
@@ -254,6 +252,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
pixel,
smooth_images,
isolated: st.isolation.map(|id| id.0),
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
rich_black: app.session.prefs.black_on_screen == "rich",
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@@ -307,6 +307,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
rich_black: key.rich_black,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
@@ -432,7 +433,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
if let Some(origin) = origin_preview {
let at = xf.to_screen(origin);
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim));
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim));
}
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin()), &t);
}
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 {
task_bar(app, ui, &xf);
@@ -1073,8 +1079,8 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
/// it makes none.
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) {
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
let [top, left, corner] = ruler_rects(full);
// Right-click on a ruler or the origin box: the document units, to swap between them as
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
@@ -1101,14 +1107,49 @@ fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egu
app.status(e.to_string());
}
}
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click());
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click_and_drag());
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
if let Some((id, p)) = unit_clicked {
crate::menus::invoke(app, &id, p);
}
if corner.double_clicked_by(egui::PointerButton::Primary) {
let artboard = app.view().map_or(0, |v| v.artboard);
if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) {
app.status(e.to_string());
}
}
// Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling
// never adds an undo step or interferes with the drawing tool's interaction.
let drag_id = egui::Id::new("ruler-origin-drag");
let uid = app.session.active().map(|d| d.uid);
if corner.drag_started_by(egui::PointerButton::Primary) {
ui.data_mut(|d| d.insert_temp(drag_id, (uid, false)));
}
let mut preview = None;
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
if corner.drag_stopped_by(egui::PointerButton::Primary) {
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
if !cancelled
&& let Some(at) = at
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y}))
{
app.status(e.to_string());
}
} else if !ui.input(|i| i.pointer.primary_down()) {
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
} else {
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
if !cancelled {
preview = at;
}
}
}
preview
}
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, origin: Point, t: &Tokens) {
let p = ui.painter();
let [top, left, corner] = ruler_rects(full);
for r in [top, left, corner] {
@@ -1140,7 +1181,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
// (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance
// (screen points) from `from` along the ruler, and its value in `unit`.
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| {
let along = |s: Pos2| xf.to_doc(s).to_vec2().dot(axis);
let along = |s: Pos2| (xf.to_doc(s) - origin).dot(axis);
let (a, b) = (along(from), along(to));
let mut v = (a / per / minor).floor() * minor;
while v * per <= b {
@@ -2165,7 +2206,7 @@ mod tests {
let [top, left, _] = ruler_rects(full);
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, &Tokens::get(ui.ctx())));
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, Point::ZERO, &Tokens::get(ui.ctx())));
out.textures_delta.clear();
// The ticks' positions along each ruler.
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
@@ -2196,6 +2237,42 @@ mod tests {
}
}
#[test]
fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let [top, left, _] = ruler_rects(full);
let origin = Point::new(350.25, 250.5);
for deg in [0.0_f64, 30.0, 90.0, 200.0] {
for unit in [Unit::Points, Unit::Millimeters] {
let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, unit, origin, &Tokens::get(ui.ctx())));
out.textures_delta.clear();
let at = xf.to_screen(origin);
for (r, horizontal) in [(top, true), (left, false)] {
let zeros: Vec<_> = out
.shapes
.iter()
.filter(|c| c.clip_rect == r)
.filter_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == "0" => Some(t.pos),
_ => None,
})
.collect();
// Left-ruler labels are separate digits: a zero in "100" isn't the zero label.
assert!(
zeros.iter().any(|zero| {
let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y };
delta.abs() < 0.02
}),
"{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}"
);
}
}
}
}
#[test]
fn shaper_selection_highlights_only_the_visible_result() {
let mut s = Session::new();
@@ -2827,6 +2904,52 @@ mod tests {
assert!(smooth(&mut app), "Pixel Preview, on");
}
/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows
/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black
/// shows it as RGB black. A rich black shows the same either way.
#[test]
fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
app.canvas.worker_started = true; // render on this thread
// Larger than the view: the middle of the art's raster is its fill.
let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone();
let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| {
let [c, m, y, k] = cmyk;
app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap();
};
let ctx = egui::Context::default();
// The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered.
let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> {
let (_, mut delta) = frame_output(app, &ctx);
let art = app.canvas.texture.as_ref().unwrap().id();
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
let egui::ImageData::Color(img) = &d.image;
let [w, h] = img.size;
img.pixels[h / 2 * w + w / 2].to_array()
});
delta.clear();
px
};
let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0);
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
fill(&mut app, [0, 0, 0, 100]);
let accurate = middle(&mut app).expect("the first frame renders the art");
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}");
assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}");
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black");
fill(&mut app, [60, 40, 40, 100]);
let rich = middle(&mut app).expect("re-rendered");
assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}");
fill(&mut app, [0, 0, 0, 100]);
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap();
assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again");
}
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
/// or with the option off.
+9 -3
View File
@@ -313,15 +313,21 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
&& ui.available_width() >= INLINE_TRANSFORM_WIDTH
{
let c = b.center();
let origin = app.ruler_origin();
let link = app.session.prefs.constrain_proportions;
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] {
for (k, lbl, v, offset) in [
("x", "X:", c.x, origin.x),
("y", "Y:", c.y, origin.y),
("width", "W:", b.rect.width(), 0.0),
("height", "H:", b.rect.height(), 0.0),
] {
// The W/H link sits between W and H.
if k == "height" {
crate::panels::transform::constrain_link(app, ui);
}
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v - offset), units, 80.0) {
app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link})).ok();
}
}
}
+4 -1
View File
@@ -49,7 +49,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w
fn lengths(kind: &str) -> &'static [&'static str] {
match kind {
"move" => &["dx", "dy"],
"rectangle" | "ellipse" | "rectangularGrid" | "artboardOptions" => &["width", "height"],
"rectangle" | "ellipse" | "rectangularGrid" | "polarGrid" | "artboardOptions" => &["width", "height"],
"roundedRectangle" => &["width", "height", "radius"],
"polygon" => &["radius"],
"star" => &["radius1", "radius2"],
@@ -68,6 +68,7 @@ fn order(kind: &str) -> &'static [&'static str] {
"offsetPath" => &["offset", "joins", "miterLimit"],
"splitIntoGrid" => &["rows", "columns", "gutter"],
"rectangularGrid" => &["width", "height", "rows", "columns"],
"polarGrid" => &["width", "height", "concentric", "radial"],
"artboardOptions" => &["name", "width", "height"],
_ => &[],
}
@@ -182,6 +183,8 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
let label = match (d.kind.as_str(), k.as_str()) {
("rectangularGrid", "rows") => tl!("Horizontal Dividers:").into(),
("rectangularGrid", "columns") => tl!("Vertical Dividers:").into(),
("polarGrid", "concentric") => tl!("Concentric Dividers:").into(),
("polarGrid", "radial") => tl!("Radial Dividers:").into(),
_ => humanized(&k),
};
field(ui, d, &k, &label);
@@ -290,7 +290,19 @@ mod tests {
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
app.run("document.save", json!({"path": doc})).unwrap();
let original_stamp = std::fs::metadata(&pic).unwrap().modified().unwrap();
let mark_modified = |seconds| {
// Link stamps use milliseconds. Equal-size writes can finish within one tick;
// advance the fixture's mtime explicitly rather than relying on a sleep or load.
std::fs::OpenOptions::new()
.write(true)
.open(&pic)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(original_stamp + std::time::Duration::from_secs(seconds)))
.unwrap();
};
std::fs::write(&pic, png([0, 0, 200])).unwrap();
mark_modified(2);
crate::io::open_path(&mut app, &doc).unwrap();
let d = app.ui.dialog.as_ref().expect("asks");
assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string()));
@@ -299,8 +311,10 @@ mod tests {
// Manually: no question.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
std::fs::write(&pic, png([0, 200, 0])).unwrap();
mark_modified(4);
crate::io::open_path(&mut app, &doc).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 1);
let _ = std::fs::remove_dir_all(dir);
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ macro_rules! registry {
registry! {
NewDocument: [new_document::KIND] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid"] => shapes::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid", "polarGrid"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
+8 -2
View File
@@ -17,6 +17,7 @@ fn title(kind: &str) -> &'static str {
"polygon" => tl!("Polygon"),
"star" => tl!("Star"),
"rectangularGrid" => tl!("Rectangular Grid"),
"polarGrid" => tl!("Polar Grid"),
_ => tl!("Line Segment Tool Options"),
}
}
@@ -35,6 +36,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
"rows": (d.f64("rows", 5.0) as u64).min(999), "columns": (d.f64("columns", 5.0) as u64).min(999)}),
),
"polarGrid" => (
"shape.polarGrid",
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0),
"concentric": (d.f64("concentric", 5.0) as u64).min(999), "radial": (d.f64("radial", 5.0) as u64).min(999)}),
),
"lineSegment" => {
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
@@ -45,13 +51,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
),
};
// On the active plane while the perspective grid shows, sized in plane units from the click.
let options = (d.kind == "rectangularGrid").then(|| json!({"rows": params["rows"], "columns": params["columns"]}));
let options = matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid").then(|| params.clone());
let result = match vectorcraft_engine::perspective_click(&app.session, id, &params, vectorcraft_geom::Point::new(x, y)) {
Some((id, params)) => run_and_close(app, &id, params),
None => run_and_close(app, id, params),
}?;
if let Some(options) = options {
app.run("tool.setOption", json!({"tool": "rectangularGrid", "values": options}))?;
app.run("tool.setOption", json!({"tool": d.kind, "values": options}))?;
}
Ok(result)
}
+71
View File
@@ -41,6 +41,7 @@ fn kinds_map_to_their_dialogs() {
("roundedRectangle", DialogKind::Shape),
("lineSegment", DialogKind::Shape),
("rectangularGrid", DialogKind::Shape),
("polarGrid", DialogKind::Shape),
("shear", DialogKind::Transform),
("splitIntoGrid", DialogKind::PathOp),
("artboardOptions", DialogKind::ArtboardOptions),
@@ -87,6 +88,7 @@ fn every_dialog_draws() {
"star",
"lineSegment",
"rectangularGrid",
"polarGrid",
"rotate",
"reflect",
"scale",
@@ -650,3 +652,72 @@ fn zig_zag_has_sliders_and_radio_buttons() {
}
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
}
#[test]
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
use vectorcraft_doc::NodeId;
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
app.select_tool("polarGrid");
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 30.0, 40.0), Default::default());
}
assert_eq!(DialogKind::of("polarGrid"), Some(DialogKind::Shape));
let d = app.ui.dialog.as_mut().expect("canvas click opens the shared shape form");
assert_eq!(d.fields["width"], json!("100 pt"));
assert_eq!(d.fields["height"], json!("100 pt"));
assert_eq!((d.fields["concentric"].clone(), d.fields["radial"].clone()), (json!(2), json!(3)));
d.fields.insert("width".into(), json!("25.4 mm"));
d.fields.insert("height".into(), json!("0.5 in"));
frame(&mut app, Default::default());
let result = confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let s = app.session.doc().unwrap();
let group = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap();
let children = group.children().unwrap();
assert_eq!(children.len(), 6);
assert_eq!(children.iter().filter(|n| n.path_data().unwrap().is_closed()).count(), 3);
assert_eq!(s.history.undo.len(), undo + 1);
let made = s.doc.clone();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, made);
}
#[test]
fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
let mut app = app();
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
let d = app.ui.dialog.as_mut().expect("shared Polar Grid form opens");
d.fields.insert("concentric".into(), json!(8));
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["concentric"], json!(2));
for key in ["concentric", "radial"] {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(0));
}
let result = confirm(&mut app).unwrap();
let group = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(result["id"].as_u64().unwrap())).unwrap();
assert_eq!(group.children().unwrap().len(), 1);
assert!(group.children().unwrap()[0].path_data().unwrap().is_closed());
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["radial"], json!(0));
cancel(&mut app);
let layer = app.session.doc().unwrap().doc.layers[0].id;
app.run("layer.setProps", json!({"ids": [layer.0], "locked": true})).unwrap();
let options = app.session.tool_options_of("polarGrid");
let before = app.session.doc().unwrap().doc.clone();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
app.ui.dialog.as_mut().unwrap().fields.insert("radial".into(), json!(9));
assert!(confirm(&mut app).is_err());
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_options_of("polarGrid"), options);
assert_eq!(app.session.doc().unwrap().doc, before);
}
+7
View File
@@ -20,6 +20,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
let options = app.session.tool_options_of(kind);
Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "rows": options["rows"], "columns": options["columns"]}))
}
"polarGrid" => {
let options = app.session.tool_options_of(kind);
Dialog::new(
kind,
json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "concentric": options["concentric"], "radial": options["radial"]}),
)
}
// Graph tool click: the graph's size.
"graph" => Dialog::new(
"command",
+4
View File
@@ -4198,6 +4198,8 @@ effect Regular Pravidelný
@msg missing panel layout chybí rozložení panelů
@msg unsupported native docking operation nepodporovaná nativní operace ukotvení
@msg this kind of brush has no options dialog yet tento druh štětce zatím nemá dialogové okno možností
Ruler Origin Počátek pravítek
Reset Ruler Origin Obnovit počátek pravítek
Column Width: Šířka sloupce:
Import Data… Importovat data…
Number of Decimals: Počet desetinných míst:
@@ -4496,3 +4498,5 @@ mark-style Japanese Japonské
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) komprimovaný soubor se rozbalí na více, než VectorCraft přečte ({_1} MB dokumentu, {_2} MB na obrázek)
@msg the document is too large to save compressed ({0}): save it with Use Compression off dokument je příliš velký pro komprimované uložení ({0}): uložte ho s vypnutou volbou Použít kompresi
@msg the file stopped being read (please report this bug) čtení souboru se přerušilo (nahlaste prosím tuto chybu)
Concentric Dividers: Soustředné dělicí čáry:
Radial Dividers: Radiální dělicí čáry:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4140,6 +4140,8 @@ theme Appearance Erscheinungsbild
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Linux Display Server Linux-Anzeigeserver
X11 (XWayland) X11 (XWayland)
Ruler Origin Linealursprung
Reset Ruler Origin Linealursprung zurücksetzen
Artistic Kunstfilter
Film Grain… Filmkörnung…
Grain Körnung
@@ -4147,3 +4149,5 @@ theme Appearance Erscheinungsbild
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) die komprimierte Datei entpackt sich auf mehr, als VectorCraft liest ({_1} MB Dokument, {_2} MB pro Bild)
@msg the document is too large to save compressed ({0}): save it with Use Compression off das Dokument ist zu groß, um komprimiert gespeichert zu werden ({0}): speichern Sie es mit deaktivierter Option „Komprimierung verwenden“
@msg the file stopped being read (please report this bug) das Lesen der Datei wurde abgebrochen (bitte melden Sie diesen Fehler)
Concentric Dividers: Konzentrische Trennlinien:
Radial Dividers: Radiale Trennlinien:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+378
View File
@@ -4184,9 +4184,387 @@ lighting Light Luz
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Linux Display Server Servidor de pantalla de Linux
X11 (XWayland) X11 (XWayland)
Ruler Origin Origen de las reglas
Reset Ruler Origin Restablecer el origen de las reglas
Artistic Artístico
Film Grain… Granulado de película…
Highlight Area Área de iluminaciones
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) el archivo comprimido ocupa al descomprimirse más de lo que VectorCraft lee ({_1} MB de documento, {_2} MB por imagen)
@msg the document is too large to save compressed ({0}): save it with Use Compression off el documento es demasiado grande para guardarlo comprimido ({0}): guárdelo con Usar compresión desactivado
@msg the file stopped being read (please report this bug) se dejó de leer el archivo (informe de este error)
Concentric Dividers: Divisiones concéntricas:
Radial Dividers: Divisiones radiales:
# Rows other catalogs already had: document and print presets, keyboard-shortcut editor, print and
# PDF options, arrowheads, harmony rules, Save for Web, graphic styles and file filters.
@id pathfinder.add Unir
3 x 3 Average Promedio de 3 x 3
5 x 5 Average Promedio de 5 x 5
PDF File Archivo PDF
1 Color Job Trabajo a 1 color
2 Color Job Trabajo a 2 colores
3 Color Job Trabajo a 3 colores
CMYK profile in effect Perfil CMYK en vigor
CMYK Equivalents Equivalentes CMYK
effect Soft Suave
effect Horizontal Horizontal
effect Vertical Vertical
theme Appearance Apariencia
effect Points Puntos
effect Regular Regular
Phone 393×852 Teléfono 393×852
Phone 430×932 Teléfono 430×932
Phone 360×800 Teléfono 360×800
Phone 412×915 Teléfono 412×915
Phone 375×667 Teléfono 375×667
Tablet 768×1024 Tableta 768×1024
Tablet 820×1180 Tableta 820×1180
Tablet 834×1194 Tableta 834×1194
Tablet 1024×1366 Tableta 1024×1366
Tablet 800×1280 Tableta 800×1280
Watch 184×224 Reloj 184×224
Watch 198×242 Reloj 198×242
Web 1600×900 Web 1600×900
Web 1440×900 Web 1440×900
Web 1366×768 Web 1366×768
Web 1280×800 Web 1280×800
Web 1024×768 Web 1024×768
Legal Legal
Tabloid Tabloide
A3 A3
A4 A4
A5 A5
B4 B4
B5 B5
HDTV 1080p HDTV 1080p
HDTV 720p HDTV 720p
4K UHD 4K UHD
8K UHD 8K UHD
2K Cinema Cine 2K
4K Cinema Cine 4K
Greeting Card Tarjeta de felicitación
Square 8 × 8 in Cuadrado de 8 × 8 pulgadas
Poster 18 × 24 in Póster de 18 × 24 pulgadas
Poster 24 × 36 in Póster de 24 × 36 pulgadas
Logo 1000×1000 Logotipo 1000×1000
Business Card 3.5 × 2 in Tarjeta de presentación de 3.5 × 2 pulgadas
Business Card 85 × 55 mm Tarjeta de presentación de 85 × 55 mm
Letterhead Letter Membrete Carta
Letterhead A4 Membrete A4
Envelope DL Sobre DL
Envelope #10 Sobre #10
Social Portrait Post 1080×1350 Publicación vertical para redes 1080×1350
Social Landscape Post 1200×630 Publicación horizontal para redes 1200×630
Social Story 1080×1920 Historia para redes 1080×1920
Social Tall Post 1000×1500 Publicación alta para redes 1000×1500
Profile Cover 1500×500 Portada de perfil 1500×500
Page Cover 1640×624 Portada de página 1640×624
Video Banner 2560×1440 Banner de video 2560×1440
Video Thumbnail 1280×720 Miniatura de video 1280×720
Keyboard Shortcuts Métodos abreviados de teclado
Set: Conjunto:
Reset to Defaults Restablecer valores predeterminados
Tool Herramienta
Command Comando
Shortcut Método abreviado
Menu Commands Comandos de menú
Press keys… Presiona las teclas…
Use Default Usar predeterminado
Go to Conflict Ir al conflicto
Remove the shortcut Quitar el método abreviado
Click, then press the new shortcut (Esc cancels) Haz clic y presiona el nuevo método abreviado (Esc cancela)
All shortcuts reset to defaults. Se restablecieron todos los métodos abreviados a los valores predeterminados.
Imported. Press OK to keep the imported set. Importado. Presiona Aceptar para conservar el conjunto importado.
⚠ {chord} was already used by {names} — it has been removed there. Atención: {chord} ya estaba en uso por {names}; se quitó de allí.
VectorCraft Defaults Valores predeterminados de VectorCraft
@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} método abreviado está asignado más de una vez|{n} métodos abreviados están asignados más de una vez
Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). «Simular» imprime las sobreimpresiones tal como las muestra la vista previa de sobreimpresión; las separaciones siempre las conservan. Un preajuste acopla la transparencia antes de imprimir (los archivos PostScript siempre la acoplan).
Preserve Conservar
Simulate Simular
Portrait (flipped) Vertical (invertida)
Landscape (flipped) Horizontal (invertida)
Visible & Printable Layers Capas visibles e imprimibles
Visible Layers Capas visibles
All Layers Todas las capas
Do Not Scale No escalar
Fit to Page Ajustar a la página
Custom Scale Escala personalizada
Tile Imageable Areas Mosaico de áreas imprimibles
Composite Compuesto
Separations Separaciones
Up (right reading) Arriba (lectura correcta)
Down (right reading) Abajo (lectura correcta)
Positive Positivo
Negative Negativo
Subset Subconjunto
Complete Completa
[Custom] [Personalizado]
[Default] [Predeterminado]
No downsampling Sin reducción de resolución
Subsample Submuestreo
Bicubic Bicúbica
Minimum Mínima
Run Length Compresión por longitud de secuencia
Roman Romano
No conversion Sin conversión
Convert to destination Convertir al destino
Convert to destination (preserve numbers) Convertir al destino (conservar valores)
Don't include profiles No incluir perfiles
Include all profiles Incluir todos los perfiles
Include destination profile Incluir perfil de destino
Include tagged source profiles Incluir perfiles de origen etiquetados
Low resolution (150 ppi) Baja resolución (150 ppp)
High resolution Alta resolución
Inserting, deleting and rotating pages Insertar, eliminar y rotar páginas
Filling in form fields and signing Rellenar campos de formulario y firmar
Commenting, filling in form fields and signing Comentar, rellenar campos de formulario y firmar
Any except extracting pages Cualquiera excepto extraer páginas
Bounding Box Cuadro delimitador
Mobile Móvil
Film & Video Cine y video
Art & Illustration Arte e ilustración
Branding Identidad de marca
Social Redes sociales
Triangle Triángulo
Triangle (open) Triángulo (abierto)
Circle (open) Círculo (abierto)
Square (open) Cuadrado (abierto)
Bar Barra
Arrow Flecha
Arrow (open) Flecha (abierta)
Barbed Con púas
Half Arrow (left) Media flecha (izquierda)
Half Arrow (right) Media flecha (derecha)
Concave Cóncava
Double Bar Doble barra
Feather Emplumado
Dot on Bar Punto sobre barra
Chevron Galón
Double Arrow Doble flecha
Cross Cruz
Plus Signo más
Hexagon Hexágono
Hexagon (open) Hexágono (abierto)
Tag Etiqueta
Tag (open) Etiqueta (abierta)
Half Circle Semicírculo
Drop Gota
Slash Barra inclinada
Double Slash Doble barra inclinada
Diamond (open) Rombo (abierto)
Reverse Triangle Triángulo invertido
Swallowtail Cola de golondrina
Bracket Corchete
Fork Horquilla
Leaf Hoja
Kite Cometa
Triangle to Bar Triángulo a barra
Oval Óvalo
Complementary Complementario
Complementary 2 Complementario 2
Split Complementary Complementario dividido
Left Complement Complemento izquierdo
Right Complement Complemento derecho
Analogous Análogo
Analogous 2 Análogo 2
Monochromatic Monocromático
Shades Sombras
Triad Tríada
Triad 2 Tríada 2
Triad 3 Tríada 3
Tetrad Tétrada
Tetrad 2 Tétrada 2
Tetrad 3 Tétrada 3
Compound Compuesto
Compound 2 Compuesto 2
High Contrast Alto contraste
High Contrast 2 Alto contraste 2
High Contrast 3 Alto contraste 3
Pentagram Pentagrama
Perceptual Perceptual
Relative Colorimetric Colorimétrico relativo
Absolute Colorimetric Colorimétrico absoluto
Exact Exacto
Preserve Tints Conservar matices
Scale Tints Escalar matices
Linear Lineal
Freeform Libre
8 Colors 8 colores
16 Colors 16 colores
256 Colors 256 colores
True Colors Colores verdaderos
Preserve Appearance Conservar apariencia
Maximize Editability Maximizar editabilidad
Clip Group Grupo de recorte
Guide Guía
Symbol Símbolo
Envelope Envolvente
Mesh Malla
Placed Document Documento colocado
Radial Repeat Repetición radial
Grid Repeat Repetición de cuadrícula
Mirror Repeat Repetición reflejada
Auto Size Tamaño automático
Shrink Text to Fit Reducir texto para que quepa
Ascent Ascendente
Cap Height Altura de mayúsculas
x Height Altura de la x
Justify Justificar
First Baseline Primera línea de base
Vertical Align Alineación vertical
Save Selection Guardar selección
Twirl Molinete
Pucker Fruncir
Bloat Engordar
Scallop Festonear
Wrinkle Arrugar
Unknown Desconocido
Copyrighted Con copyright
Public Domain Dominio público
None (Color Preview) Ninguno (vista previa de color)
Rasterized Complex Regions Regiones complejas rasterizadas
Transparent Objects Objetos transparentes
All Affected Objects Todos los objetos afectados
Expanded Patterns Motivos expandidos
Outlined Strokes Trazos contorneados
Outlined Text Texto contorneado
All Rasterized Regions Todas las regiones rasterizadas
High Resolution Alta resolución
Medium Resolution Resolución media
Low Resolution Baja resolución
Copyright and Contact Info Copyright e información de contacto
All Slices Todos los sectores
Selected Slices Sectores seleccionados
No Slices Sin sectores
Images Only Solo imágenes
HTML and Images HTML e imágenes
Selective Selectivo
Adaptive Adaptativo
Black & White Blanco y negro
No Dither Sin tramado
Diffusion Difusión
Unsorted Sin ordenar
Sort by Hue Ordenar por tono
Sort by Luminance Ordenar por luminancia
Sort by Popularity Ordenar por popularidad
Baseline (Standard) Secuencial (estándar)
Baseline Optimized Secuencial optimizado
Art Optimized Optimizado para ilustración
Type Optimized Optimizado para texto
Language Level 2 Nivel de lenguaje 2
Language Level 3 Nivel de lenguaje 3
TIFF (Black & White) TIFF (blanco y negro)
TIFF (8-bit Color) TIFF (color de 8 bits)
1 bit 1 bit
4 bit 4 bits
8 bit 8 bits
16 bit 16 bits
24 bit 24 bits
32 bit 32 bits
16 bits/pixel 16 bits/píxel
24 bits/pixel 24 bits/píxel
32 bits/pixel 32 bits/píxel
DWG (use DXF) DWG (usa DXF)
file-filter All readable files Todos los archivos legibles
file-filter All placeable files Todos los archivos que se pueden colocar
file-filter Swatch libraries Bibliotecas de muestras
file-filter Flattener presets Preajustes del acoplador
file-filter PDF presets Preajustes de PDF
file-filter Print presets Preajustes de impresión
file-filter SVG Compressed SVG comprimido
file-filter PDF-compatible .ai .ai compatible con PDF
file-filter VectorCraft Template Plantilla de VectorCraft
file-filter Templates Plantillas
diagnostics render renderizado
diagnostics ui interfaz
Preset {number} Preajuste {number}
PRs PR
@{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} líneas (Δ {delta}), +{binary_added} / −{binary_deleted} recursos binarios, {first} – {last}
@plural {n} PR|{n} PRs {n} PR|{n} PR
@plural {n} commit|{n} commits {n} commit|{n} commits
gradient-kind Radial Radial
mark-style Japanese Japonesas
2-Up 2 vistas
2up 2 vistas
3 Colors 3 colores
4-Up 4 vistas
4up 4 vistas
6 Colors 6 colores
A6 A6
Alt+Click Alt+clic
Alt+Drag Alt+arrastrar
Anchors Anclas
Area Type Texto de área
Basic Básico
Black Outline Contorno negro
Brown Marrón
Convert to Global Convertir en global
Cool Fríos
CRLF (Windows) CRLF (Windows)
Current Colors Colores actuales
Default Graphic Style Estilo gráfico predeterminado
Direct Selection Selección directa
Distance: Distancia:
GIF, 128 colours GIF, 128 colores
Gray Gris
Grey Gris
Group Selection Selección de grupos
Hand Mano
Harmony Rules Reglas de armonía
Heavy Ink Tinta densa
High Fidelity Photo Foto de alta fidelidad
Image Trace (Make and Expand) Calco de imagen (crear y expandir)
Joins: Uniones:
Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Mantiene el documento editable: VectorCraft vuelve a abrir el PDF sin perder nada. Para archivos que volverás a editar.
Lab Spot Colors Tintas planas Lab
Lasso Lazo
Length: Longitud:
LF (macOS, Linux) LF (macOS, Linux)
Line Art Arte lineal
Low Fidelity Photo Foto de baja fidelidad
Make Text Wrap Crear ceñido de texto
Measure Medición
Monochrome Monocromo
Muted Apagados
of de
Orange Naranja
Pattern Swatches Muestras de motivo
PDF 1.7 PDF 1.7
Pen Pluma
Pink Rosa
Pixel Píxel
Points: Puntos:
Purple Púrpura
Release Image Trace Soltar calco de imagen
Release Text Wrap Soltar ceñido de texto
RGB RGB
Rotate View Rotar vista
Scissors Tijeras
Shades of Gray Tonos de gris
Shear: Inclinar:
Shift+Click Shift+clic
Shift+Drag Shift+arrastrar
Silhouettes Siluetas
Sketched Art Arte esbozado
Spectrum Espectro
sRGB IEC61966-2.1 sRGB IEC61966-2.1
Sunshine Soleado
SVG 1.1 SVG 1.1
Technical Drawing Dibujo técnico
Text Displayed in Cell Texto mostrado en la celda
Touch Type Retocar texto
UTF-16 (Unicode) UTF-16 (Unicode)
Variation Variación
VectorCraft Default Predeterminado de VectorCraft
VectorCraft Generic CMYK (SWOP-like) CMYK genérico de VectorCraft (similar a SWOP)
VectorCraft Swatches (.vcswatches) Muestras de VectorCraft (.vcswatches)
Vertical Area Type Texto de área vertical
Vertical Type Texto vertical
Vertical Type on a Path Texto vertical en trazado
Vivid Vivos
Warm Cálidos
WebP WebP
Working RGB RGB de trabajo
X: X:
Y: Y:
@id file.close Cerrar archivo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4194,9 +4194,13 @@ lighting Light Lumière
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Linux Display Server Serveur d’affichage Linux
X11 (XWayland) X11 (XWayland)
Ruler Origin Origine des règles
Reset Ruler Origin Réinitialiser l’origine des règles
Artistic Artistique
Film Grain… Grain photographique…
Highlight Area Zone des tons clairs
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) le fichier compressé dépasse une fois décompressé ce que VectorCraft lit ({_1} Mo de document, {_2} Mo par image)
@msg the document is too large to save compressed ({0}): save it with Use Compression off le document est trop volumineux pour être enregistré compressé ({0}) : enregistrez-le avec Utiliser la compression désactivé
@msg the file stopped being read (please report this bug) la lecture du fichier s’est interrompue (veuillez signaler ce bogue)
Concentric Dividers: Divisions concentriques :
Radial Dividers: Divisions radiales :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2774,6 +2774,7 @@
Require a password to open the document Jelszó kérése a dokumentum megnyitásához
Reselect Újrakijelölés
Reset Visszaállít
Reset Ruler Origin Vonalzók origójának visszaállítása
Reset all Összes visszaállítása
Reset all Revolve settings to their defaults. A tengely körüli forgatás minden beállítását visszaállítja az alapértékre.
Reset Automation Automation visszaállítása
@@ -2888,6 +2889,7 @@
Row Size: Sorméret:
Rows: Sorok:
Rule: Szabály:
Ruler Origin Vonalzók origója
Rulers Vonalzók
Rulers & Grids Vonalzók és rácsok
Run Length Futáshossz (RLE)
@@ -4590,3 +4592,5 @@ theme Appearance Kinézet
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) a tömörített fájl kicsomagolva nagyobb lenne, mint amennyit a VectorCraft beolvas ({_1} MB dokumentum, képenként {_2} MB)
@msg the document is too large to save compressed ({0}): save it with Use Compression off a dokumentum túl nagy a tömörített mentéshez ({0}): mentsd a Tömörítés használata beállítás kikapcsolásával
@msg the file stopped being read (please report this bug) a fájl beolvasása megszakadt (kérjük, jelentsd ezt a hibát)
Concentric Dividers: Koncentrikus osztók:
Radial Dividers: Sugárirányú osztók:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4186,9 +4186,13 @@ lighting Light Luce
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Linux Display Server Server di visualizzazione Linux
X11 (XWayland) X11 (XWayland)
Ruler Origin Origine dei righelli
Reset Ruler Origin Ripristina origine dei righelli
Artistic Artistico
Film Grain… Grana della pellicola…
Highlight Area Area luci
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) il file compresso, una volta decompresso, supera ciò che VectorCraft legge ({_1} MB di documento, {_2} MB per immagine)
@msg the document is too large to save compressed ({0}): save it with Use Compression off il documento è troppo grande per essere salvato compresso ({0}): salvalo con Usa compressione disattivato
@msg the file stopped being read (please report this bug) la lettura del file si è interrotta (segnala questo errore)
Concentric Dividers: Divisori concentrici:
Radial Dividers: Divisori radiali:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -4180,9 +4180,13 @@ lighting Light ライト
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Linux Display Server Linux ディスプレイサーバー
X11 (XWayland) X11 (XWayland)
Ruler Origin 定規の原点
Reset Ruler Origin 定規の原点をリセット
Artistic アーティスティック
Film Grain… フィルム粒子…
Highlight Area ハイライト領域
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 圧縮ファイルを展開すると VectorCraft が読み込める量を超えます (ドキュメント {_1} MB、画像 1 枚あたり {_2} MB)
@msg the document is too large to save compressed ({0}): save it with Use Compression off ドキュメントが大きすぎて圧縮保存できません ({0}): 「圧縮を使用」をオフにして保存してください
@msg the file stopped being read (please report this bug) ファイルの読み込みが中断されました (このバグを報告してください)
Concentric Dividers: 同心円の分割線:
Radial Dividers: 放射状の分割線:
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -82,11 +82,15 @@ fn plural_east_slavic(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 13] = [
pub static LANGUAGES: [LangInfo; 14] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Korean: the whole interface and the status and error messages, keeping the same names in
// English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an
// installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here.
LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Czech: the whole interface (every menu string and `tl!` literal) and the status and error
// messages, with one/few/other plurals, keeping the same names in English as Spanish.
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new() },
+4
View File
@@ -3668,6 +3668,8 @@ axis Both Ambos
@msg invalid native panel set conjunto de painéis nativos inválido
@msg missing panel layout layout de painéis ausente
@msg unsupported native docking operation operação de encaixe nativa não suportada
Ruler Origin Origem das réguas
Reset Ruler Origin Redefinir origem das réguas
Artistic Artístico
Film Grain… Granulação de filme…
Grain Grão
@@ -3675,3 +3677,5 @@ axis Both Ambos
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) o arquivo compactado se expande para mais do que o VectorCraft lê ({_1} MB de documento, {_2} MB por imagem)
@msg the document is too large to save compressed ({0}): save it with Use Compression off o documento é grande demais para ser salvo compactado ({0}): salve-o com Usar Compressão desativado
@msg the file stopped being read (please report this bug) a leitura do arquivo foi interrompida (informe este erro)
Concentric Dividers: Divisórias concêntricas:
Radial Dividers: Divisórias radiais:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -4182,9 +4182,13 @@ lighting Light Свет
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
Linux Display Server Дисплейный сервер Linux
X11 (XWayland) X11 (XWayland)
Ruler Origin Начало координат линеек
Reset Ruler Origin Сбросить начало координат линеек
Artistic Художественные
Film Grain… Зернистость плёнки…
Highlight Area Область светов
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) сжатый файл распаковывается в объём больше, чем читает VectorCraft ({_1} МБ документа, {_2} МБ на изображение)
@msg the document is too large to save compressed ({0}): save it with Use Compression off документ слишком велик для сохранения со сжатием ({0}): сохраните его с выключенным параметром «Использовать сжатие»
@msg the file stopped being read (please report this bug) чтение файла прервалось (пожалуйста, сообщите об этой ошибке)
Concentric Dividers: Концентрические разделители:
Radial Dividers: Радиальные разделители:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+34 -3
View File
@@ -29,6 +29,9 @@ fn tags_map_to_languages() {
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu"] {
assert_eq!(lang_from_tag(tag), Some(hu()), "{tag}");
}
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] {
assert_eq!(lang_from_tag(tag), Some(ko()), "{tag}");
}
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
@@ -251,7 +254,7 @@ fn language_command_sets_the_preference() {
/// Chinese and Japanese characters (CJK punctuation, kana, ideographs, full-width forms).
fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x3000..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
matches!(c as u32, 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
}
/// The UI fonts draw every character the catalogs use, in every family the UI draws text with
@@ -446,7 +449,7 @@ fn cs() -> Lang {
}
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
const KEEPS_MENU_NAMES: [&str; 10] = ["cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "uk"];
const KEEPS_MENU_NAMES: [&str; 11] = ["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "pt-br", "ru", "uk"];
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
@@ -631,6 +634,34 @@ fn hungarian_reads_as_hungarian() {
assert_eq!(message(hu(), nested), "a parancs jelenleg nem érhető el: `object.group`: nincs kijelölés");
}
fn ko() -> Lang {
Lang::from_code("ko").expect("ko registered")
}
/// Korean uses the vector-illustration vocabulary its users know, has no plural forms, and
/// translates the reason inside a message too.
#[test]
fn korean_reads_as_korean() {
for (en, want) in [
("Artboard Tool", "아트보드 도구"),
("Swatches", "견본"),
("Pathfinder", "패스파인더"),
("Stroke", "선"),
("Fill", "칠"),
("Direct Selection Tool", "직접 선택 도구"),
("Save As…", "다른 이름으로 저장…"),
("Undo", "실행 취소"),
("Close", "닫기"),
] {
assert_eq!(tr(ko(), en), want);
}
for n in [0, 1, 3, 21] {
assert_eq!(trn(ko(), n, "{n} Layer", "{n} Layers"), format!("레이어 {n}개"));
}
let nested = "command `object.group` is not available right now: nothing selected";
assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다");
}
fn uk() -> Lang {
Lang::from_code("uk").expect("uk registered")
}
@@ -1057,7 +1088,7 @@ fn select_key_param_error_is_translated() {
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ru", "uk"];
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "hu", "it", "ja", "ko", "ru", "uk"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
+4
View File
@@ -4166,9 +4166,13 @@ lighting Light Світло
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
Linux Display Server Дисплейний сервер Linux
X11 (XWayland) X11 (XWayland)
Ruler Origin Початок координат лінійок
Reset Ruler Origin Скинути початок координат лінійок
Artistic Художні
Film Grain… Зернистість плівки…
Highlight Area Область світлих тонів
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) стислий файл розпаковується в обсяг більший, ніж читає VectorCraft ({_1} МБ документа, {_2} МБ на зображення)
@msg the document is too large to save compressed ({0}): save it with Use Compression off документ завеликий для збереження зі стиском ({0}): збережіть його з вимкненим параметром «Використовувати стиск»
@msg the file stopped being read (please report this bug) читання файлу перервалося (будь ласка, повідомте про цю помилку)
Concentric Dividers: Концентричні розділювачі:
Radial Dividers: Радіальні розділювачі:
Can't render this file because it contains an unexpected character in line 659 and column 40.
+4
View File
@@ -3781,6 +3781,10 @@ lighting Light 光源
Move Guide to Layer 将参考线移至图层
Linux Display Server Linux 显示服务器
X11 (XWayland) X11 (XWayland)
Ruler Origin 标尺原点
Reset Ruler Origin 重置标尺原点
Artistic 艺术效果
Film Grain… 胶片颗粒…
Highlight Area 高光区域
Concentric Dividers: 同心分隔线:
Radial Dividers: 径向分隔线:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3562,6 +3562,10 @@ lighting Light 光源
Move Guide to Layer 將參考線移至圖層
Linux Display Server Linux 顯示伺服器
X11 (XWayland) X11 (XWayland)
Ruler Origin 尺標原點
Reset Ruler Origin 重設尺標原點
Artistic 藝術效果
Film Grain… 底片顆粒…
Highlight Area 亮部範圍
Concentric Dividers: 同心分隔線:
Radial Dividers: 徑向分隔線:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+16
View File
@@ -122,6 +122,8 @@ mod tests_recovery;
#[cfg(test)]
mod tests_removeanchors;
#[cfg(test)]
mod tests_ruler_origin;
#[cfg(test)]
mod tests_save;
#[cfg(test)]
mod tests_saveext;
@@ -310,6 +312,9 @@ pub struct CacheKey {
pub smooth_images: bool,
/// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]).
pub isolated: Option<u64>,
/// Appearance of Black › On Screen › Display All Blacks as Rich Black
/// ([`vectorcraft_render::RenderOptions::rich_black`]).
pub rich_black: bool,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -548,6 +553,17 @@ impl VectorcraftApp {
pub fn view(&self) -> Option<&View> {
self.session.active_index().and_then(|i| self.views.get(i))
}
/// The zero point shown by rulers and position fields; command coordinates stay absolute.
pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point {
let origin = self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin);
// An untrusted file cannot make the ruler's tick loop or position fields overflow.
if origin.is_finite() && origin.x.abs() <= vectorcraft_engine::MAX_COORD && origin.y.abs() <= vectorcraft_engine::MAX_COORD {
origin
} else {
vectorcraft_geom::Point::ZERO
}
}
pub fn view_mut(&mut self) -> Option<&mut View> {
self.sync_views();
let i = self.session.active_index()?;
+7
View File
@@ -3217,6 +3217,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
app.open_link(id);
return;
}
// Object → Align aligns to what the align buttons align to, unless the call names `to`. With
// anchors selected (Direct Selection) it sends its params unchanged, and the points align to
// their own bounds.
let follows_align_to = id == "object.align"
&& p.as_object().is_some_and(|o| !o.contains_key("to"))
&& app.session.active().is_some_and(|st| st.selection.anchors.is_empty());
let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p };
if let Err(e) = app.run(id, p) {
app.status(e);
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {
+146 -27
View File
@@ -2,13 +2,17 @@
//! aligning to a key object) and Align To (selection / key object / artboard).
use egui::Ui;
use serde::Serialize;
use serde_json::{Value, json};
use super::{pstate, selection_len, set_pstate};
use crate::VectorcraftApp;
use crate::widgets::{self, menu_item};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// What Align aligns to. The Align To choice kept with the UI state ([`crate::UiState::align_to`])
/// is Selection or Artboard: Align to Key Object stores no choice.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AlignTo {
#[default]
Selection,
@@ -26,14 +30,34 @@ impl AlignTo {
}
}
/// Serde reader for the saved Align To choice ([`crate::UiState::align_to`]): `"artboard"` is
/// Artboard, and any other value, `"key"` included, is Selection.
pub fn deserialize_align_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result<AlignTo, D::Error> {
let v = <Value as serde::Deserialize>::deserialize(d)?;
Ok(if v == "artboard" { AlignTo::Artboard } else { AlignTo::Selection })
}
/// What Align aligns to with `n_selected` objects selected: the artboard for a single object, else
/// `to`.
fn aligns_to(to: AlignTo, n_selected: usize) -> AlignTo {
if n_selected == 1 { AlignTo::Artboard } else { to }
}
/// `object.align` params: a single object aligns to the artboard (Illustrator does the same).
pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value {
let mut p = base;
let to = if n_selected == 1 { AlignTo::Artboard } else { to };
p["to"] = json!(to.param());
p["to"] = json!(aligns_to(to, n_selected).param());
p
}
/// `object.align` params `base` with the `to` that the align buttons of the Align panel, the Control
/// bar and the Properties panel send: the artboard for a single selected object, else the key object
/// while there is one, else the Align To choice. Object → Align sends it too while every selected
/// object is selected whole ([`crate::menus::invoke`]).
pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value {
align_params(base, align_to(app), selection_len(app))
}
pub const ALIGN: [(&str, &str, &str, &str); 6] = [
("dc-al-left", "Horizontal Align Left", "horizontal", "left"),
("dc-al-hcenter", "Horizontal Align Center", "horizontal", "center"),
@@ -56,8 +80,6 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [
/// They align to what the Align panel's Align To says (the key object while there is one), as
/// its own buttons do (#789).
pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
let n = selection_len(app);
let to = align_to(app, ui.ctx());
for (i, (icon, tip, axis, v)) in [
("align-start-vertical", "Horizontal Align Left", "horizontal", "left"),
("align-center-vertical", "Horizontal Align Center", "horizontal", "center"),
@@ -70,10 +92,10 @@ pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
.enumerate()
{
if i == 3 && widgets::icon_button(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, size).clicked() {
app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok();
app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok();
}
if widgets::icon_button(ui, icon, tl!(tip), false, size).clicked() {
app.run("object.align", align_params(json!({axis: v}), to, n)).ok();
app.run("object.align", with_align_to(app, json!({axis: v}))).ok();
}
}
}
@@ -91,13 +113,13 @@ fn topmost(app: &VectorcraftApp) -> Option<vectorcraft_doc::NodeId> {
/// What Align aligns to: the key object while there is one (a click on an object of the
/// selection makes it the key, and Align to Key Object makes the topmost one the key), else the
/// stored Align To choice, Selection or Artboard.
fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo {
if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") }
fn align_to(app: &VectorcraftApp) -> AlignTo {
if has_key(app) { AlignTo::Key } else { app.ui.align_to }
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n = selection_len(app);
let to = align_to(app, ui.ctx());
let to = align_to(app);
widgets::subheader(ui, tl!("Align Objects:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
@@ -105,12 +127,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if i == 3 {
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, n >= 1, 32.0).clicked() {
app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok();
app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok();
}
ui.add_space(6.0);
}
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 1, 32.0).clicked() {
app.run("object.align", align_params(json!({*axis: v}), to, n)).ok();
app.run("object.align", with_align_to(app, json!({*axis: v}))).ok();
}
}
});
@@ -155,6 +177,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Align To:"));
// With one object selected the buttons align it to the artboard, and Align To shows Artboard.
let shown = aligns_to(to, n);
ui.horizontal(|ui| {
for (v, icon, tip) in [
(AlignTo::Selection, "dc-alignto-selection", tl!("Align to Selection")),
@@ -163,7 +187,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
] {
// A key object is one of two or more selected objects.
let on = v != AlignTo::Key || n >= 2;
if widgets::icon_button_enabled(ui, icon, tl!(tip), to == v, on, 28.0).clicked() {
if widgets::icon_button_enabled(ui, icon, tl!(tip), shown == v, on, 28.0).clicked() {
if v == AlignTo::Key {
// The topmost selected object becomes the key object, unless
// there is one already. The Align To choice stays as it was, and
@@ -174,7 +198,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("select.key", json!({"id": id.0})).ok();
}
} else {
set_pstate(ui.ctx(), "align-to", v);
app.ui.align_to = v;
// Aligning to the selection or the artboard lets go of the key object.
if has_key(app) {
app.run("select.key", json!({})).ok();
@@ -197,7 +221,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
super::transform::set_pref(app, "usePreviewBounds", !pb);
}
// Cancel Key Object clears the key, and Align To shows its stored choice again.
if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) {
if menu_item(ui, tl!("Cancel Key Object"), align_to(app) == AlignTo::Key, false) {
app.run("select.key", json!({})).ok();
}
menu_item(ui, tl!("Align to Glyph Bounds"), false, false);
@@ -252,7 +276,7 @@ mod tests {
frame(&mut app, vec![press(false)]);
assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge");
// The Align panel's Horizontal Align Left.
let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app));
let p = with_align_to(&app, json!({"horizontal": "left", "bounds": "geometric"}));
app.run("object.align", p).unwrap();
assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge");
}
@@ -274,12 +298,11 @@ mod tests {
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
assert_eq!(align_to(&app), AlignTo::Selection);
app.run("select.key", json!({"id": b})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
assert_eq!(align_to(&app), AlignTo::Key);
app.run("select.key", json!({})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again");
assert_eq!(align_to(&app), AlignTo::Selection, "no key: the choice again");
}
/// Two rectangles, the back one at x 10 and the front one at x 80, selected front first →
@@ -326,7 +349,7 @@ mod tests {
app.run("select.set", json!({"ids": [back, front]})).unwrap();
click(&ctx, &mut app, key.center(), show);
assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected last");
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
assert_eq!(align_to(&app), AlignTo::Key);
click(&ctx, &mut app, left.center(), show);
assert_eq!((left_edge(&app, &back), left_edge(&app, &front)), (80.0, 80.0), "to the key's left edge: {}", app.ui.status);
app.run("select.key", json!({"id": back})).unwrap();
@@ -344,20 +367,40 @@ mod tests {
let ([_, key, artboard], _) = panel_buttons(&mut app, &ctx);
click(&ctx, &mut app, artboard.center(), show);
click(&ctx, &mut app, key.center(), show);
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
assert_eq!(align_to(&app), AlignTo::Key);
let cancel = text_rect(&frame_events(&ctx, &mut app, vec![], menu), " Cancel Key Object");
click(&ctx, &mut app, cancel.center(), menu);
assert!(!has_key(&app));
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "Cancel Key Object");
assert_eq!(align_to(&app), AlignTo::Artboard, "Cancel Key Object");
// The Selection tool's click on the key object runs `select.key {}`.
click(&ctx, &mut app, key.center(), show);
assert!(has_key(&app));
app.run("select.key", json!({})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "a click on the key");
assert_eq!(align_to(&app), AlignTo::Artboard, "a click on the key");
click(&ctx, &mut app, key.center(), show);
assert!(has_key(&app));
app.run("select.set", json!({"ids": [back, front]})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "another selection");
assert_eq!(align_to(&app), AlignTo::Artboard, "another selection");
}
/// The Align To choice is kept with the UI state, which the app saves with its preferences and
/// `ui.inspect` reports. A saved value other than "artboard" reads as Selection, and the rest of
/// the state still loads.
#[test]
fn the_align_to_choice_is_kept_with_the_ui_state() {
let (mut app, _, _) = back_and_front();
let ctx = egui::Context::default();
let ([_, _, artboard], _) = panel_buttons(&mut app, &ctx);
click(&ctx, &mut app, artboard.center(), show);
assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["align_to"], "artboard");
let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
assert_eq!(back.align_to, AlignTo::Artboard);
for v in [json!("key"), json!("Artboard"), json!(3), Value::Null] {
let ui: crate::UiState = serde_json::from_value(json!({"align_to": v, "place_link": false})).unwrap();
assert_eq!((ui.align_to, ui.place_link), (AlignTo::Selection, false), "{v}");
}
let old: crate::UiState = serde_json::from_value(json!({})).unwrap();
assert_eq!(old.align_to, AlignTo::Selection, "preferences saved without it");
}
/// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the
@@ -372,11 +415,36 @@ mod tests {
assert!(!clickable, "disabled");
click(&ctx, &mut app, key.center(), show);
assert_eq!(app.session.active().unwrap().selection.key, None);
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
assert_eq!(align_to(&app), AlignTo::Selection);
click(&ctx, &mut app, left.center(), show);
assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge");
}
/// One frame of the Align panel → the rects of the Align To buttons drawn as chosen.
fn chosen_align_to(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::Rect> {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let fill = crate::theme::Tokens::get(ctx).tool_active;
let chosen = |s: &egui::Shape| match s {
egui::Shape::Rect(r) if r.fill == fill && r.rect.size() == egui::vec2(28.0, 28.0) => Some(r.rect),
_ => None,
};
out.shapes.iter().filter_map(|c| chosen(&c.shape)).collect()
}
/// With one object selected the align buttons align it to the artboard, and Align To shows
/// Artboard; with two, it shows the stored choice.
#[test]
fn align_to_shows_artboard_while_one_object_is_selected() {
let (mut app, _, front) = back_and_front();
let ctx = egui::Context::default();
let ([selection, _, artboard], _) = panel_buttons(&mut app, &ctx);
assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "two objects");
app.run("select.set", json!({"ids": [front]})).unwrap();
assert_eq!(chosen_align_to(&mut app, &ctx), [artboard], "one object");
assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice");
}
/// #789: the Control bar's and the Properties panel's align buttons follow Align To too.
#[test]
fn the_other_align_buttons_align_to_the_align_to_choice() {
@@ -386,7 +454,7 @@ mod tests {
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
set_pstate(&ctx, "align-to", AlignTo::Artboard);
app.ui.align_to = AlignTo::Artboard;
// One frame finds the first button (Horizontal Align Right is third); the next clicks it.
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
@@ -407,4 +475,55 @@ mod tests {
let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1;
assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge");
}
/// Runs the Object → Align item `label` as a click on it does.
fn object_align(app: &mut VectorcraftApp, label: &str) {
let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item");
let (id, p) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
crate::menus::invoke(app, &id, p);
}
/// Object → Align aligns as the align buttons do: it aligns one object to the active artboard
/// whatever Align To says, and with Align to Artboard chosen it aligns two objects to the active
/// artboard too.
#[test]
fn object_align_follows_align_to() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
let bounds = |app: &VectorcraftApp, id: &Value| {
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap()
};
app.run("select.set", json!({"ids": [b]})).unwrap();
object_align(&mut app, "Horizontal Align Left");
assert_eq!(bounds(&app, &b).x0, 0.0, "one object, Align to Selection: the artboard's left edge");
app.ui.align_to = AlignTo::Artboard;
app.view_mut().unwrap().artboard = 1;
app.run("select.set", json!({"ids": [a, b]})).unwrap();
object_align(&mut app, "Horizontal Align Right");
assert_eq!((bounds(&app, &a).x1, bounds(&app, &b).x1), (boards[1].x1, boards[1].x1), "the active artboard's right edge");
}
/// With anchor points selected, Object → Align sends its params unchanged: two anchor points of
/// one path align to their own bounds with Align to Selection and with Align to Artboard chosen.
#[test]
fn object_align_aligns_anchor_points_to_each_other() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap();
let anchors = |app: &VectorcraftApp| -> Vec<(f64, f64)> {
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
node.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect()
};
for to in [AlignTo::Selection, AlignTo::Artboard] {
app.ui.align_to = to;
app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
object_align(&mut app, "Horizontal Align Left");
assert_eq!(anchors(&app), [(100.0, 40.0), (100.0, 60.0), (150.0, 160.0)], "{to:?}: {}", app.ui.status);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(anchors(&app)[1], (200.0, 60.0), "undone");
}
}
}
+27 -1
View File
@@ -114,7 +114,8 @@ fn set_view(app: &mut VectorcraftApp, view: TraceView) {
/// The selected Image Trace object's View row (Control bar, Properties).
pub(crate) fn view_row(app: &mut VectorcraftApp, ui: &mut Ui, current: TraceView, width: f32) {
widgets::dim_label(ui, tl!("View:"));
if let Some(v) = view_dropdown(ui, current, width) {
// No wider than what the label leaves of the row (#1358).
if let Some(v) = view_dropdown(ui, current, width.min(ui.available_width())) {
set_view(app, v);
}
}
@@ -419,6 +420,31 @@ mod tests {
app
}
/// #1358: in Properties the View row fits the panel, so the panel doesn't widen and clip its
/// labels (the row took the panel's whole width for its menu, beside its label).
#[test]
fn the_view_row_fits_the_properties_panel() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// Where the panel's content ends after a few frames.
let right = |app: &mut VectorcraftApp| {
let mut right = 0.0;
for _ in 0..3 {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
crate::panels::properties::show(app, ui);
right = ui.min_rect().right();
});
out.textures_delta.clear();
}
right
};
let traced = right(&mut app);
app.run("select.none", json!({})).unwrap();
assert_eq!(traced, right(&mut app));
}
#[test]
fn advanced_is_a_section_that_stays_as_it_was_left() {
let mut app = traced_app();
+13 -6
View File
@@ -199,6 +199,7 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
/// Exit back to the Selection tool (#530).
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
let origin = app.ruler_origin();
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let i = super::artboards::selected(app, n);
let scale_art = super::artboards::scale_art(app);
@@ -214,8 +215,13 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
{
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
dim_label(ui, label);
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
app.run("artboard.setProps", json!({"index": i, key: v, "scaleArt": scale_art, "moveArt": move_art})).ok();
let offset = match key {
"x" => origin.x,
"y" => origin.y,
_ => 0.0,
};
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v - offset), units, fw) {
app.run("artboard.setProps", json!({"index": i, key: v + offset, "scaleArt": scale_art, "moveArt": move_art})).ok();
}
}
ui.end_row();
@@ -393,6 +399,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
}
let units = app.session.general_unit();
let origin = app.ruler_origin();
// The bounding box, rotated with rotated objects (as in the Transform panel).
let Some(bx) = app.selection_box() else {
dim_label(ui, tl!("No Selection"));
@@ -411,8 +418,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let link = app.session.prefs.constrain_proportions;
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x - origin.x), units, fw) {
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok();
}
dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) {
@@ -420,8 +427,8 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.end_row();
dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y), units, fw) {
app.run("object.setBounds", json!({"y": v, "reference": refi})).ok();
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y - origin.y), units, fw) {
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok();
}
dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) {
+10 -8
View File
@@ -52,6 +52,7 @@ pub fn artboard_rect(r: Rect, refi: usize, x: Option<f64>, y: Option<f64>, w: Op
/// The Artboard tool's view of the panel: the active artboard's position and size.
fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) {
let units = app.session.general_unit();
let origin = app.ruler_origin();
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
let link = app.session.prefs.constrain_proportions;
let rp = vectorcraft_geom::reference_point(r, refi);
@@ -71,8 +72,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
ui.add_space(4.0);
egui::Grid::new("xfp-ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
widgets::dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x), units, 80.0) {
set(app, Some(v), None, None, None);
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x - origin.x), units, 80.0) {
set(app, Some(v + origin.x), None, None, None);
}
widgets::dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) {
@@ -80,8 +81,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
}
ui.end_row();
widgets::dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y), units, 80.0) {
set(app, None, Some(v), None, None);
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y - origin.y), units, 80.0) {
set(app, None, Some(v + origin.y), None, None);
}
widgets::dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) {
@@ -104,6 +105,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
}
let units = app.session.general_unit();
let origin = app.ruler_origin();
// The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference
// point on the page.
let bx = app.selection_box();
@@ -120,8 +122,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_enabled_ui(has, |ui| {
egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
widgets::dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x), units, 80.0) {
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x - origin.x), units, 80.0) {
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok();
}
widgets::dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) {
@@ -129,8 +131,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.end_row();
widgets::dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y), units, 80.0) {
app.run("object.setBounds", json!({"y": v, "reference": refi})).ok();
if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y - origin.y), units, 80.0) {
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok();
}
widgets::dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) {
+37
View File
@@ -28,12 +28,20 @@ pub struct PrintJob<'a> {
pub pdf: &'a [u8],
}
/// Submission callable from the desktop background worker.
pub type SharedPrintFn = std::sync::Arc<dyn Fn(&PrintJob<'_>) -> Result<String, String> + Send + Sync>;
/// The platform's printing ([`crate::Services::print`]).
pub trait PrintService {
/// The printers the system knows (none: only the default one, or the browser's dialog).
fn printers(&mut self) -> Vec<Printer>;
/// Send `job` to its printer → what happened, for the status bar.
fn print(&mut self, job: &PrintJob) -> Result<String, String>;
/// Desktop services can submit on the existing background worker; browser services stay
/// on the UI thread to invoke their print dialog.
fn shared_print(&self) -> Option<SharedPrintFn> {
None
}
/// Whether Setup… can open the system's printer settings.
fn has_setup(&self) -> bool {
false
@@ -88,6 +96,35 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
let printer = s("printer").filter(|n| !n.is_empty());
let service = app.services.print.as_mut().ok_or("no print service")?;
if let Some(submit) = service.shared_print() {
let pending_printer = printer.clone();
let mut pending = crate::background::run(
app,
true,
format!("Printing {title}"),
None,
move |_| {
submit(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf }).map(|note| json!({ "note": note, "printer": printer }))
},
move |app, result| {
let receipt = result?;
app.status(receipt.get("note").and_then(Value::as_str).unwrap_or_default());
Ok(json!({ "printer": receipt["printer"], "printed": true }))
},
)?;
if pending.get("background").and_then(Value::as_bool) == Some(true) {
// Acceptance by the worker is not acceptance by the spooler.
pending["printer"] = json!(pending_printer);
pending["printed"] = json!(false);
}
// Preserve the engine's pages, byte count, format and warnings in automation replies.
if let Some(fields) = pending.as_object() {
for (key, value) in fields {
v[key] = value.clone();
}
}
return Ok(v);
}
let done = service.print(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf })?;
app.status(done);
v["printer"] = json!(printer);
+5
View File
@@ -360,6 +360,10 @@ pub struct UiState {
/// swatches) or "" (none) (`ui.colorGuideLimit`).
#[serde(default)]
pub color_guide_limit: String,
/// Align panel: the Align To choice, Selection or Artboard, which Object → Align and the align
/// buttons follow ([`crate::panels::align::with_align_to`]).
#[serde(default, deserialize_with = "crate::panels::align::deserialize_align_to")]
pub align_to: crate::panels::align::AlignTo,
/// The SVG Options chosen last (`svg` object of the export/save commands; null: never used).
#[serde(default)]
pub svg_options: Value,
@@ -639,6 +643,7 @@ impl Default for UiState {
library_panel: None,
flattener_preview: Default::default(),
color_guide_limit: String::new(),
align_to: Default::default(),
svg_options: Value::Null,
place_link: true,
dialog_file: None,
+81 -1
View File
@@ -1,5 +1,6 @@
//! Numeric fields: math typed before the unit suffix, and the whole text (unit included) selected
//! on focus or double-click so typing replaces it.
//! on keyboard focus, a released focus click or double-click so typing replaces it; a drag
//! keeps the pressed character as its selection anchor.
use std::cell::Cell;
@@ -49,6 +50,12 @@ impl Field {
std::iter::once(Event::PointerMoved(at)).chain((0..count).flat_map(|_| [button(true), button(false)])).collect()
}
fn text_point(&self, text: &str, index: usize, margin: f32) -> Pos2 {
let galley = self.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), egui::FontId::proportional(12.5), egui::Color32::BLACK));
let x = galley.pos_from_cursor(egui::text::CCursor::new(index)).left();
Pos2::new(self.rect.get().left() + margin + x + 0.1, self.rect.get().center().y)
}
/// The selected character range of the field's text.
fn selection(&self) -> Option<(usize, usize)> {
let st = egui::TextEdit::load_state(&self.ctx, self.id.get())?;
@@ -292,3 +299,76 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() {
}
}
}
fn numeric_fixture(ui: &mut egui::Ui, mode: usize) -> Option<f64> {
match mode {
0 => widgets::num_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0),
1 => widgets::num_field(ui, "f", Some(123456.0), Unit::Points, 100.0),
2 => widgets::plain_field(ui, "f", 123456.0, "°", 0, 100.0),
_ => widgets::opt_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0).flatten(),
}
}
fn field_text(mode: usize) -> &'static str {
["123456 px", "123456 pt", "123456°", "123456"][mode]
}
fn pointer_button(at: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
#[test]
fn numeric_drag_keeps_its_press_position_with_or_without_prior_focus() {
for mode in 0..4 {
for focused in [false, true] {
for (start, end) in [(1, 4), (4, 1)] {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
f.frame(vec![], &draw);
if focused {
f.frame(f.click(1), &draw);
f.frame(vec![], &draw);
f.time += 1.0;
}
let margin = if mode == 3 { 5.0 } else { 7.0 };
let at = f.text_point(field_text(mode), start, margin);
let to = f.text_point(field_text(mode), end, margin);
f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
assert_eq!(f.selection(), Some((start, start)), "mode {mode}, prior focus {focused}");
f.frame(vec![], &draw); // Holding still must leave the press position alone.
f.frame(vec![Event::PointerMoved(to)], &draw);
f.frame(vec![pointer_button(to, false)], &draw);
assert_eq!(f.selection(), Some((1, 4)), "mode {mode}, {start}->{end}, prior focus {focused}");
f.frame(vec![Event::Text("7".into())], &draw);
assert_eq!(f.frame(vec![enter()], &draw), Some(1756.0), "replace only the dragged text");
}
}
}
}
#[test]
fn a_new_numeric_click_selects_all_on_release_without_moving_its_press_caret() {
let mut f = Field::new();
f.num(vec![]);
let at = f.rect.get().center();
f.num(vec![Event::PointerMoved(at), pointer_button(at, true)]);
assert!(f.selection().is_some_and(|(a, b)| a == b), "a hold can still start a text drag");
f.num(vec![]);
assert!(f.selection().is_some_and(|(a, b)| a == b));
f.num(vec![pointer_button(at, false)]);
assert_eq!(f.selection(), Some((0, "100 px".len())));
f.num(vec![Event::Text("7".into())]);
assert_eq!(f.num(vec![enter()]), Some(7.0));
}
#[test]
fn keyboard_focus_keeps_selecting_the_entire_numeric_field() {
for mode in 0..4 {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
f.frame(vec![], &draw);
f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw);
f.frame(vec![], &draw);
assert_eq!(f.selection(), Some((0, field_text(mode).chars().count())), "mode {mode}");
}
}
+52
View File
@@ -63,3 +63,55 @@ fn postscript_is_saved_not_printed() {
assert_eq!(*sent.borrow(), 1);
assert!(app.run("file.print", json!({"format": "ps2"})).is_err());
}
/// Submission stays off the frame and the eventual queue receipt/refusal reaches the status bar.
#[test]
fn desktop_print_pending_receipt_and_refusal_are_distinct() {
use std::sync::{Arc, Mutex, mpsc};
struct AsyncPrinter {
release: Arc<Mutex<mpsc::Receiver<()>>>,
refused: bool,
}
impl PrintService for AsyncPrinter {
fn printers(&mut self) -> Vec<Printer> {
Vec::new()
}
fn print(&mut self, _: &PrintJob) -> Result<String, String> {
Err("UI thread must not submit".into())
}
fn shared_print(&self) -> Option<crate::print::SharedPrintFn> {
let release = self.release.clone();
let refused = self.refused;
Some(Arc::new(move |job| {
release.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).unwrap();
assert_eq!(job.printer, Some("Office Laser"));
assert!(job.pdf.starts_with(b"%PDF-"));
if refused { Err("queue refused this job".into()) } else { Ok("Sent to Office Laser".into()) }
}))
}
}
for refused in [false, true] {
let (release, wait) = mpsc::channel();
let services = Services {
print: Some(Box::new(AsyncPrinter { release: Arc::new(Mutex::new(wait)), refused })),
write_shared: Some(Arc::new(|_, _| Err("printing must not write a document".into()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({ "width": 100, "height": 80 })).unwrap();
let pending = app.run("file.print", json!({ "printer": "Office Laser" })).unwrap();
assert_eq!(pending["background"], true);
assert_eq!(pending["printed"], false);
assert_eq!(pending["pages"], 1);
assert!(pending["bytes"].as_u64().is_some_and(|bytes| bytes > 0));
assert!(!app.ui.status.starts_with("Sent"));
release.send(()).unwrap();
crate::background::wait_all(&mut app);
assert!(app.background.jobs.is_empty());
if refused {
assert!(app.ui.status.contains("queue refused this job"));
} else {
assert_eq!(app.ui.status, "Sent to Office Laser");
}
}
}
+203
View File
@@ -0,0 +1,203 @@
//! #1212: real ruler gestures and position fields share the document's zero point.
use egui::{Event, Key, PointerButton, Pos2, Rect, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::{NodeId, Unit};
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, chrome, panels};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.control_bar = true;
app
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
}
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0))),
time: Some(ctx.input(|i| i.time) + 0.05),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
texts
}
fn press(at: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn key(key: Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
fn start_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: Pos2) {
let at = pos2(8.0, 8.0);
frame(app, ctx, vec![Event::PointerMoved(at)], canvas::show);
frame(app, ctx, vec![press(at, true)], canvas::show);
frame(app, ctx, vec![Event::PointerMoved(to)], canvas::show);
}
#[test]
fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() {
let mut app = app();
app.select_tool("rectangle");
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
let v = app.view_mut().unwrap();
v.center = Point::new(220.0, 130.0);
v.zoom = 2.3;
v.rotation = 35.0;
frame(&mut app, &ctx, vec![], canvas::show);
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let goal = Point::new(125.25, 90.5);
let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(goal);
start_drag(&mut app, &ctx, to);
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "preview does not edit the document");
frame(&mut app, &ctx, vec![press(to, false)], canvas::show);
let st = app.session.doc().unwrap();
assert!(st.doc.ruler_origin.distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin);
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag");
assert!(st.doc.guides.is_empty());
let origin = st.doc.ruler_origin;
app.run("edit.undo", json!({})).unwrap();
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.ruler_origin(), origin);
}
#[test]
fn ruler_origin_double_click_resets_to_the_active_artboard() {
let mut app = app();
app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
app.run("document.setRulerOrigin", json!({"x": 125, "y": 90})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
app.view_mut().unwrap().artboard = 1;
let at = pos2(8.0, 8.0);
let undo = app.session.doc().unwrap().history.undo.len();
for _ in 0..2 {
frame(&mut app, &ctx, vec![Event::PointerMoved(at)], canvas::show);
frame(&mut app, &ctx, vec![press(at, true)], canvas::show);
frame(&mut app, &ctx, vec![press(at, false)], canvas::show);
}
assert_eq!(app.ruler_origin(), Point::new(700.0, -250.0));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.ruler_origin(), Point::new(125.0, 90.0));
}
#[test]
fn ruler_origin_cancelled_drags_do_not_modify_either_document() {
#[derive(Debug, PartialEq)]
enum Cancel {
Outside,
Escape,
Focus,
Document,
}
for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] {
let mut app = app();
app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
let to = app.canvas_rect.unwrap().center();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
start_drag(&mut app, &ctx, to);
let release = match cancel {
Cancel::Outside => pos2(4.0, 4.0),
Cancel::Escape => {
frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show);
to
}
Cancel::Focus => {
let mut out = ctx.run_ui(egui::RawInput { focused: false, ..Default::default() }, |ui| canvas::show(&mut app, ui));
out.textures_delta.clear();
to
}
Cancel::Document => {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
to
}
};
frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show);
if cancel == Cancel::Document {
assert_eq!(app.ruler_origin(), Point::ZERO);
app.run("document.activate", json!({"index": 0})).unwrap();
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
} else {
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "{cancel:?}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{cancel:?}");
}
}
}
#[test]
fn ruler_origin_position_fields_show_relative_values_and_edit_absolute_positions() {
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show, chrome::control_bar] {
for (label, arrow) in [("X:", (1.0, 0.0)), ("Y:", (0.0, 1.0))] {
let mut app = app();
let id = NodeId(app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap());
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], draw);
let texts = frame(&mut app, &ctx, vec![], draw);
assert!(texts.iter().filter(|(t, _)| t == &Unit::Points.format(20.0)).count() >= 2, "{texts:?}");
let label = texts.iter().find(|(t, _)| t == label).unwrap().1;
let at = pos2(label.right() + 40.0, label.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
frame(&mut app, &ctx, vec![press(at, false)], draw);
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
let bounds = app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert_eq!((bounds.x0, bounds.y0), (100.0 + arrow.0, 50.0 + arrow.1));
assert_eq!((bounds.width(), bounds.height()), (80.0, 40.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
}
}
#[test]
fn ruler_origin_artboard_fields_preserve_the_absolute_reference_point() {
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show] {
for label in ["X:", "Y:"] {
let mut app = app();
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
app.select_tool("artboard");
let ctx = context();
frame(&mut app, &ctx, vec![], draw);
let texts = frame(&mut app, &ctx, vec![], draw);
let label_rect = texts.iter().find(|(t, _)| t == label).unwrap().1;
let at = pos2(label_rect.right() + 40.0, label_rect.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
frame(&mut app, &ctx, vec![press(at, false)], draw);
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
let ab = app.session.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.x0, ab.y0), if label == "X:" { (1.0, 0.0) } else { (0.0, 1.0) });
assert_eq!((ab.width(), ab.height()), (400.0, 300.0));
}
}
}
+37 -6
View File
@@ -95,7 +95,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The user's choice alone: a window too short for the tools scrolls them.
let cols = if app.ui.toolbar_double { 2 } else { 1 };
let w = if cols == 2 { 76.0 } else { WIDTH };
// Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they
// overran the right border).
let w = if cols == 2 { 80.0 } else { WIDTH };
// Where the panel docks: along the window's left edge, under the bars.
let edge = ui.available_rect_before_wrap();
ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));
@@ -199,11 +201,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
// Centred on the panel's inside (`bounds`: within its border and, docked, the
// separator line egui reserves on its right), not on `WIDTH`, which sat them 2½ pt
// right of centre docked; nor on this row, which the scroll area starts ½ pt in.
ui.add_space((bounds.center().x - cols as f32 * 18.0 - ui.cursor().min.x).max(0.0));
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
@@ -862,6 +863,36 @@ pub(crate) mod tests {
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// The tool buttons sit in the middle of the panel, between its border and the separator
/// line, docked and floating, in one or two columns (docked, they sat 2½ pt right of centre).
#[test]
fn the_tool_buttons_are_centred_in_the_panel() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
for (double, floating) in [(false, None), (true, None), (false, Some([100.0, 100.0])), (true, Some([100.0, 100.0]))] {
app.run("window.toolbarColumns", json!({ "double": double })).unwrap();
app.ui.toolbar_pos = floating;
frame(&mut app, &ctx, 0.0, vec![]);
let tools = frame(&mut app, &ctx, 0.1, vec![]);
// The title bar spans the panel's inside.
let hdr = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.rect.height() == 14.0 && w.rect.width() > 40.0)
.map(|w| w.rect)
.next()
});
let hdr = hdr.unwrap();
let first = tools[0];
let row_right = tools.iter().filter(|r| r.top() == first.top()).map(|r| r.right()).fold(first.right(), f32::max);
assert_eq!(row_right - hdr.center().x, hdr.center().x - first.left(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}");
assert!(first.left() >= hdr.left() && row_right <= hdr.right(), "double {double}, floating {floating:?}: {first:?} in {hdr:?}");
}
}
/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a
/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel.
#[test]
+24 -4
View File
@@ -309,13 +309,33 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) {
st.store(ui.ctx(), id);
}
/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked
/// (egui would select one word): typing a number or an expression replaces it.
/// Numeric fields select the whole text on keyboard focus, a completed focus click or a
/// double-click. A pointer press keeps its caret so dragging selects from the pressed position.
fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
if !(resp.gained_focus() || resp.double_clicked()) {
let pending = resp.id.with("select-all-on-focus-click");
let (pressed, released) =
ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary)));
if resp.gained_focus() {
ui.data_mut(|d| {
if pressed {
d.insert_temp(pending, true);
} else {
d.remove_temp::<bool>(pending);
}
});
}
let clicked_focus = released && ui.data_mut(|d| d.remove_temp::<bool>(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary);
if resp.lost_focus() {
ui.data_mut(|d| d.remove_temp::<bool>(pending));
}
if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus) {
return;
}
if egui::TextEdit::load_state(ui.ctx(), resp.id).is_some() {
if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) {
// Preserve a text selection if the focus click already produced one.
if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) {
return;
}
select_all(ui, resp.id, text);
ui.ctx().request_repaint();
}
+9 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
@@ -52,6 +52,13 @@ down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/en
lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See
[file-format-parity.md](file-format-parity.md).
Desktop printer discovery and PDF submission use `craft-print`, pinned to an immutable
craft-libs revision. The engine still owns sheet composition, copies, separations and colour
management. Submission runs on the existing background worker; its receipt or refusal
reaches the status bar, and Quit waits for pending submissions. Windows 7 retains WMI discovery
and the installed viewer's Print/PrintTo verb;
its target excludes the shared backend because Windows.Data.Pdf is unavailable there.
## Agent control
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
@@ -69,5 +76,6 @@ saves copies in the background. See [development.md](development.md#robustness-v
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
| 2026-10-10 | minor | Added the `three-d` crate |
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more