Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick b12a52f724 PDF.R1: integrate guarded shared PDF writing and reading (#1894)
Integrate the shared PDF writer and reader as one qualified final tree. Preserve the separate writer and reader commits and review records.

All eight local CI gates and all eight hosted jobs passed; the checked source tree matches the final integration.
2026-10-14 22:46:16 -06:00
skylar taylor-barrick 05f6e2e6df PDF.R1: reconcile shared PDF migration with current main
Preserve the writer and reader changes while integrating the current main.

The combined candidate passes all eight local CI gates and the 2,048-case
reader panic verification. Hosted checks remain required before landing.

Refs #1893, #1894.
2026-10-14 22:02:49 -06:00
skylar taylor-barrickandskylartaylor 0946bc5b74 ci: add comfortable runtime limits to unbounded jobs (#2077)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:16:14 +02:00
Brandon ThomasandClaude Opus 5.5 0a33cff3f6 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:33 -04:00
skylar taylor-barrick d80950b3f3 CI: isolate the font cache test from parallel font loads 2026-10-14 06:40:06 -06:00
skylar taylor-barrick 664e2cec5f CI: keep command comparison fixtures independent of the clock 2026-10-14 05:10:20 -06:00
skylar taylor-barrick 2ecba1fb9c PDF.R1: reconcile shared reader with current writer 2026-10-14 03:59:03 -06:00
skylar taylor-barrick 52960faf63 PDF.W1: reconcile shared writer with current main 2026-10-14 01:23:19 -06:00
BFlat a067177778 Merge pull request #1223 from dotCooCoo/feat/shortcut-sets
Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
2026-10-14 14:12:14 +07:00
bflatastic d9c9526898 Keyboard Shortcuts: shortcut set reading and matching stay bounded on hostile files
A set whose entries sit in long, deeply nested sections kept every section's names again for each entry (up to hundreds of megabytes from a 4 MiB file); the names kept over all entries now stop at 8 MiB. Name matching compares character pairs in linear time, so a 20,000-entry set of long names is matched in about 2 s instead of 17 s.
2026-10-14 13:49:59 +07:00
dotCooCoo 8c889279f8 Keyboard Shortcuts: importMatches.list answers an unknown parameter key with an error
Since #1442 (3acc29f8), a caller outside the app gets an error for a
parameter key that the command's params description doesn't name, and
every_description_parses requires each top-level entry of the params
object to start with a key. The note on query in importMatches.list had
a comma at the top level ("text in the name, target or label"), so the
description didn't parse: the test failed, and the command took any key.
The note is now in parentheses, and the command takes kind and query.
A test checks that shortcutSet.read and the three importMatches
commands take every key they read and refuse a misspelled one.
2026-10-14 13:22:08 +07:00
dotCooCoo d1108bdbca i18n: Korean, Polish and Vietnamese rows for Import Shortcut Set and its commands
Since #1352, #1498 and #1507, Korean, Polish and Vietnamese are complete
catalogs: every menu string, tl! literal and status or error message has
a row, and the i18n tests check it. ko.tsv, pl.tsv and vi.tsv get the
rows that the other complete catalogs have for Import Shortcut Set and
its commands: the dialog's strings, the labels Import Shortcut Set…,
Read Shortcut Set File, Import Matches, Remember Import Matches and
Forget Import Matches, the two plurals and the two error messages. Copy
Report and Not read are already in all three, and Shortcut and the
Imported message in pl, so they are not repeated. The dialog's rows go
after Import Print Presets in ko and vi and after the Keyboard Shortcuts
dialog's rows in pl, and the two messages after "not a VectorCraft
shortcut set".
2026-10-14 13:22:08 +07:00
dotCooCoo 04244fa2a2 i18n: remove Import Shortcut Set rows that the catalogs already have
Check Script (3374d077) added Copy Report and Not read to every catalog,
and es and fr gained Shortcut and the Imported message in a747daa3 and
9e39a6d1. The rows that Import Shortcut Set added for the same strings
became second entries, which bundled_catalogs_are_consistent rejects.
They are removed, 26 rows in cs, de, es, fr, hu, it, ja, ru, uk, zh-hans
and zh-hant, and the existing rows stay as they are.
2026-10-14 13:22:08 +07:00
dotCooCoo 612a13b39c i18n: Hungarian rows for Import Shortcut Set and its commands
Since b9716366 (#1302), Hungarian is a complete catalog: every menu
string, tl! literal and status or error message has a hu row, and the
i18n tests check it. hu.tsv gets the rows that the other complete
catalogs have for Import Shortcut Set and its commands: 35 strings, the
two plurals in the single form hu.tsv uses (a count takes the
singular), and the two error messages. Shortcut and the Imported
message already have hu rows on main, so they are not repeated. The
rows are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo f1e634af19 i18n: Czech rows for Import Shortcut Set and its commands
Since #1189, Czech is a complete catalog: every menu string, tl! literal
and status or error message has a cs row, and the i18n tests check it.
cs.tsv gets the rows that the other complete catalogs have for Import
Shortcut Set and its commands: 35 strings, the two plurals with one, few
and other forms, and the two error messages. Shortcut and the Imported
message already have cs rows on main, so they are not repeated. The rows
are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo 8c14609370 Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
2026-10-14 13:22:07 +07:00
BFlat b076025a31 Merge pull request #1897 from storytold/fix/1881-typing-cmd-drag
Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
2026-10-14 12:38:12 +07:00
BFlat 3bb87989df Merge pull request #1896 from storytold/fix/pattern-tiles-brush-cms
Pattern tiles after brush edits and Color Settings; MCP docs on failed batches
2026-10-14 12:38:00 +07:00
BFlat a40e8f5aff Merge pull request #1895 from storytold/fix/1866-section-highlight
Fix #1866: Preferences and Save PDF highlight the section shown
2026-10-14 12:37:48 +07:00
BFlat 1a8e0456cb Merge pull request #1880 from marcoarnulfo/fix/tab-leader
Text: draw tab leaders in the tab's gap
2026-10-14 12:37:27 +07:00
bflatastic 7f1ad31a7b Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
With the Type tool editing, a press with Ctrl (Cmd on the Mac) lends the
Selection tool for the drag. Its drag joined the open typing session, so its
preview started from the document before the typing: the type moved or
scaled and lost what had just been typed. The typing is now kept as its own
undo step before the lent tool takes the press, as Undo already does, and
typing goes on after the drag.
2026-10-14 12:27:04 +07:00
bflatastic 6fd31d4dbd MCP docs: a failed batch leaves the files its steps wrote
The rollback of a failed command.batch (#1862) puts the documents back, not
the disk. Say so, and how to keep a batch from writing unless it succeeds.
2026-10-14 12:27:04 +07:00
bflatastic f9b051d3d1 Fix #1866: Preferences and Save PDF highlight the section shown
The section lists asked for a selectable button and then turned its frame
off while the pointer was elsewhere, which also dropped the selected row's
fill: the section shown looked like every other row. The selected row now
keeps its fill.
2026-10-14 12:27:04 +07:00
bflatastic 6bc0222074 #1723 follow-up: pattern tiles are drawn again after a brush edit or new Color Settings
A tile was kept while the pattern's art and what it shows were unchanged,
but a brush the art is stroked with lives in the document's other data, and
every tile is drawn through the color settings. A tile whose art reads more
than itself now also remembers the document's other data, and every tile
remembers the color settings it was drawn through (both weakly).
2026-10-14 12:27:04 +07:00
Marco Arnulfo eff2ccdc34 Text: draw tab leaders in the tab's gap (fixes #1877) 2026-10-14 12:27:03 +07:00
BFlat c73871eacc Merge pull request #1891 from storytold/fix/1691-shape-builder-gaps
Shape Builder: the gaps closed paths enclose are regions (#1691)
2026-10-14 12:14:45 +07:00
BFlat 2afd2654e8 Merge pull request #1859 from dotCooCoo/feat/proof-preview-prepared-document
Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
2026-10-14 12:14:20 +07:00
BFlat 725c3881d4 Merge pull request #1860 from dotCooCoo/feat/web-build-clock
Web: the status bar shows measured render and UI times instead of 0.0 ms
2026-10-14 12:14:03 +07:00
BFlat aa93258ea7 Merge pull request #1861 from dotCooCoo/fix/hidden-chars-release-when-off
Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
2026-10-14 12:13:42 +07:00
BFlat fe735f0e7d Merge pull request #1890 from storytold/docs/progress-1014b
Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters
2026-10-14 12:13:08 +07:00
bflatastic 65e2168791 Shape Builder: the gaps closed paths enclose are regions (#1691) 2026-10-14 11:28:21 +07:00
dotCooCoo c44ece3113 Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
With Separations Preview on, the renderer copied every node of the
document in every frame and separated each of its colors again. With
Overprint Preview on, it copied each object with an overprinting fill
or stroke in every frame, with the groups and layers that hold it. The
copies were new Arcs, so the geometry, stroke outline and raster
caches missed for each of them in every frame. In a release build,
drawing a 2880x1800 frame of a 50,000-path document again with the
same view and Separations Preview took 795 to 908 ms.

The renderer now keeps the art it prepared and what it was made from,
and in the next frame it uses that art again for each layer, symbol
art and member that has not changed. Only the objects that changed are
prepared again, with the layers and groups that hold them. All of the
art is prepared again when Discard White Overprint changes and, with
Separations Preview, when the plates shown, the proof setup, the
swatches, the Spot Colors option or the color settings change or a
profile is registered (cms::profiles_generation). The renderer now
draws that frame again in 98 to 107 ms, with the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo 014021577b Web: the status bar shows measured render and UI times instead of 0.0 ms
In the web build, now_ms() in crates/ui-egui/src/lib.rs returned 0.0.
The status bar read "render 0.0 ms · ui 0.0 ms" however long a frame
took, and the Layers panel's budget of 8 ms for the row thumbnails it
renders in a frame never ran out, so only its limit of 16 thumbnails
applied.

now_ms() now returns the milliseconds since its first call, read from
the browser's performance.now() through web-time, which the build
already has through eframe. The desktop clock is unchanged. In Chrome,
after a pan frame of 750 ms over a document of 50,000 paths, the status
bar now reads "render 750.7 ms". The Layers panel now renders the
thumbnails of 24 groups of 2,000 paths one per frame, where main
rendered nine of them in one frame of 217 ms. The canvas has no render
worker in the web build, so it still renders on the UI thread and draws
the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo abe89d5176 Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
Show Hidden Characters keeps each text object's marks together with
the node it laid them out from. Only a frame that drew the overlay
changed what it kept. After the option was turned off, or the
document was closed, those nodes stayed alive until the overlay was
next drawn.

The canvas now takes the kept marks at the start of each frame and
puts them back only when it draws the overlay. A frame that does not
draw it, for example with the option off or with no document open,
drops the marks and their nodes. A frame that draws the overlay still
keeps the marks of the frame before.
2026-10-14 11:22:41 +07:00
bflatastic 0c0d2448da Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters; macOS save sheet Format menu back to missing 2026-10-14 11:22:40 +07:00
skylar taylor-barrick e01a65d8a9 PDF.R1: integrate shared reader with bounded nesting checks 2026-10-13 21:58:01 -06:00
BFlat 5a120e323b Merge pull request #1875 from storytold/xtask/web-wasm-check
xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack
2026-10-14 10:29:29 +07:00
BFlat 5de774835b Merge pull request #1874 from storytold/fix/krilla-no-simple-text
PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730)
2026-10-14 10:29:19 +07:00
BFlat 1a280e1402 Merge pull request #1873 from storytold/fix/pdf-hostile-streams
Importers: PDF streams the reader would fail on are refused (#1700, #1701)
2026-10-14 10:29:09 +07:00
BFlat 8bd8e2b298 Merge pull request #1872 from storytold/fix/brushes-drawn-by-name
Importers: a brush that draws itself by name no longer crashes
2026-10-14 10:28:57 +07:00
BFlat cd1b590aa8 Merge pull request #1871 from storytold/fix/pdf-nested-containers
Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8)
2026-10-14 10:28:48 +07:00
BFlat dcd0da87cb Merge pull request #1870 from storytold/fix/svg-link-scope
Importers: an SVG's linked files are read within the automation roots again
2026-10-14 10:28:37 +07:00
skylar taylor-barrick a699a26221 refactor(pdf): use shared writer for export and print (PDF.W1) 2026-10-13 20:41:14 -06:00
125 changed files with 6314 additions and 499 deletions

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
+8
View File
@@ -35,6 +35,7 @@ env:
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
@@ -63,6 +64,7 @@ jobs:
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
@@ -113,6 +115,7 @@ jobs:
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
@@ -174,6 +177,7 @@ jobs:
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
@@ -243,6 +247,7 @@ jobs:
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
@@ -335,6 +340,7 @@ jobs:
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
@@ -378,6 +384,7 @@ jobs:
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
@@ -458,6 +465,7 @@ jobs:
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
+24
View File
@@ -333,3 +333,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
## Assets embedded by the PDF backend
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+78 -66
View File
@@ -806,6 +806,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"hayro-interpret",
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1304,12 +1315,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fearless_simd"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1766,9 +1771,9 @@ dependencies = [
[[package]]
name = "hayro-ccitt"
version = "0.4.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
@@ -1782,9 +1787,8 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -1794,28 +1798,27 @@ dependencies = [
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.46.2",
"skrifa 0.47.0",
"smallvec",
"yoke",
]
[[package]]
name = "hayro-jbig2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"fearless_simd 1.1.0",
"fearless_simd",
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.4.1"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
dependencies = [
"fearless_simd 1.1.0",
"fearless_simd",
]
[[package]]
@@ -1826,9 +1829,8 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -1837,6 +1839,7 @@ dependencies = [
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -2227,8 +2230,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3839,16 +3841,6 @@ dependencies = [
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
dependencies = [
"bytemuck",
"read-fonts 0.43.3",
]
[[package]]
name = "skrifa"
version = "0.47.0"
@@ -4011,6 +4003,17 @@ dependencies = [
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -4395,6 +4398,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -4506,7 +4518,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4541,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4552,7 +4564,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4564,7 +4576,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4580,7 +4592,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4602,7 +4614,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4614,7 +4626,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4631,7 +4643,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4646,7 +4658,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4682,7 +4694,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
@@ -4703,7 +4715,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"proptest",
@@ -4718,7 +4730,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4730,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"log",
@@ -4749,7 +4761,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4763,7 +4775,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4775,12 +4787,11 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"aes",
"craft-pdf",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
@@ -4805,7 +4816,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
"serde_json",
@@ -4819,7 +4830,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"gif",
@@ -4844,14 +4855,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
@@ -4875,7 +4886,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4892,7 +4903,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4912,7 +4923,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4920,7 +4931,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4935,7 +4946,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4947,7 +4958,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -4976,11 +4987,12 @@ dependencies = [
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
"web-time",
]
[[package]]
name = "vectorcraft-web"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -5003,7 +5015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -5018,7 +5030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -6090,7 +6102,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
]
+8 -1
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
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
@@ -111,3 +112,9 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
+20
View File
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
+7 -1
View File
@@ -1,12 +1,15 @@
# VectorCraft Roadmap
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -129,6 +132,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
@@ -166,6 +171,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
+1 -1
View File
@@ -310,7 +310,7 @@ fn notice(lines: &[&str]) {
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
+11 -1
View File
@@ -13,7 +13,7 @@ pub mod generic;
pub mod icc;
pub mod lab;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
@@ -165,6 +165,8 @@ fn generic() -> Arc<GenericCmyk> {
}
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]).
static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0);
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
@@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
u.push(Arc::new(p));
drop(u);
clear_proof_cache();
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
/// as long as the number stays the same.
pub fn profiles_generation() -> u64 {
PROFILES_GENERATION.load(Ordering::Relaxed)
}
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
+5
View File
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
/// A weak reference to the value: it keeps the allocation, so the value's address names it
/// while the reference is kept, but not the value.
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
Arc::downgrade(&this.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
+1
View File
@@ -683,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
+2 -2
View File
@@ -159,8 +159,8 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
8 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
+172
View File
@@ -0,0 +1,172 @@
//! What names and codes in other apps' files stand for in VectorCraft, as the user confirmed them on
//! import: a shortcut set's entry names, and its key and modifier codes. They are kept with the
//! preferences on the user's machine, so a later import proposes them and asks only about the rest
//! (`importMatches.*`).
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::*;
use crate::Prefs;
/// The most matches kept.
const MAX_MATCHES: usize = 20_000;
/// The longest name, target or label kept (bytes).
const MAX_TEXT: usize = 1024;
/// The kinds of names: entry names (commands, tools and panels), and a shortcut set's key and
/// modifier codes.
pub const KINDS: &[&str] = &["name", "key", "modifiers"];
/// A name or code in another app's file and what it stands for in VectorCraft
/// ([`crate::Prefs::import_matches`]). Missing fields read as empty, so a preferences file with a
/// damaged match still loads.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImportMatch {
/// One of [`KINDS`].
pub kind: String,
/// As the file writes it: an entry's name, or a code in decimal (`48`).
pub name: String,
/// Kind `name`: a command id, `tool:<id>` or `panel:<id>`, or "" for no equivalent. Kind `key`: a
/// key as Edit › Keyboard Shortcuts writes it (`K`, `]`, `F5`), or "" for no equivalent. Kind
/// `modifiers`: modifiers as it writes them (`Cmd+Shift`), or "" for none.
pub target: String,
/// What the target was called when it was chosen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "importMatches.list",
"Import Matches",
[],
None,
"{kind?: name|key|modifiers, query?: text (in the name, target or label; any case)} the matches the user confirmed when importing other apps' files (shortcut sets), kept with the preferences → [{kind, name, target, label?}] (target \"\": no equivalent)",
always,
list
),
cmd!(
query "importMatches.set",
"Remember Import Matches",
[],
None,
"{matches: [{kind: name|key|modifiers, name: as the file writes it (codes in decimal), target: a command id, tool:<id> or panel:<id> (name), a key as Edit › Keyboard Shortcuts writes it (key), modifiers as it writes them, such as Cmd+Shift (modifiers), \"\" for no equivalent (no modifiers for kind modifiers), or null to forget the match, label?}]} remember what names and codes in another app's file stand for in VectorCraft, for later imports (at most 20,000 matches; names, targets and labels up to 1,024 bytes) → {count}",
always,
set
),
cmd!(
query "importMatches.reset",
"Forget Import Matches",
[],
None,
"{kind?: name|key|modifiers} forget the import matches, all of them or those of one kind → {removed}",
always,
reset
),
]
}
/// The target the user confirmed for each kind and name.
pub fn targets(prefs: &Prefs) -> HashMap<(&str, &str), &str> {
let mut out = HashMap::new();
for m in &prefs.import_matches {
out.entry((m.kind.as_str(), m.name.as_str())).or_insert(m.target.as_str());
}
out
}
/// `kind` when it is one of [`KINDS`].
fn kind_param(v: Option<&Value>, cmd: &str) -> Result<Option<&'static str>> {
match v.filter(|v| !v.is_null()) {
None => Ok(None),
Some(k) => k
.as_str()
.and_then(|k| KINDS.iter().copied().find(|x| *x == k))
.map(Some)
.ok_or_else(|| bad(cmd, format!("`kind` is one of {}", KINDS.join(", ")))),
}
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p.get("kind"), "importMatches.list")?;
let q = str_param(p, "query").unwrap_or_default().to_lowercase();
let hit = |m: &ImportMatch| {
q.is_empty() || [Some(&m.name), Some(&m.target), m.label.as_ref()].into_iter().flatten().any(|t| t.to_lowercase().contains(&q))
};
let rows: Vec<&ImportMatch> = s.prefs.import_matches.iter().filter(|m| kind.is_none_or(|k| m.kind == k) && hit(m)).collect();
Ok(serde_json::to_value(rows).unwrap_or_else(|_| json!([])))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "importMatches.set";
let rows = p.get("matches").and_then(Value::as_array).ok_or_else(|| bad(C, "give `matches`: [{kind, name, target}]"))?;
if rows.len() > MAX_MATCHES {
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
}
let text = |v: Option<&Value>, what: &str| -> Result<Option<String>> {
match v.filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => {
let t = v.as_str().ok_or_else(|| bad(C, format!("`{what}` must be text")))?;
if t.len() > MAX_TEXT {
return Err(bad(C, format!("`{what}` is longer than {MAX_TEXT} bytes")));
}
Ok(Some(t.to_string()))
}
}
};
// Checked whole before anything changes.
let mut changes: Vec<(ImportMatch, bool)> = vec![];
for row in rows {
let kind = kind_param(row.get("kind"), C)?.ok_or_else(|| bad(C, "each match needs `kind`"))?;
let name = text(row.get("name"), "name")?.filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "each match needs a `name`"))?;
let forget = row.get("target").is_none_or(Value::is_null);
let target = text(row.get("target"), "target")?.unwrap_or_default();
let label = text(row.get("label"), "label")?;
changes.push((ImportMatch { kind: kind.to_string(), name, target, label }, forget));
}
// Forgotten matches leave a gap until the end, so the others keep their places.
let mut next: Vec<Option<ImportMatch>> = s.prefs.import_matches.iter().cloned().map(Some).collect();
let mut at: HashMap<(String, String), usize> = HashMap::new();
for (i, m) in s.prefs.import_matches.iter().enumerate() {
at.entry((m.kind.clone(), m.name.clone())).or_insert(i);
}
for (m, forget) in changes {
let key = (m.kind.clone(), m.name.clone());
match (at.get(&key).copied(), forget) {
(Some(i), true) => {
if let Some(slot) = next.get_mut(i) {
*slot = None;
}
at.remove(&key);
}
(Some(i), false) => {
if let Some(slot) = next.get_mut(i) {
*slot = Some(m);
}
}
(None, true) => {}
(None, false) => {
at.insert(key, next.len());
next.push(Some(m));
}
}
}
let next: Vec<ImportMatch> = next.into_iter().flatten().collect();
if next.len() > MAX_MATCHES {
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
}
s.prefs.import_matches = next;
Ok(json!({ "count": s.prefs.import_matches.len() }))
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p.get("kind"), "importMatches.reset")?;
let before = s.prefs.import_matches.len();
s.prefs.import_matches.retain(|m| kind.is_some_and(|k| m.kind != k));
Ok(json!({ "removed": before - s.prefs.import_matches.len() }))
}
+1 -1
View File
@@ -206,7 +206,7 @@ fn kept_layers(s: &Session, p: &Value, command: &str) -> Result<HashSet<NodeId>>
/// on a hidden layer's eye shows just that layer (#1158).
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool, command: &str) -> Result<Value> {
let keep = kept_layers(s, p, command)?;
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, keep.contains(&l.id) != v)).collect();
set_flag(s, changes, flag, flag.label(v))
}
+5
View File
@@ -31,6 +31,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
pub mod importmatches;
pub(crate) mod inline;
mod layer;
mod layerpanel;
@@ -67,6 +68,7 @@ pub mod recovery;
pub mod scripts;
mod select;
pub(crate) mod shaper;
pub mod shortcutset;
pub(crate) mod slices;
mod stroke;
mod style;
@@ -283,6 +285,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(cut::specs());
v.extend(halftone::specs());
v.extend(perspgrid::specs());
v.extend(shortcutset::specs());
v.extend(importmatches::specs());
v
})
}
@@ -457,6 +461,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+4
View File
@@ -90,9 +90,13 @@ fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
}
let arrangement = po::shape_builder(&shapes, true);
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
// A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps
// closed paths enclose are the Shape Builder's only.
let open = po::has_open_path(&shapes);
let regions = arrangement
.regions
.into_iter()
.filter(|r| open || !r.sources.is_empty())
.filter_map(|r| {
let bounds = r.path.bounds()?;
let path = r.path.to_bezpath();
+393
View File
@@ -0,0 +1,393 @@
//! Shortcut sets another app saves as text (`.kys` files that start `/Menus {`), read into their
//! entries without changing anything (`shortcutSet.read`). Names and codes the user matched on an
//! earlier import ([`super::importmatches`]) come back with what they stand for.
//!
//! The text is a tree of blocks. A slash name opens a block with `{` (a backslash in a name takes the
//! next character as it is, so `/Acme\ Lasso\ Tool` names "Acme Lasso Tool"), and `}` closes it.
//! Inside a block, a slash name followed by an integer sets a field: `/Context`, `/Modifiers`,
//! `/Represent` and `/Key`. A block that sets `/Key` is an entry, and the blocks around it are its
//! section. The layout follows the description at https://filext.com/file-extension/KYS and the
//! example in the MIT-licensed AiCommandPalette README
//! (https://github.com/joshbduncan/AiCommandPalette), whose entry for the 0 key holds `/Key 48` and
//! `/Modifiers 0`. No public source says what the other codes mean, so they are read as numbers.
use serde_json::{Value, json};
use super::*;
/// The most bytes of a file read.
const MAX_BYTES: usize = 4 << 20;
/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text
/// goes on.
pub const READ_LIMIT: usize = MAX_BYTES + 4;
/// The most blocks open at once.
const MAX_DEPTH: usize = 16;
/// The most entries read.
const MAX_ENTRIES: usize = 20_000;
/// The longest name kept (bytes).
const MAX_NAME: usize = 1024;
/// The most bytes of names kept over all entries, sections included: each entry repeats the names
/// of the blocks around it, so a small file could otherwise fill hundreds of megabytes.
const MAX_KEPT: usize = 2 * MAX_BYTES;
/// The most tokens listed as unread.
const MAX_UNREAD: usize = 1000;
/// The longest unread token shown (characters).
const UNREAD_SHOWN: usize = 80;
/// What `shortcutSet.read` says about a file that isn't a text shortcut set.
pub const NOT_READABLE: &str = "This shortcut file is in a form VectorCraft can't read.";
/// The extensions of shortcut sets other apps save (File › Open lists them).
pub const EXTS: &[&str] = &["kys"];
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "shortcutSet.read",
"Read Shortcut Set File",
[],
None,
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read a shortcut set another app saved as text (.kys, starting \"/Menus {\") without changing anything: each entry is a block that sets /Key, with the names of the blocks around it as its section → {set: the file's name without its extension, entries: [{section: [names], name, key, modifiers, context, represent, line, target?, chord?}] (codes as the file writes them; null where the entry doesn't set one; target: what importMatches says the name stands for; chord: the shortcut its codes stand for, as Edit › Keyboard Shortcuts writes it, when importMatches decodes them, an entry without /Modifiers taking none), codes: {keys: [{value, count, key?}], modifiers: [{value, count, mods?}]} (key and mods from importMatches), unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (4 MiB, 16 open blocks, 20,000 entries, 8 MiB of names over the entries and their sections; names past 1,024 bytes are cut)}. A file in another form (such as XML) is an error",
always,
read
)]
}
/// An entry of a shortcut set: a block that sets `/Key`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysEntry {
/// The names of the blocks around it, outermost first (`["Menus"]`).
pub section: Vec<String>,
pub name: String,
pub key: Option<i64>,
pub modifiers: Option<i64>,
pub context: Option<i64>,
pub represent: Option<i64>,
/// The line its name is on (from 1).
pub line: usize,
}
/// A shortcut set read from text.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysSet {
pub entries: Vec<KysEntry>,
/// The tokens that weren't read, with their lines.
pub unread: Vec<(usize, String)>,
/// A cap stopped the reading, or cut a name.
pub truncated: bool,
}
/// A token of the text.
enum Token {
/// A slash name, its escapes undone.
Name(Vec<u8>),
/// An integer; `None` when it doesn't fit an `i64`.
Int(Option<i64>),
Open,
Close,
/// Anything else, as written.
Other(Vec<u8>),
}
/// The tokens of `text`, each with its line.
struct Tokens<'a> {
text: &'a [u8],
at: usize,
line: usize,
}
impl Tokens<'_> {
fn next_token(&mut self) -> Option<(usize, Token)> {
// Whitespace: LF, CR and CRLF each end a line.
while let Some(&b) = self.text.get(self.at) {
match b {
b'\n' => self.line += 1,
b'\r' if self.text.get(self.at + 1) != Some(&b'\n') => self.line += 1,
b' ' | b'\t' | b'\r' | 0x0C | 0 => {}
_ => break,
}
self.at += 1;
}
let line = self.line;
let b = *self.text.get(self.at)?;
let ends = |b: u8| b.is_ascii_whitespace() || matches!(b, b'{' | b'}' | b'/' | 0);
let token = match b {
b'{' => {
self.at += 1;
Token::Open
}
b'}' => {
self.at += 1;
Token::Close
}
b'/' => {
self.at += 1;
let mut name = vec![];
while let Some(&c) = self.text.get(self.at) {
if c == b'\\' {
// The next character as it is (a backslash at the end of the text is dropped).
if let Some(&n) = self.text.get(self.at + 1) {
name.push(n);
if n == b'\n' || n == b'\r' && self.text.get(self.at + 2) != Some(&b'\n') {
self.line += 1;
}
}
self.at += 2;
} else if ends(c) {
break;
} else {
name.push(c);
self.at += 1;
}
}
Token::Name(name)
}
_ => {
let start = self.at;
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
self.at += 1;
}
// Always past at least one byte.
self.at = self.at.max(start + 1);
let word = self.text.get(start..self.at).unwrap_or_default();
let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word);
if !number.is_empty() && number.iter().all(u8::is_ascii_digit) {
Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok()))
} else {
Token::Other(word.to_vec())
}
}
};
Some((line, token))
}
}
/// A block being read.
struct Block {
name: String,
line: usize,
fields: [Option<i64>; 4],
}
/// The fields an entry's block sets, in [`Block::fields`] order.
const FIELDS: [&[u8]; 4] = [b"Key", b"Modifiers", b"Context", b"Represent"];
/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters.
fn name_text(name: &[u8], set: &mut KysSet) -> String {
if name.len() > MAX_NAME {
set.truncated = true;
}
let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned();
// A character cut at the limit reads as one replacement character.
if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') {
s.pop();
}
s
}
/// List a token that isn't read.
fn unread(set: &mut KysSet, line: usize, text: impl AsRef<[u8]>) {
if set.unread.len() < MAX_UNREAD {
let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect();
set.unread.push((line, text));
}
}
/// Read a shortcut set saved as text. An error when the text isn't one: it starts with `<` (XML) or
/// doesn't start with a slash name and `{`.
pub fn read_kys(bytes: &[u8]) -> std::result::Result<KysSet, String> {
let mut set = KysSet::default();
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let text = match text.get(..MAX_BYTES) {
Some(head) if text.len() > MAX_BYTES => {
set.truncated = true;
head
}
_ => text,
};
let mut tokens = Tokens { text, at: 0, line: 1 };
// The first two tokens show what the text is.
let mut first = Tokens { text, at: 0, line: 1 };
match (first.next_token(), first.next_token()) {
(Some((_, Token::Name(_))), Some((_, Token::Open))) => {}
(Some((_, Token::Other(w))), _) if w.starts_with(b"<") => return Err(NOT_READABLE.into()),
_ => return Err("not a shortcut set: the text doesn't start with a slash name and an opening brace".into()),
}
let mut stack: Vec<Block> = vec![];
// A slash name waiting for its `{` or its integer: (line, name).
let mut pending: Option<(usize, Vec<u8>)> = None;
// Blocks past [`MAX_DEPTH`] being skipped.
let mut skipped = 0usize;
// The bytes of names the entries keep so far ([`MAX_KEPT`]).
let mut kept = 0usize;
while let Some((line, token)) = tokens.next_token() {
if skipped > 0 {
match token {
Token::Open => skipped += 1,
Token::Close => skipped -= 1,
_ => {}
}
continue;
}
match token {
Token::Name(name) => {
if let Some((l, n)) = pending.replace((line, name)) {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
}
Token::Int(value) => match pending.take() {
Some((_, name)) if !stack.is_empty() && FIELDS.contains(&name.as_slice()) && value.is_some() => {
let i = FIELDS.iter().position(|f| *f == name.as_slice()).unwrap_or(0);
if let Some(slot) = stack.last_mut().and_then(|b| b.fields.get_mut(i)) {
*slot = value;
}
}
Some((l, name)) => {
let shown = match value {
Some(v) => format!("/{} {v}", String::from_utf8_lossy(&name)),
None => format!("/{} (a number too large)", String::from_utf8_lossy(&name)),
};
unread(&mut set, l, shown);
}
None => unread(&mut set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())),
},
Token::Open => {
let (l, name) = pending.take().unwrap_or((line, vec![]));
if stack.len() >= MAX_DEPTH {
set.truncated = true;
unread(&mut set, l, format!("blocks open more than {MAX_DEPTH} deep"));
skipped = 1;
continue;
}
let name = name_text(&name, &mut set);
stack.push(Block { name, line: l, fields: [None; 4] });
}
Token::Close => {
if let Some((l, n)) = pending.take() {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
let Some(block) = stack.pop() else {
unread(&mut set, line, "}");
continue;
};
let [key, modifiers, context, represent] = block.fields;
if key.is_some() {
kept += block.name.len() + stack.iter().map(|b| b.name.len()).sum::<usize>();
if set.entries.len() >= MAX_ENTRIES || kept > MAX_KEPT {
set.truncated = true;
break;
}
let section = stack.iter().map(|b| b.name.clone()).collect();
set.entries.push(KysEntry { section, name: block.name, key, modifiers, context, represent, line: block.line });
}
}
Token::Other(word) => {
if let Some((l, n)) = pending.take() {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
unread(&mut set, line, word);
}
}
}
if let Some((l, n)) = pending {
unread(&mut set, l, [b"/".as_slice(), &n].concat());
}
for block in stack.iter().rev() {
unread(&mut set, block.line, format!("/{} {{ (not closed)", block.name));
}
Ok(set)
}
/// Each code of `codes` with how many entries use it, the most used first.
fn counts(codes: impl Iterator<Item = i64>) -> Vec<(i64, usize)> {
let mut by: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
for c in codes {
*by.entry(c).or_default() += 1;
}
let mut v: Vec<(i64, usize)> = by.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v
}
/// The shortcut text for `mods` (`Cmd+Shift`, or "" for none) and `key`, as Edit › Keyboard
/// Shortcuts writes it; none when the key has no equivalent.
fn chord(mods: &str, key: &str) -> Option<String> {
match (mods, key) {
(_, "") => None,
("", k) => Some(k.to_string()),
(m, k) => Some(format!("{m}+{k}")),
}
}
/// The bytes base64 text `b64` holds, up to [`READ_LIMIT`] and a few more: only the text they take is
/// decoded.
fn decode_head(b64: &str) -> Option<Vec<u8>> {
let chars = READ_LIMIT.div_ceil(3) * 4;
let mut kept = 0usize;
let end = b64
.bytes()
.position(|b| {
kept += usize::from(!b.is_ascii_whitespace());
kept > chars
})
.unwrap_or(b64.len());
vectorcraft_format::base64_decode(b64.get(..end)?)
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shortcutSet.read";
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file_head(path, READ_LIMIT)?.into(),
(None, Some(text), _) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(),
(None, None, Some(b64)) => decode_head(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let set = read_kys(&bytes).map_err(EngineError::Other)?;
let file = str_param(p, "name").or_else(|| str_param(p, "path")).unwrap_or_default();
let name = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let targets = importmatches::targets(&s.prefs);
let saved = |kind: &str, name: &str| targets.get(&(kind, name)).copied();
let code = |kind: &str, c: i64| saved(kind, &c.to_string());
let entries: Vec<Value> = set
.entries
.iter()
.map(|e| {
let mut v = json!({
"section": e.section, "name": e.name, "key": e.key, "modifiers": e.modifiers,
"context": e.context, "represent": e.represent, "line": e.line,
});
if let Some(t) = saved("name", &e.name) {
v["target"] = json!(t);
}
let mods = match e.modifiers {
Some(m) => code("modifiers", m),
None => Some(""),
};
if let Some(c) = e.key.and_then(|k| code("key", k)).zip(mods).and_then(|(k, m)| chord(m, k)) {
v["chord"] = json!(c);
}
v
})
.collect();
let rows = |codes: Vec<(i64, usize)>, kind: &str, field: &str| -> Vec<Value> {
codes
.into_iter()
.map(|(value, count)| {
let mut v = json!({ "value": value, "count": count });
if let Some(t) = code(kind, value) {
v[field] = json!(t);
}
v
})
.collect()
};
Ok(json!({
"set": name,
"entries": entries,
"codes": {
"keys": rows(counts(set.entries.iter().filter_map(|e| e.key)), "key", "key"),
"modifiers": rows(counts(set.entries.iter().filter_map(|e| e.modifiers)), "modifiers", "mods"),
},
"unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::<Vec<_>>(),
"truncated": set.truncated,
}))
}
+34
View File
@@ -113,4 +113,38 @@ mod tests {
s.execute("text.tabs.clear", &json!({})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([]));
}
/// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots.
#[test]
fn tab_leader_fills_the_gap_with_its_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap();
s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap();
let st = s.doc().unwrap();
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
let (from, to) = (x_of(3), x_of(4));
assert!(to - from > 150.0, "{from}..{to}");
// Contours drawn inside the gap: only the leader's dots can be there.
let mut dots = 0;
for g in &lay.glyphs {
let mut contours: Vec<kurbo::BezPath> = vec![];
for el in g.outline.elements() {
match (el, contours.last_mut()) {
(kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])),
(_, Some(c)) => c.push(*el),
}
}
for c in contours {
let r = kurbo::Shape::bounding_box(&c);
if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 {
assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}");
dots += 1;
}
}
}
assert!(dots > 30, "{dots} dots in a {} pt gap", to - from);
}
}
+9
View File
@@ -12,6 +12,7 @@ pub mod guard;
pub mod inspect;
pub mod journal;
pub mod link_watch;
pub mod namematch;
pub mod steps;
mod tooling;
pub mod units;
@@ -856,6 +857,11 @@ pub struct Prefs {
/// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane
/// Switching Widget shows and where (`perspective.widget.options`).
pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions,
/// What names and codes in other apps' shortcut sets stand for, as the user confirmed them on
/// import (`importMatches.*`). A local library, not a Preferences dialog field: resetting the
/// preferences keeps it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub import_matches: Vec<cmd::importmatches::ImportMatch>,
}
impl Default for Prefs {
@@ -1001,6 +1007,7 @@ impl Default for Prefs {
perspective_presets: vec![],
blend_options: None,
perspective_widget: Default::default(),
import_matches: vec![],
}
}
}
@@ -1959,6 +1966,8 @@ mod tests_scripts;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_shortcutset;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
+328
View File
@@ -0,0 +1,328 @@
//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's
//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate
//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from.
use std::collections::HashMap;
/// A command, tool or panel a name can stand for.
#[derive(Clone, Debug)]
pub struct Candidate {
/// `select.all`, `tool:rectangle` or `panel:layers`.
pub id: String,
/// Its labels: the English one, and the one in the interface language.
pub labels: Vec<String>,
}
/// What a name stands for, as far as [`propose`] can tell.
#[derive(Clone, Debug, PartialEq)]
pub enum Proposal {
/// The candidate at this index of the list, with its score (0 to 1).
Match(usize, f64),
/// The best candidates, best first, when none stands out.
Choice(Vec<usize>),
}
/// The lowest score proposed.
const THRESHOLD: f64 = 0.6;
/// How far the best score must lead the next one to be proposed.
const MARGIN: f64 = 0.1;
/// The most candidates a [`Proposal::Choice`] lists.
const CHOICES: usize = 5;
/// The lowest score a [`Proposal::Choice`] lists.
const CHOICE_FLOOR: f64 = 0.3;
/// `s` with only its letters and digits, lowercased.
fn folded(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit
/// meets an uppercase one (`selectAll`: `select`, `all`).
fn words(s: &str) -> Vec<String> {
let mut out = vec![];
let mut word = String::new();
let mut prev_lower = false;
for c in s.chars() {
if !c.is_alphanumeric() {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
prev_lower = false;
continue;
}
if c.is_uppercase() && prev_lower && !word.is_empty() {
out.push(std::mem::take(&mut word));
}
prev_lower = c.is_lowercase() || c.is_ascii_digit();
word.extend(c.to_lowercase());
}
if !word.is_empty() {
out.push(word);
}
out
}
/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`].
fn id_words(id: &str) -> Vec<String> {
let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id);
id.split('.').flat_map(words).collect()
}
/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1).
fn word_overlap(a: &[String], b: &[String]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut rest: Vec<&String> = b.iter().collect();
let mut shared = 0usize;
for w in a {
if let Some(i) = rest.iter().position(|x| *x == w) {
rest.swap_remove(i);
shared += 1;
}
}
(2 * shared) as f64 / (a.len() + b.len()) as f64
}
/// The pairs of neighboring characters in `s`.
fn pairs(s: &str) -> Vec<(char, char)> {
let c: Vec<char> = s.chars().collect();
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for
/// names written without spaces. Linear in their lengths, so long names in a file stay quick.
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize);
while let (Some(p), Some(q)) = (a.get(i), b.get(j)) {
match p.cmp(q) {
std::cmp::Ordering::Less => i += 1,
std::cmp::Ordering::Greater => j += 1,
std::cmp::Ordering::Equal => {
shared += 1;
i += 1;
j += 1;
}
}
}
(2 * shared) as f64 / (a.len() + b.len()) as f64
}
/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the
/// character pairs of the former (sorted).
struct Text {
folded: String,
words: Vec<String>,
pairs: Vec<(char, char)>,
}
impl Text {
fn of(s: &str) -> Self {
let folded = folded(s);
let mut pairs = pairs(&folded);
pairs.sort_unstable();
Self { pairs, words: words(s), folded }
}
}
/// A candidate as the scores compare it.
struct Prepared {
/// Its id's words, and those joined.
id_words: Vec<String>,
id: String,
labels: Vec<Text>,
}
impl Prepared {
fn of(c: &Candidate) -> Self {
let id_words = id_words(&c.id);
Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() }
}
/// See [`score`].
fn score(&self, name: &Text) -> f64 {
if name.folded.is_empty() {
return 0.0;
}
if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) {
return 1.0;
}
let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max);
let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max);
by_words.max(by_pairs * 0.9)
}
}
/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and
/// digits compared without case; else the best of word overlap and character-pair similarity, the
/// latter counted a little lower.
pub fn score(name: &str, candidate: &Candidate) -> f64 {
Prepared::of(candidate).score(&Text::of(name))
}
/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else
/// the best five that score at least 0.3, to choose from.
pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal {
Matcher::new(candidates).propose(name)
}
/// [`propose`] for many names against the same candidates: each candidate's words and character
/// pairs are worked out once, and a name is scored only against the candidates that share a word or
/// a character pair with it (the others score 0).
pub struct Matcher {
prepared: Vec<Prepared>,
/// Candidates by their joined id words and their labels' letters and digits.
exact: HashMap<String, Vec<usize>>,
/// Candidates by a word of their id or labels.
by_word: HashMap<String, Vec<usize>>,
/// Candidates by a character pair of their labels.
by_pair: HashMap<(char, char), Vec<usize>>,
}
impl Matcher {
pub fn new(candidates: &[Candidate]) -> Self {
let prepared: Vec<Prepared> = candidates.iter().map(Prepared::of).collect();
let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new());
fn add<K: std::hash::Hash + Eq>(map: &mut HashMap<K, Vec<usize>>, key: K, i: usize) {
let list = map.entry(key).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
for (i, p) in prepared.iter().enumerate() {
add(&mut exact, p.id.clone(), i);
for l in &p.labels {
add(&mut exact, l.folded.clone(), i);
}
for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) {
add(&mut by_word, w.clone(), i);
}
for pair in p.labels.iter().flat_map(|l| &l.pairs) {
add(&mut by_pair, *pair, i);
}
}
Self { prepared, exact, by_word, by_pair }
}
/// See [`propose`].
pub fn propose(&self, name: &str) -> Proposal {
let text = Text::of(name);
let mut seen = vec![false; self.prepared.len()];
let lists = self
.exact
.get(&text.folded)
.into_iter()
.chain(text.words.iter().filter_map(|w| self.by_word.get(w)))
// Each pair looked up once (they are sorted).
.chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?)));
let mut scored: Vec<(usize, f64)> = vec![];
for &i in lists.flatten() {
if let Some(s) = seen.get_mut(i)
&& !*s
{
*s = true;
let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text));
if score >= CHOICE_FLOOR {
scored.push((i, score));
}
}
}
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
match (scored.first(), scored.get(1)) {
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidates(list: &[(&str, &str)]) -> Vec<Candidate> {
list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect()
}
fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> {
match propose(name, list) {
Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()),
Proposal::Choice(_) => None,
}
}
#[test]
fn extra_words_still_find_the_tool() {
let tools = candidates(&[
("tool:rectangle", "Rectangle Tool"),
("tool:roundedRectangle", "Rounded Rectangle Tool"),
("tool:rectangularGrid", "Rectangular Grid Tool"),
("tool:ellipse", "Ellipse Tool"),
]);
assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle"));
assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle"));
assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself");
}
#[test]
fn letters_only_names_match_ids() {
let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]);
assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll"));
assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage"));
assert_eq!(proposed("clear out", &commands), Some("acme.clear"));
}
#[test]
fn ties_need_a_choice() {
let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]);
assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2]));
assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![]));
assert_eq!(propose("", &tools), Proposal::Choice(vec![]));
}
#[test]
fn names_without_spaces_match_by_character_pairs() {
let list = vec![
Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] },
Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] },
];
assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle"));
assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside");
assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared");
}
#[test]
fn the_index_finds_what_scoring_every_candidate_finds() {
let list = candidates(&[
("tool:rectangle", "Rectangle Tool"),
("tool:roundedRectangle", "Rounded Rectangle Tool"),
("tool:ellipse", "Ellipse Tool"),
("select.all", "All"),
("select.none", "Deselect"),
("edit.undo", "Undo"),
("foo.bar", "Zzz"),
("panel:layers", "Layers"),
]);
let matcher = Matcher::new(&list);
for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] {
let mut scored: Vec<(usize, f64)> =
list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
let every = match (scored.first(), scored.get(1)) {
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
};
assert_eq!(matcher.propose(name), every, "{name}");
}
assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label");
}
#[test]
fn words_split_camel_case_and_separators() {
assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]);
assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]);
assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]);
}
}
+33
View File
@@ -152,6 +152,39 @@ fn shape_builder_regions_query() {
assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2));
}
/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing
/// fills. It is a region, and a drag from a circle into it merges the two.
#[test]
fn shape_builder_merges_the_gap_touching_circles_enclose() {
let mut s = session();
let mut circles = vec![];
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] {
let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap();
circles.push(NodeId(r["id"].as_u64().unwrap()));
}
set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regs = r["regions"].as_array().unwrap();
assert_eq!(regs.len(), 5, "{regs:?}");
assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap();
assert_eq!(r["regions"], 2);
let out = ids(&r);
assert_eq!(out.len(), 4, "the merged shape and the three other circles");
let merged = NodeId(r["merged"].as_u64().unwrap());
// A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1).
assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged));
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on");
assert_eq!(layer_children(&s), 4);
// A click in the diamond alone makes it a shape of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap();
assert_eq!(ids(&r).len(), 5);
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0);
}
#[test]
fn shape_builder_tool_drives_the_command() {
let mut s = session();
@@ -7,7 +7,8 @@ use super::*;
fn doc_with_rect() -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Separately created fixtures must compare equal across clock ticks.
s.execute("file.new", &json!({"width": 200, "height": 200, "created": null})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("select.none", &json!({})).unwrap();
(s, id)
+246
View File
@@ -0,0 +1,246 @@
//! Shortcut sets another app saved as text (`shortcutSet.read`): entries, sections, codes, the
//! tokens left unread and the caps; and the matches kept from earlier imports (`importMatches.*`).
//! Every set here is written for the test, with invented names.
use serde_json::{Value, json};
use super::*;
use crate::cmd::importmatches;
use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys};
fn read(text: &str) -> Value {
Session::new().execute("shortcutSet.read", &json!({ "data": text, "name": "My Keys.kys" })).unwrap()
}
#[test]
fn reads_an_entry_with_its_codes() {
let text = "/Menus {\n /Acme\\ Lasso\\ Tool {\n /Represent 7\n /Key 48\n /Context 2\n /Modifiers 0\n }\n}\n";
let set = read_kys(text.as_bytes()).unwrap();
let entry = KysEntry {
section: vec!["Menus".into()],
name: "Acme Lasso Tool".into(),
key: Some(48),
modifiers: Some(0),
context: Some(2),
represent: Some(7),
line: 2,
};
assert_eq!((set.entries, set.unread, set.truncated), (vec![entry], vec![], false));
}
#[test]
fn entries_keep_their_sections_and_the_command_counts_codes() {
let text = "/Menus {\n /Edit {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n }\n /Acme\\ Zoom { /Key 61 /Modifiers 64 }\n /Group\\ Only { /Context 1 }\n}\n";
let r = read(text);
assert_eq!(r["set"], "My Keys");
let names: Vec<(&str, Vec<&str>, usize)> = r["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["name"].as_str().unwrap(),
e["section"].as_array().unwrap().iter().map(|s| s.as_str().unwrap()).collect(),
e["line"].as_u64().unwrap() as usize,
)
})
.collect();
assert_eq!(names, [("Acme Undo", vec!["Menus", "Edit"], 3), ("Acme Redo", vec!["Menus", "Edit"], 4), ("Acme Zoom", vec!["Menus"], 6)]);
assert_eq!(r["entries"][0]["context"], Value::Null, "a code the entry doesn't set");
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 2}, {"value": 61, "count": 1}]));
assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}]));
assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}");
// The same from a file, and from its bytes.
let dir = vectorcraft_testkit::temp_dir("shortcutset-read");
let path = dir.join("Studio Keys.kys");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
let r = s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap();
assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3));
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn refuses_xml_shortcut_files() {
let e = read_kys(b"\xEF\xBB\xBF<?xml version=\"1.0\"?>\n<shortcuts/>").unwrap_err();
assert_eq!(e, NOT_READABLE);
assert!(read_kys(b"").unwrap_err().starts_with("not a shortcut set"));
assert!(read_kys(b"/Menus 3").unwrap_err().starts_with("not a shortcut set"));
let e = Session::new().execute("shortcutSet.read", &json!({ "data": "<shortcuts/>" })).unwrap_err().to_string();
assert!(e.contains(NOT_READABLE), "{e}");
assert!(Session::new().execute("shortcutSet.read", &json!({})).is_err());
}
#[test]
fn lists_unknown_tokens_with_their_lines() {
// CR, LF and CRLF each end a line.
let text = "/Menus {\r/Acme\\ Tool {\r\n/Key 66 /Bogus 3 junk\n/Modifiers\n}\n/Dangling\n}\n} /Key 70";
let set = read_kys(text.as_bytes()).unwrap();
assert_eq!(set.entries.len(), 1);
assert_eq!(set.entries[0].modifiers, None, "/Modifiers without a value");
let unread: Vec<(usize, &str)> = set.unread.iter().map(|(l, t)| (*l, t.as_str())).collect();
assert_eq!(unread, [(3, "/Bogus 3"), (3, "junk"), (4, "/Modifiers"), (6, "/Dangling"), (8, "}"), (8, "/Key 70")]);
// A number too large, and a name ending in a backslash at the end of the text.
let set = read_kys(b"/Menus { /A { /Key 99999999999999999999 } } /End\\").unwrap();
assert!(set.entries.is_empty());
assert_eq!(set.unread, [(1, "/Key (a number too large)".to_string()), (1, "/End".to_string())]);
// A block left open.
let set = read_kys(b"/Menus { /A { /Key 1 }").unwrap();
assert_eq!((set.entries.len(), set.unread.clone()), (1, vec![(1, "/Menus { (not closed)".to_string())]));
}
#[test]
fn caps_nesting_entries_and_names() {
// Blocks past 16 deep are skipped, and what follows them is read.
let deep = format!("/Menus {{ {} /Inner {{ /Key 1 }} {} /After {{ /Key 2 }} }}", "/A { ".repeat(20), "} ".repeat(20));
let set = read_kys(deep.as_bytes()).unwrap();
assert!(set.truncated);
assert_eq!(set.entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["After"]);
// At most 20,000 entries.
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_005));
let set = read_kys(many.as_bytes()).unwrap();
assert_eq!((set.entries.len(), set.truncated), (20_000, true));
// A name is cut at 1,024 bytes, between characters.
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "é".repeat(700));
let set = read_kys(long.as_bytes()).unwrap();
assert_eq!((set.entries[0].name.chars().count(), set.truncated), (512, true));
// At most 1,000 unread tokens.
let junk = format!("/Menus {{ {} }}", "x ".repeat(1_500));
assert_eq!(read_kys(junk.as_bytes()).unwrap().unread.len(), 1000);
// At most 4 MiB of text.
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
let set = read_kys(big.as_bytes()).unwrap();
assert_eq!((set.entries.len(), set.truncated), (1, true));
// Long section names repeated for every entry stop at 8 MiB of names, not hundreds of megabytes.
let open: String = (0..15).map(|i| format!("/{}{i} {{ ", "s".repeat(1020))).collect();
let wide = format!("{open}{}", "/E{/Key 1}".repeat(19_000));
let set = read_kys(wide.as_bytes()).unwrap();
let kept: usize = set.entries.iter().map(|e| e.name.len() + e.section.iter().map(String::len).sum::<usize>()).sum();
assert!(set.truncated && kept <= 8 << 20 && set.entries.len() > 500, "{} entries, {kept} bytes", set.entries.len());
}
#[test]
fn big_files_are_read_up_to_the_cap() {
let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
let dir = vectorcraft_testkit::temp_dir("shortcutset-big");
let path = dir.join("Big.kys");
std::fs::write(&path, &text).unwrap();
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
// Base64 broken into lines decodes as well.
let lines: String = b64.as_bytes().chunks(76).map(|l| format!("{}\n", String::from_utf8_lossy(l))).collect();
let mut s = Session::new();
for p in [json!({ "path": path.to_string_lossy() }), json!({ "data": text }), json!({ "dataBase64": b64 }), json!({ "dataBase64": lines })] {
let r = s.execute("shortcutSet.read", &p).unwrap();
assert_eq!(
(r["entries"].as_array().unwrap().len(), r["truncated"].as_bool()),
(1, Some(true)),
"{}",
p.as_object().unwrap().keys().next().unwrap()
);
}
// A byte order mark doesn't count toward the cap.
let fits = format!("\u{feff}/Menus {{ /E {{ /Key 1 }} {}}}", " ".repeat((4 << 20) - 24));
assert_eq!(fits.len(), (4 << 20) + 3);
std::fs::write(&path, &fits).unwrap();
assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false);
let _ = std::fs::remove_dir_all(dir);
}
fn matched(s: &mut Session, p: Value) -> Vec<String> {
let r = s.execute("importMatches.list", &p).unwrap();
r.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn import_matches_are_kept_replaced_and_forgotten() {
let mut s = Session::new();
let matches = json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo", "label": "Undo"},
{"kind": "name", "name": "Acme Gadget", "target": ""},
{"kind": "key", "name": "90", "target": "Z"},
{"kind": "modifiers", "name": "64", "target": "Cmd"},
]);
assert_eq!(s.execute("importMatches.set", &json!({ "matches": matches })).unwrap()["count"], 4);
assert_eq!(matched(&mut s, json!({"kind": "name"})), ["Acme Undo", "Acme Gadget"]);
assert_eq!(matched(&mut s, json!({"query": "UNDO"})), ["Acme Undo"], "the name, target or label, any case");
assert_eq!(matched(&mut s, json!({"query": "cmd"})), ["64"]);
// A later answer replaces the earlier one, and null forgets it.
let later = json!([{"kind": "name", "name": "Acme Gadget", "target": "object.group"}, {"kind": "key", "name": "90", "target": null}]);
assert_eq!(s.execute("importMatches.set", &json!({ "matches": later })).unwrap()["count"], 3);
let targets = importmatches::targets(&s.prefs);
assert_eq!((targets.get(&("name", "Acme Gadget")), targets.get(&("key", "90"))), (Some(&"object.group"), None));
// A bad match fails the whole call.
for bad in [
json!([{"kind": "name", "name": "Fine", "target": "edit.copy"}, {"kind": "colour", "name": "Red", "target": ""}]),
json!([{"kind": "name", "name": "x".repeat(1025), "target": ""}]),
json!([{"kind": "name", "name": "", "target": ""}]),
json!([{"kind": "name", "name": "Fine", "target": 3}]),
json!((0..20_001).map(|i| json!({"kind": "key", "name": i.to_string(), "target": "K"})).collect::<Vec<_>>()),
] {
assert!(s.execute("importMatches.set", &json!({ "matches": bad })).is_err());
}
assert!(s.execute("importMatches.list", &json!({"kind": "colour"})).is_err());
assert_eq!(s.prefs.import_matches.len(), 3);
// Forgetting one kind, then the rest.
assert_eq!(s.execute("importMatches.reset", &json!({"kind": "modifiers"})).unwrap()["removed"], 1);
assert_eq!(s.execute("importMatches.reset", &json!({})).unwrap()["removed"], 2);
}
#[test]
fn import_matches_survive_a_preferences_reset_and_a_restart() {
let mut s = Session::new();
s.execute("importMatches.set", &json!({"matches": [{"kind": "name", "name": "Acme Undo", "target": "edit.undo"}]})).unwrap();
let saved = s.prefs.to_json();
assert_eq!(saved["importMatches"][0]["target"], "edit.undo");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.import_matches.len(), 1);
let mut fresh = Session::new();
fresh.apply_prefs(serde_json::from_value(saved).unwrap());
assert_eq!(matched(&mut fresh, json!({})), ["Acme Undo"]);
// Nothing is written while there are none, older preference files load with none, and a damaged
// match doesn't stop the preferences loading.
assert!(Prefs::default().to_json().get("importMatches").is_none());
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
assert!(old.import_matches.is_empty());
let damaged: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2, "importMatches": [{"name": "Acme Undo"}]})).unwrap();
assert_eq!((damaged.keyboard_increment, damaged.import_matches[0].target.as_str()), (2.0, ""));
}
#[test]
fn read_shows_what_earlier_imports_matched() {
let mut s = Session::new();
let matches = json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo"},
{"kind": "key", "name": "90", "target": "Z"},
{"kind": "key", "name": "61", "target": ""},
{"kind": "modifiers", "name": "64", "target": "Cmd"},
{"kind": "modifiers", "name": "0", "target": ""},
]);
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
let text = "/Menus {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n /Acme\\ Zoom { /Key 61 /Modifiers 0 }\n /Acme\\ Tool { /Key 90 }\n}\n";
let r = s.execute("shortcutSet.read", &json!({ "data": text })).unwrap();
let e = &r["entries"];
assert_eq!((e[0]["target"].as_str(), e[0]["chord"].as_str()), (Some("edit.undo"), Some("Cmd+Z")));
assert_eq!((e[1].get("target"), e[1].get("chord")), (None, None), "modifiers 80 aren't matched yet");
assert_eq!(e[2].get("chord"), None, "a key with no equivalent");
assert_eq!(e[3]["chord"], "Z", "an entry without /Modifiers takes none");
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 3, "key": "Z"}, {"value": 61, "count": 1, "key": ""}]));
let mods = json!([{"value": 0, "count": 1, "mods": ""}, {"value": 64, "count": 1, "mods": "Cmd"}, {"value": 80, "count": 1}]);
assert_eq!(r["codes"]["modifiers"], mods);
}
#[test]
fn the_commands_take_only_the_keys_they_describe() {
// The control channel, MCP and the CLI check params against the description (#1442).
for (id, every_key) in [
("shortcutSet.read", json!({"path": "/x/a.kys", "data": "", "dataBase64": "", "name": "a.kys"})),
("importMatches.list", json!({"kind": "name", "query": "undo"})),
("importMatches.set", json!({"matches": []})),
("importMatches.reset", json!({"kind": "key"})),
] {
check_params(id, &every_key).unwrap();
assert!(check_params(id, &json!({"qeury": "undo"})).is_err(), "{id}");
}
}
+24
View File
@@ -150,6 +150,30 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
assert_eq!(obj(&s, id).plain_text(), "abc!");
}
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
/// and keeps what was typed, and typing goes on after it.
#[test]
fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Hello", v).unwrap();
let before = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
let (at, cmd) = (before.center(), Mods { cmd: true, ..Default::default() });
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] {
s.pointer(&PointerEvent::new(kind, at.x + dx, at.y).with_mods(cmd), v).unwrap();
}
assert_eq!(obj(&s, id).plain_text(), "Hello", "the typing is kept");
let moved = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!(moved.x0 - before.x0 > 50.0, "the type moved: {before:?} → {moved:?}");
s.tool_text("!", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello!");
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}");
}
#[test]
fn undo_during_a_drag_takes_back_only_the_drag() {
let mut s = session();
+6
View File
@@ -115,6 +115,12 @@ impl Session {
return *ev;
}
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
// Typing in progress is kept as its own step first: the lent tool's drag previews from the
// document its interaction started from, which would drop the typing (#1881).
if crate::cmd::typing_in_progress(self) {
// Fails only without a document, and then there is no typing to keep.
let _ = crate::cmd::settle_interaction(self);
}
let lent = self.make_tool(id);
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
+77
View File
@@ -56,6 +56,48 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
cms::set_active(&before).unwrap();
}
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
/// square on the Black plate through the new working CMYK profile, and the four process plates
/// through a proof profile loaded again under the same name, as a new renderer draws them.
#[test]
fn separations_preview_draws_through_new_color_settings() {
let _g = settings();
let mut s = session();
let gray = rect(&mut s, 20.0, 20.0, 160.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [gray.0], "color": "#808080"}));
// The proof and Overprint Preview options the canvas takes from the view state the
// commands set.
let canvas = || RenderOptions {
proof: vectorcraft_render::proof::active_proof(),
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
background: Some([255, 255, 255, 255]),
..Default::default()
};
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &canvas()).pixels;
exec(&mut s, "view.separationsPreview", json!({"only": "Black"}));
let mut r = Renderer::new();
let before = frame(&mut r, &s);
let old = cms::active_settings();
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
cms::set_active(&old).unwrap();
assert!(fresh != before, "the new profile changes the plate");
assert!(warm == fresh);
let name = "Separations Proof CMYK";
cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(name.into())).unwrap();
exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{name}")}));
exec(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]}));
let before = frame(&mut r, &s);
cms::register_icc(&cms::icc_bytes(cms::DEVICE_CMYK).unwrap(), Some(name.into())).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
exec(&mut s, "view.proofSetup", json!({"target": "workingCmyk"}));
exec(&mut s, "view.separationsPreview", json!({"on": false}));
assert!(fresh != before, "the profile loaded again changes the plates");
assert!(warm == fresh);
}
/// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow
/// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while
/// interacting (as during a dialog's preview) and after it.
@@ -85,6 +127,41 @@ fn raster_effects_draw_through_new_color_settings() {
cms::set_active(&old).unwrap();
}
/// Pattern tiles follow the Color Settings: a fill of a pattern in a CMYK color is drawn through
/// the new working CMYK profile in the next frame, as a renderer on a new thread (with no tiles of
/// its own yet) draws it.
#[test]
fn pattern_tiles_draw_through_new_color_settings() {
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::rectangle;
let _g = settings();
let old = cms::active_settings();
let mut d = Document::new(200.0, 100.0);
let cyan = Appearance::basic(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), cyan);
let mut def = PatternDef::new("Checks", vec![Arc::new(square)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
let checks = Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0);
let filled = Node::path(d.alloc_id(), rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), checks);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, filled).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
let before = frame(&mut r, &d);
session().execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let warm = frame(&mut r, &d);
let fresh = std::thread::scope(|t| t.spawn(|| frame(&mut Renderer::new(), &d)).join().unwrap());
cms::set_active(&old).unwrap();
assert!(fresh != before, "the new profile changes the frame");
assert!(warm == fresh, "drawn as a renderer with no tiles draws it");
}
/// Live objects follow the Color Settings: in the next frame, a blend of an RGB red and a CMYK cyan,
/// the same blend in an envelope, an envelope that distorts the appearance of a CMYK cyan rectangle
/// with Revolve, and a gradient mesh in CMYK colors draw through the new working CMYK profile, and
+69 -1
View File
@@ -1,6 +1,6 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
//! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
@@ -1011,6 +1011,74 @@ proptest! {
}
}
// ---------- shortcut sets ----------
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
/// error, or a set within the caps.
fn shortcut_set(bytes: &[u8]) -> Result<(), TestCaseError> {
let mut s = vectorcraft_engine::Session::new();
let matches = json!([
{"kind": "name", "name": "A", "target": "edit.undo"},
{"kind": "key", "name": "1", "target": "K"},
{"kind": "modifiers", "name": "-1", "target": "Cmd"},
]);
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.kys"});
if let Ok(r) = no_panic(s.execute("shortcutSet.read", &read)) {
prop_assert!(r["entries"].as_array().map_or(0, Vec::len) <= 20_000);
prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000);
}
Ok(())
}
/// A token of a text shortcut set: a name (escaped spaces), a field, an integer of any size and sign,
/// a brace, a lone backslash, a line end, NUL or bytes that aren't UTF-8.
fn arb_kys_token() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
"[A-Za-z ]{0,12}".prop_map(|n| format!("/{}", n.replace(' ', "\\ ")).into_bytes()),
prop::sample::select(vec!["/Key", "/Modifiers", "/Context", "/Represent"]).prop_map(|f| f.as_bytes().to_vec()),
any::<i64>().prop_map(|n| n.to_string().into_bytes()),
Just(b"99999999999999999999999".to_vec()),
prop::sample::select(vec!["{", "}", "\\", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn shortcut_set_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600), head in any::<bool>()) {
let mut data = if head { b"/Menus {".to_vec() } else { vec![] };
data.extend(bytes);
shortcut_set(&data)?;
}
#[test]
fn hostile_shortcut_sets_never_panic(tokens in prop::collection::vec(arb_kys_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) {
let mut data = b"/Menus {".to_vec();
for t in tokens {
data.extend(sep.as_bytes());
data.extend(t);
}
shortcut_set(&data)?;
}
}
/// A shortcut set past any cap (nesting, entries, name length, file size) is read up to it and
/// marked truncated.
#[test]
fn shortcut_sets_past_the_caps_are_truncated() {
let read = |text: String| vectorcraft_engine::Session::new().execute("shortcutSet.read", &json!({ "data": text })).unwrap();
let deep = format!("/Menus {{ {} /K {{ /Key 1 }} }}", "/A { ".repeat(40));
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_001));
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "n".repeat(2000));
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
for text in [deep, many, long, big] {
assert_eq!(read(text)["truncated"], true);
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10]));
+1 -1
View File
@@ -676,6 +676,7 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() {
let addr = listener.local_addr().unwrap().to_string();
let mut remote = Remote::connect(&addr).unwrap();
let (mut stale, _) = listener.accept().unwrap();
stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
writeln!(stale, "{}", json!({"ok": false, "error": "idle"})).unwrap();
let probe = remote.connection_probe();
probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
@@ -693,7 +694,6 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() {
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!(7)));
h.join().unwrap();
// Nothing was written to the stale connection: it ends (or is reset, its line unread) without a byte.
stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let mut rest = String::new();
assert_eq!(BufReader::new(stale).read_line(&mut rest).unwrap_or(0), 0, "{rest}");
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+12 -7
View File
@@ -102,7 +102,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
// 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) {
if op == PathfinderOp::Divide && crate::has_open_path(shapes) {
return crate::shape_builder(shapes, false)
.regions
.into_iter()
@@ -298,21 +298,22 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
/// (#893), and so do faces with a gap closed paths enclose, which no shape covers (#1691). Either
/// falls back to [`merge_regions`] when it can't place a face.
pub struct FaceMerger {
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
arr: Option<Arrangement>,
shapes: usize,
/// With open paths, the shapes, and their planar map once a merge has needed it.
/// The shapes, and their planar map once a merge has needed it (open paths, or a gap).
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
}
impl FaceMerger {
pub fn new(shapes: &[Shape]) -> Self {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let has_open = crate::has_open_path(shapes);
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
}
@@ -321,8 +322,8 @@ impl FaceMerger {
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
}
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
/// point inside it, and the result checked to cover what they cover.
/// `faces` merged in the planar map of the shapes: each face found again by a point inside
/// it, and the result checked to cover what they cover.
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
let (shapes, map) = self.lines.as_ref()?;
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
@@ -335,6 +336,10 @@ impl FaceMerger {
}
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
// A gap no shape covers is no face of the arrangement: it merges in the planar map.
if faces.iter().any(|r| r.sources.is_empty()) {
return None;
}
let arr = self.arr.as_ref()?;
// Each face by its coverage and a point inside it.
let mut picks: Vec<(Vec<bool>, Point)> = Vec::with_capacity(faces.len());
+66 -14
View File
@@ -307,8 +307,9 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
}
/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more
/// than the filled areas' arrangement, so past this only the filled areas count.
/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed
/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past
/// this only the filled areas count.
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
/// The Shape Builder's arrangement: see [`shape_builder`].
@@ -322,20 +323,26 @@ pub struct BuilderArrangement {
pub edges: Vec<Shape>,
}
/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)?
pub fn has_open_path(shapes: &[Shape]) -> bool {
shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2)
}
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
///
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the
/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of
/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count)
/// are cutting edges: a line across a shape splits it, lines around an area enclose a region
/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets
/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
/// count and there are no pieces.
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let has_open = has_open_path(shapes);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
live_paint(&keyed)
@@ -346,11 +353,15 @@ pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
BuilderArrangement {
regions: if has_open { faces } else { crate::regions(shapes) },
lines: if has_open { lines } else { Vec::new() },
edges: if edges { outline } else { Vec::new() },
}
let regions = if has_open {
faces
} else {
// The filled areas from their arrangement, whose faces merge exactly, then the gaps.
let mut regions = crate::regions(shapes);
regions.extend(faces.into_iter().filter(|f| f.sources.is_empty()));
regions
};
BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } }
}
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
@@ -1182,6 +1193,43 @@ mod tests {
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
}
fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape {
Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key)
}
#[test]
fn shape_builder_offers_the_gaps_closed_shapes_enclose() {
// Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and
// the diamond between them, which nothing fills.
let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)];
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 5, "{regions:?}");
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
assert_eq!(gaps.len(), 1);
assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0)));
let diamond = 10000.0 - std::f64::consts::PI * 2500.0;
assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0]));
assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them");
// A ring of four bars that overlap at the corners: the eight pieces of the bars and the
// square they enclose.
let s =
[rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)];
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 9, "{regions:?}");
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
assert_eq!(gaps.len(), 1);
assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0)));
// The gap merges with a bar in the planar map: the bar's piece and the square, no seam.
let merger = crate::FaceMerger::new(&s);
let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap();
let merged = merger.merge(&[bar, gaps[0]]);
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6);
// Shapes apart or overlapping without a hole enclose no gap.
let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)];
assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty()));
}
#[test]
fn shape_builder_stays_quick_up_to_its_cap() {
// A wavy ring of nearly the most segments the map takes, crossed by a few lines.
@@ -1194,6 +1242,10 @@ mod tests {
})
.collect();
let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)];
// Closed paths alone are swept for the gaps they enclose.
let started = std::time::Instant::now();
assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap");
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
for k in 0..4 {
let y = -60.0 + 40.0 * f64::from(k);
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
+2 -4
View File
@@ -18,13 +18,11 @@ vectorcraft-text = { workspace = true }
skrifa = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
flate2 = { workspace = true }
flate2 = { workspace = true, features = ["zlib-rs"] }
image = { workspace = true }
# CMYK JPEGs (the image crate writes RGB and grey ones only).
jpeg-encoder = { workspace = true }
krilla = { workspace = true }
hayro-syntax = "0.8.0"
hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
craft-pdf = { workspace = true }
log = { workspace = true }
smallvec = "1"
weezl = "0.1"
+3 -2
View File
@@ -5,8 +5,9 @@
use std::collections::HashSet;
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, Object, ObjectIdentifier, Stream, String as PdfString};
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Object, ObjectIdentifier, Stream, String as PdfString};
use craft_pdf::write::backend as krilla;
use krilla::embed::{AssociationKind, EmbeddedFile, MimeType};
use krilla::metadata::DateTime;
+30 -36
View File
@@ -4,12 +4,13 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use craft_pdf::write::{self, Metadata};
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
use krilla::configure::{Archival, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::image::Image;
use krilla::metadata::Metadata;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
@@ -37,6 +38,10 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
/// Like [`export`], also returning warnings: options accepted but not applied yet (see
/// [`crate::PdfSettings::warnings`]) and features approximated or dropped.
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
write::guard(|| export_with_report_inner(doc, opts))
}
fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
let set = &opts.settings;
set.check()?;
// Live geometry effects export as their result; raster effects are reported below.
@@ -52,7 +57,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let mut warnings = set.warnings();
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
if let Some(native) = native {
w.pdf.embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
} else if set.preserve_editing {
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
}
@@ -123,7 +128,7 @@ pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfErr
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
pub(crate) struct Writer {
pub pdf: krilla::Document,
pub pdf: write::Writer,
/// How colours are written (the Output settings).
pub out: Arc<ColorOut>,
/// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see
@@ -180,12 +185,9 @@ impl Writer {
Compatibility::Pdf17 => PdfVersion::Pdf17,
Compatibility::Pdf20 => PdfVersion::Pdf20,
};
let mut cb = ConfigurationBuilder::new().with_version(version);
// `check` has refused the standards (and standard/version pairs) the writer can't produce.
if set.standard == Standard::PdfA2b {
cb = cb.with_archival_validator(Archival::A2_B);
}
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let archival = (set.standard == Standard::PdfA2b).then_some(Archival::A2_B);
let configuration = write::configuration(version, archival)?;
let settings = krilla::SerializeSettings {
compress_content_streams: set.compression.compress_text,
no_device_cs: out.tagged,
@@ -194,27 +196,19 @@ impl Writer {
..Default::default()
};
let mut pdf = krilla::Document::new_with(settings);
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
if !title.is_empty() {
meta = meta.title(title.to_string());
}
// File Info.
// File Info normalization stays with the application.
let info = &doc.metadata;
let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string);
if let Some(a) = given(&info.author) {
meta = meta.authors(vec![a]);
}
if let Some(d) = given(&info.description) {
meta = meta.description(d);
}
if !info.keywords.is_empty() {
meta = meta.keywords(info.keywords.clone());
}
if let Some(t) = created.map(date_time) {
meta = meta.creation_date(t);
}
pdf.set_metadata(meta);
let metadata = Metadata {
creator: "VectorCraft".into(),
producer: "VectorCraft".into(),
title: Some(title.to_owned()),
authors: given(&info.author).into_iter().collect(),
description: given(&info.description),
keywords: info.keywords.clone(),
created: created.map(date_time),
};
let pdf = write::Writer::new(settings, metadata);
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() })
}
@@ -223,7 +217,7 @@ impl Writer {
let (w, h) = sheet.size;
let size = Size::from_wh(w.max(1.0) as f32, h.max(1.0) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?;
let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32);
let mut page = self.pdf.start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
let mut s = page.surface();
let paper = Rect::new(0.0, 0.0, w, h);
if sheet.negative {
@@ -313,7 +307,7 @@ impl Writer {
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
/// alternates.
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let mut bytes = self.pdf.finish()?;
if self.cmyk {
cmyk_blending(&mut bytes, self.out.tagged);
}
@@ -1578,13 +1572,13 @@ impl Exporter<'_> {
Embedding::Subset => {
// A named instance of a variable font is embedded instanced (its own outlines and
// widths, not the default instance's).
let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
let font =
krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em()));
if font.is_none() {
self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines"));
match write::font(face.file_data(), face.face_index(), Some(face.variations())) {
Ok(font) => Some((font, face.units_per_em())),
Err(error) => {
self.warn(format!("the font “{name}” can't be embedded ({error}): its text is exported as outlines"));
None
}
}
font
}
Embedding::Whole => {
self.warn(format!(
+1
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::io::Read;
use craft_pdf::write::backend as krilla;
use krilla::geom::PathBuilder;
use krilla::graphic::Graphic;
use krilla::surface::Surface;
+1
View File
@@ -15,6 +15,7 @@
use std::io::Cursor;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use image::imageops::FilterType;
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
+130 -144
View File
@@ -1,16 +1,17 @@
//! PDF → Document (hayro-interpret device that builds a VectorCraft node tree).
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use hayro_interpret::font::{Glyph, GlyphRun as ShownGlyphs};
use hayro_interpret::hayro_cmap::BfString;
use hayro_interpret::pattern::{Pattern, TilingPattern};
use hayro_interpret::{
CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageData, ImageDrawProps, InterpreterCache, InterpreterSettings,
InterpreterWarning, LumaData, MaskType, SoftMask, StrokeProps, interpret_page,
use craft_pdf::interpret::font::Glyph;
use craft_pdf::interpret::hayro_cmap::BfString;
use craft_pdf::interpret::pattern::{Pattern, TilingPattern};
use craft_pdf::interpret::{
CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData,
MaskType, PathDrawMode, SoftMask, StrokeProps,
};
use hayro_syntax::object::Name;
use craft_pdf::syntax::object::Name;
use kurbo::{Affine, BezPath, PathEl, Rect, Shape};
use vectorcraft_color::{BlendMode, Color, Paint, Swatch};
use vectorcraft_doc::{
@@ -154,17 +155,35 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
/// [`import_with_report`] on the thread that asks.
pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let original = crate::pages::open(bytes, opts.password.as_deref())?;
read_with_reader_options(bytes, opts, &crate::pages::open_options())
}
#[cfg(test)]
pub(crate) fn import_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result<ImportReport, PdfError> {
vectorcraft_doc::depth::with_deep_stack(|| read_with_reader_options(bytes, opts, reader))
}
fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result<ImportReport, PdfError> {
let original = crate::pages::open_with_options(bytes, opts.password.as_deref(), reader)?;
let picked = crate::pages::picked(opts, original.pages().len())?;
let mut ocgs = Ocgs::read(&original);
let route = opts.layers && !ocgs.list.is_empty();
// Content that is off imports as hidden layers: an update of the file turns every group on
// (unless a form is hidden by its own group: its art couldn't be told apart).
let hide = route && ocgs.skips_any();
let turned_on = (hide && !picked.iter().filter_map(|&n| original.pages().get(n)).any(|p| hides_forms(p, &mut ocgs)))
let mut hidden_form = false;
if hide {
for page in picked.iter().filter_map(|&n| original.pages().get(n)) {
hidden_form |= hides_forms(page, &mut ocgs, original.decode_limits().max_decoded_stream())?;
if hidden_form {
break;
}
}
}
let turned_on = (hide && !hidden_form)
.then(|| all_on(bytes, &original))
.flatten()
.and_then(|b| crate::pages::open(&b, opts.password.as_deref()).ok())
.and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok())
.filter(|p| Ocgs::read(p).list.is_empty());
let mut notes = vec![];
if hide && turned_on.is_none() {
@@ -176,14 +195,17 @@ pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, P
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
let incomplete = Arc::new(AtomicBool::new(false));
let incomplete_sink = incomplete.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |w| {
let msg = match w {
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
// Annotations aren't drawn (`render_annotations` below), and a kind of warning
// added later has no message yet.
_ => return,
InterpreterWarning::ContentTruncated => {
incomplete_sink.store(true, Ordering::Relaxed);
return;
}
};
if let Ok(mut v) = sink2.lock()
&& !v.iter().any(|x| x == msg)
@@ -236,14 +258,17 @@ pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, P
b.page = ab;
// The first id of this page's art.
let first_id = b.next;
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts, pdf.decode_limits().max_decoded_stream())?;
if route {
let mut seen = HashSet::new();
let marked: Vec<usize> = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
merge_order(&mut order, &mut known, &marked);
}
b.begin_page(scan);
interpret_page(page, &mut ctx, &mut b);
craft_pdf::interpret_page(page, &mut ctx, &mut b).map_err(|e| PdfError::Parse(e.to_string()))?;
if incomplete.load(Ordering::Relaxed) {
return Err(crate::import_scan::incomplete_content());
}
let mut parts = b.end_page();
// A PDF-compatible `.ai` (the editor's private data next to the PDF art).
let ai = crate::pages::has_private_data(page);
@@ -437,7 +462,7 @@ pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, P
pub(crate) struct CidText {
/// CID of each glyph.
cids: Vec<u16>,
table: hayro_interpret::hayro_cmap::CMap,
table: craft_pdf::interpret::hayro_cmap::CMap,
}
impl CidText {
@@ -446,7 +471,7 @@ impl CidText {
if data.len() > 64 << 20 {
return None;
}
use hayro_interpret::hayro_cmap::{CMap, CMapName, load_embedded};
use craft_pdf::interpret::hayro_cmap::{CMap, CMapName, load_embedded};
use skrifa::raw::ps::cff::{CffFontRef, dict, v1::Cff};
use skrifa::raw::{FontData, FontRead};
let cff = Cff::read(FontData::new(data)).ok()?;
@@ -468,15 +493,15 @@ impl CidText {
Some(Self { cids, table })
}
fn unicode(&self, glyph: u32) -> Option<hayro_interpret::hayro_cmap::BfString> {
fn unicode(&self, glyph: u32) -> Option<craft_pdf::interpret::hayro_cmap::BfString> {
let cid = *self.cids.get(glyph as usize)?;
self.table.lookup_bf_string(u32::from(cid))
}
}
/// A ToUnicode value that names no character: U+FFFD alone (#708).
fn replacement(u: &hayro_interpret::hayro_cmap::BfString) -> bool {
use hayro_interpret::hayro_cmap::BfString;
fn replacement(u: &craft_pdf::interpret::hayro_cmap::BfString) -> bool {
use craft_pdf::interpret::hayro_cmap::BfString;
match u {
BfString::Char(c) => *c == char::REPLACEMENT_CHARACTER,
BfString::String(s) => !s.is_empty() && s.chars().all(|c| c == char::REPLACEMENT_CHARACTER),
@@ -509,7 +534,7 @@ const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力
/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly
/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare
/// (several characters, a vertical form, no ink in either).
fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &hayro_interpret::font::OutlineGlyph, vertical: bool) -> Option<f64> {
fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &craft_pdf::interpret::font::OutlineGlyph, vertical: bool) -> Option<f64> {
let Some(c) = comparable(text, vertical) else { return Some(0.0) };
let gid = face.glyph_for(c);
if gid == 0 {
@@ -539,7 +564,7 @@ fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &hayro_inte
fn matching_face(
face: &Arc<vectorcraft_text::FontFace>,
text: &str,
o: &hayro_interpret::font::OutlineGlyph,
o: &craft_pdf::interpret::font::OutlineGlyph,
vertical: bool,
) -> Option<Arc<vectorcraft_text::FontFace>> {
let own = glyph_deviation(face, text, o, vertical);
@@ -811,8 +836,8 @@ struct Builder<'p> {
lines: HashMap<NodeId, LineFacts>,
}
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
use hayro_interpret::BlendMode as H;
fn blend(b: craft_pdf::interpret::BlendMode) -> BlendMode {
use craft_pdf::interpret::BlendMode as H;
match b {
H::Normal => BlendMode::Normal,
H::Multiply => BlendMode::Multiply,
@@ -833,10 +858,10 @@ fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
}
}
fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
fn fill_rule(r: craft_pdf::interpret::FillRule) -> FillRule {
match r {
hayro_interpret::FillRule::NonZero => FillRule::NonZero,
hayro_interpret::FillRule::EvenOdd => FillRule::EvenOdd,
craft_pdf::interpret::FillRule::NonZero => FillRule::NonZero,
craft_pdf::interpret::FillRule::EvenOdd => FillRule::EvenOdd,
}
}
@@ -993,10 +1018,6 @@ impl<'p> Builder<'p> {
self.marked.clear();
self.pending = false;
(self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0);
if self.scan.overprints && self.scan.cut {
self.scan.overprints = false;
self.warn(OVERPRINT_UNREAD);
}
if self.scan.overprints && self.scan.zero_mode {
self.warn(OVERPRINT_ZERO_MODE);
}
@@ -1270,9 +1291,9 @@ impl<'p> Builder<'p> {
/// before it on the open line was stroked so too, is held with the line instead of ending it
/// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the
/// type). `false`: it isn't one.
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &DrawMode) -> bool {
let DrawMode::Stroke(props) = draw_mode else { return false };
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool {
let PathDrawMode::Stroke(props) = draw_mode else { return false };
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, craft_pdf::interpret::Paint::Pattern(_)) {
return false;
}
let (p, opacity) = self.paint(paint, true);
@@ -1299,14 +1320,14 @@ impl<'p> Builder<'p> {
}
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
fn paint(&mut self, p: &hayro_interpret::Paint<'_>, stroke: bool) -> (Paint, f32) {
fn paint(&mut self, p: &craft_pdf::interpret::Paint<'_>, stroke: bool) -> (Paint, f32) {
match p {
hayro_interpret::Paint::Color(c) => {
craft_pdf::interpret::Paint::Color(c) => {
let [r, g, b, a] = c.to_rgba().components();
let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint);
(paint, a)
}
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
craft_pdf::interpret::Paint::Pattern(pat) => match pat.as_ref() {
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) {
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
None => {
@@ -1430,7 +1451,7 @@ impl<'p> Builder<'p> {
/// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a
/// greyscale image over the image's `w` × `h` pixels (`transform`): its opacity mask. `None`
/// when it masks nothing.
fn alpha_mask(&mut self, key: u128, r: &hayro_interpret::RasterImage<'_>, w: u32, h: u32, transform: Affine) -> Option<OpacityMask> {
fn alpha_mask(&mut self, key: u128, r: &craft_pdf::interpret::RasterImage<'_>, w: u32, h: u32, transform: Affine) -> Option<OpacityMask> {
let mkey = key ^ 0x5_3a5c;
if self.opaque.contains(&mkey) {
return None;
@@ -1502,7 +1523,7 @@ impl<'p> Builder<'p> {
/// name, else the available family its name reads as; whether it is available.
/// The character of glyph `glyph` of font `key` that no mapping names, found by its outline in
/// the installed face of the font's name ([`identify_glyph`]); cached per glyph.
fn identified_char(&mut self, key: u128, glyph: u32, o: &hayro_interpret::font::OutlineGlyph) -> Option<char> {
fn identified_char(&mut self, key: u128, glyph: u32, o: &craft_pdf::interpret::font::OutlineGlyph) -> Option<char> {
if let Some(c) = self.identified.get(&(key, glyph)) {
return *c;
}
@@ -1511,7 +1532,7 @@ impl<'p> Builder<'p> {
c
}
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> FontInfo {
fn font_name(&mut self, key: u128, o: &craft_pdf::interpret::font::OutlineGlyph) -> FontInfo {
if let Some(n) = self.font_names.get(&key) {
return n.clone();
}
@@ -1547,10 +1568,10 @@ impl<'p> Builder<'p> {
/// its outline (no Unicode text, mirrored, or not type mode).
fn type_glyph(
&mut self,
o: &hayro_interpret::font::OutlineGlyph,
o: &craft_pdf::interpret::font::OutlineGlyph,
m: Affine,
scale: f64,
paint: &hayro_interpret::Paint<'_>,
paint: &craft_pdf::interpret::Paint<'_>,
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
@@ -1696,14 +1717,7 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
}
}
/// What the interpreter draws, as the device below gets it.
impl<'a> Builder<'_> {
/// The soft mask and blend mode of what is drawn next.
fn set_state(&mut self, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
self.set_soft_mask(mask);
self.set_blend_mode(blend_mode);
}
impl<'a> Device<'a> for Builder<'_> {
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
let spec = mask.map(|m| self.soft_mask(&m));
let same = match (&spec, &self.mask) {
@@ -1717,7 +1731,7 @@ impl<'a> Builder<'_> {
}
}
fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) {
fn set_blend_mode(&mut self, blend_mode: craft_pdf::interpret::BlendMode) {
let b = blend(blend_mode);
if b != self.blend {
self.flush_glyphs();
@@ -1726,7 +1740,7 @@ impl<'a> Builder<'_> {
}
}
fn path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &DrawMode) {
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
self.pending = false;
@@ -1741,26 +1755,26 @@ impl<'a> Builder<'_> {
return;
}
let shading = match paint {
hayro_interpret::Paint::Pattern(p) => match p.as_ref() {
craft_pdf::interpret::Paint::Pattern(p) => match p.as_ref() {
Pattern::Shading(sp) => Some(sp),
Pattern::Tiling(_) => None,
},
hayro_interpret::Paint::Color(_) => None,
craft_pdf::interpret::Paint::Color(_) => None,
};
// Patch and triangle meshes are gradient meshes.
if let (DrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
{
self.draw_meshes(meshes, &bp, sp.opacity);
return;
}
let pd = PathData::from_bezpath(&bp);
let stroke = matches!(draw_mode, DrawMode::Stroke(_));
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
let (paint, opacity) = self.paint(paint, stroke);
// A gradient that doesn't extend past its ends paints only between them.
let width = match draw_mode {
DrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
_ => 0.0,
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
PathDrawMode::Fill(_) => 0.0,
};
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
let wrap = |b: &mut Self, n: Node| match &band {
@@ -1772,7 +1786,7 @@ impl<'a> Builder<'_> {
None => n,
};
match draw_mode {
DrawMode::Fill(rule) => {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
(f.opacity, f.overprint) = (opacity, overprint);
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
@@ -1783,7 +1797,7 @@ impl<'a> Builder<'_> {
let n = wrap(self, n);
self.push_node(n);
}
DrawMode::Stroke(props) => {
PathDrawMode::Stroke(props) => {
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
st.overprint = overprint;
// Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke
@@ -1818,71 +1832,6 @@ impl<'a> Builder<'_> {
let n = wrap(self, n);
self.push_node(n);
}
// (`draw_path` gives a fill and a stroke one after the other, and no path is invisible.)
DrawMode::FillAndStroke(..) | DrawMode::Invisible => {}
}
}
/// One glyph: filled, or stroked with `stroke`.
fn glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &hayro_interpret::Paint<'a>,
stroke: Option<&StrokeProps>,
) {
self.pending = false;
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke, overprint) {
return;
}
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.overprint == overprint
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke: stroke.cloned(), scale, overprint });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());
}
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
}
}
}
}
impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, props: DrawProps<'a>, draw_mode: &DrawMode) {
self.set_state(props.soft_mask, props.blend_mode);
match draw_mode {
DrawMode::Invisible => {}
DrawMode::FillAndStroke(rule, stroke) => {
self.path(path, props.transform, &props.paint, &DrawMode::Fill(*rule));
self.path(path, props.transform, &props.paint, &DrawMode::Stroke(stroke.clone()));
}
one => self.path(path, props.transform, &props.paint, one),
}
}
@@ -1909,7 +1858,7 @@ impl<'a> Device<'a> for Builder<'_> {
self.stack.push(Frame::new(kind));
}
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, blend_mode: craft_pdf::interpret::BlendMode) {
self.flush();
let mask = mask.map(|m| self.soft_mask(&m));
self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false }));
@@ -1918,22 +1867,60 @@ impl<'a> Device<'a> for Builder<'_> {
self.blend = BlendMode::Normal;
}
fn draw_glyph_run(&mut self, glyph_run: &ShownGlyphs<'_, 'a>, props: DrawProps<'a>, draw_mode: &DrawMode) {
// (A run is filled or stroked: one filled and stroked is given twice.)
fn draw_glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &craft_pdf::interpret::Paint<'a>,
draw_mode: &GlyphDrawMode,
) {
self.pending = false;
let stroke = match draw_mode {
DrawMode::Invisible => return,
DrawMode::Fill(_) => None,
DrawMode::Stroke(p) | DrawMode::FillAndStroke(_, p) => Some(p),
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Fill => None,
GlyphDrawMode::Stroke(p) => Some(p.clone()),
};
for g in glyph_run.glyphs() {
self.set_state(props.soft_mask.clone(), props.blend_mode);
self.glyph(g, props.transform, g.transform(), &props.paint, stroke);
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
return;
}
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.overprint == overprint
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());
}
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
}
}
}
fn draw_image(&mut self, image: Image<'a, '_>, props: ImageDrawProps<'a>) {
self.set_state(props.soft_mask, props.blend_mode);
let transform = props.transform;
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
self.flush_glyphs();
self.flush_text();
self.pending = false;
@@ -1941,7 +1928,7 @@ impl<'a> Device<'a> for Builder<'_> {
Image::Raster(r) => {
let white = self.white_images;
// Inside an alpha mask the same image is read again, white (its own cache key).
let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
let key = craft_pdf::interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
let st = r.stream();
let (w, h) = (r.width(), r.height());
// CMYK images keep their samples (read once per image).
@@ -1967,7 +1954,7 @@ impl<'a> Device<'a> for Builder<'_> {
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
let jpeg = !white
&& filters.len() == 1
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
&& matches!(filters[0], craft_pdf::syntax::Filter::DctDecode)
&& cs_ok
&& !dict.contains_key(b"SMask")
&& !dict.contains_key(b"Mask")
@@ -2000,13 +1987,13 @@ impl<'a> Device<'a> for Builder<'_> {
}
Image::Stencil(s) => {
let white = self.white_images;
let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
let key = craft_pdf::interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
let mut decoded: Option<Decoded> = None;
s.with_stencil(
|luma, paint| {
let rgb = match paint {
_ if white => [255, 255, 255, 255],
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
craft_pdf::interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
_ => [0, 0, 0, 255],
};
let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect();
@@ -2021,7 +2008,7 @@ impl<'a> Device<'a> for Builder<'_> {
}
}
fn pop_clip(&mut self) {
fn pop_clip_path(&mut self) {
self.flush();
self.pending = false;
self.pop_frame();
@@ -2044,9 +2031,8 @@ impl<'a> Device<'a> for Builder<'_> {
_ => {
if self.route && self.aligned {
self.aligned = false;
let why = if self.scan.cut { "a page draws more than can be read for its layers: " } else { "" };
let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" };
self.warn(&format!("{why}some art couldn't be told apart by layer and went to {went}"));
self.warn(&format!("some art couldn't be told apart by layer and went to {went}"));
}
self.unsure.then_some(UNSORTED)
}
+6 -6
View File
@@ -9,10 +9,10 @@
use std::fmt::{self, Debug, Write as _};
use hayro_interpret::Function;
use hayro_interpret::color::ColorSpace;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, MaybeRef, Name, Object, Stream};
use craft_pdf::interpret::Function;
use craft_pdf::interpret::color::ColorSpace;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, MaybeRef, Name, Object, Stream};
use smallvec::SmallVec;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Paint, Swatch};
@@ -162,7 +162,7 @@ impl<'p> Colors<'p> {
}
/// A solid colour in its own model; `None`: use its RGB.
pub fn solid(&mut self, c: &hayro_interpret::color::Color) -> Option<Native> {
pub fn solid(&mut self, c: &craft_pdf::interpret::color::Color) -> Option<Native> {
let mapped = debug_text(c).and_then(|t| {
let (space, comps) = split_color(&t)?;
let space = self.space_of(space, comps.len());
@@ -431,7 +431,7 @@ fn lab_profile(profile: &Stream<'_>) -> bool {
let mut head = vec![];
match profile.filters().as_slice() {
[] => head.extend(raw.iter().take(20)),
[hayro_syntax::Filter::FlateDecode] => {
[craft_pdf::syntax::Filter::FlateDecode] => {
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
}
_ => return false,
+3 -3
View File
@@ -6,9 +6,9 @@
//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the
//! interpreter.
use hayro_syntax::Filter;
use hayro_syntax::object::stream::ImageDecodeParams;
use hayro_syntax::object::{Array, Dict, Name, Stream};
use craft_pdf::syntax::Filter;
use craft_pdf::syntax::object::stream::ImageDecodeParams;
use craft_pdf::syntax::object::{Array, Dict, Name, Stream};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
+137 -25
View File
@@ -14,12 +14,14 @@
use std::collections::{HashMap, HashSet};
use hayro_interpret::CacheKey;
use hayro_syntax::Pdf;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, dict_or_stream};
use hayro_syntax::page::{Page, Resources};
use craft_pdf::interpret::CacheKey;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::content::TypedIter;
use craft_pdf::syntax::content::ops::TypedInstruction;
use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, dict_or_stream};
use craft_pdf::syntax::page::{Page, Resources};
use crate::PdfError;
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
@@ -33,6 +35,9 @@ const MAX_OPS: usize = 64_000_000;
const MAX_TAGS: usize = 4_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// Match the interpreter's ordinary content window and maximum incomplete instruction.
const CONTENT_WINDOW: usize = 64 << 10;
const MAX_WINDOW: usize = 256 << 20;
/// The deepest layers nest in the configuration's `/Order`.
pub(crate) const MAX_NESTING: usize = 32;
@@ -104,7 +109,7 @@ impl Ocgs {
/// lists that array's groups as its sublayers; an array starting with a label lists its
/// groups under the label, which isn't a group (they go under `parent`). A group listed twice
/// stays where it is first listed. `seen`: the entries read so far.
fn nest(&mut self, order: &Array<'_>, parent: Option<usize>, depth: usize, xref: &hayro_syntax::xref::XRef, seen: &mut usize) {
fn nest(&mut self, order: &Array<'_>, parent: Option<usize>, depth: usize, xref: &craft_pdf::syntax::xref::XRef, seen: &mut usize) {
let mut prev = None;
for item in order.raw_iter() {
*seen += 1;
@@ -147,7 +152,7 @@ impl Ocgs {
if let Some(i) = self.index.get(&id) {
return *i;
}
let name = d.get::<hayro_syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
let name = d.get::<craft_pdf::syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
let print = usage(d, b"Print", b"PrintState") != Some(false);
// A view state applies when the configuration says so on opening; an off one hides the
// group all the same (it says how the group is meant to be seen).
@@ -170,7 +175,7 @@ impl Ocgs {
/// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its
/// object) belongs to: a membership dictionary's first group.
fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &hayro_syntax::xref::XRef) -> Option<usize> {
fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &craft_pdf::syntax::xref::XRef) -> Option<usize> {
if d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") {
let first = d.get::<Array<'_>>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?;
let id = ObjectIdentifier::from(first);
@@ -219,7 +224,8 @@ pub(crate) struct Scan {
pub tags: Vec<(u64, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
/// Content was incomplete or a scan limit was reached. No document may be returned from
/// this scan, since its routing no longer describes every object the interpreter draws.
pub cut: bool,
/// Each fill and each stroke of a path the interpreter draws (a fill-and-stroke operator is
/// a fill, then a stroke; `sh` is a fill): whether it overprints.
@@ -278,15 +284,49 @@ fn max_ops() -> usize {
TEST_MAX_OPS.with(std::cell::Cell::get)
}
#[cfg(not(test))]
fn max_window() -> usize {
MAX_WINDOW
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_MAX_WINDOW: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_WINDOW) };
}
#[cfg(test)]
fn max_window() -> usize {
TEST_MAX_WINDOW.with(std::cell::Cell::get)
}
pub(crate) fn incomplete_content() -> PdfError {
PdfError::Parse("PDF content is incomplete or exceeds the reader's limits; no document was imported".into())
}
/// Whitespace and comments after the final instruction do not make an incomplete tail.
fn blank_tail(mut bytes: &[u8]) -> bool {
loop {
let first = bytes.iter().position(|b| !matches!(b, 0 | 9 | 10 | 12 | 13 | 32)).unwrap_or(bytes.len());
bytes = bytes.get(first..).unwrap_or_default();
if bytes.first() == Some(&b'%') {
let end = bytes.iter().position(|b| matches!(b, b'\r' | b'\n')).unwrap_or(bytes.len());
bytes = bytes.get(end..).unwrap_or_default();
} else {
return bytes.is_empty();
}
}
}
struct Walker<'w, 'p> {
ocgs: &'w mut Ocgs,
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
off: HashSet<ObjectIdentifier>,
xref: &'p hayro_syntax::xref::XRef,
xref: &'p craft_pdf::syntax::xref::XRef,
fonts: &'w mut FontNames,
visible: Vec<bool>,
ops: usize,
max_ops: usize,
max_form_bytes: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
@@ -384,13 +424,63 @@ impl Walker<'_, '_> {
}
}
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
fn walk(&mut self, data: &[u8], res: &Resources<'_>, depth: u32) {
let mut iter = TypedIter::new(data);
self.note_fonts(res);
// The interpreter saves the state for each content stream, and restores what the stream
// left saved when it ends.
let saved = self.states.len();
self.save();
self.walk_ops(&mut iter, res, depth);
self.out.cut |= !blank_tail(data.get(iter.committed()..).unwrap_or_default());
self.states.truncate(saved);
}
/// Scan the same stream windows as the interpreter without materializing the page.
/// Graphics and optional-content state live across windows; page state is saved once.
fn page(&mut self, page: &Page<'_>) {
let resources = page.resources();
self.note_fonts(resources);
let saved = self.states.len();
self.save();
let mut content = page.content_reader();
let mut window = Vec::new();
let limit = max_window();
let mut exhausted = false;
loop {
let (committed, needs_more) = {
let mut ops = TypedIter::new_window(&window, exhausted);
self.walk_ops(&mut ops, resources, 0);
(ops.committed(), ops.needs_more())
};
if committed > window.len() {
self.out.cut = true;
break;
}
window.drain(..committed);
if self.out.cut {
break;
}
if !needs_more || exhausted {
// No success on an undecidable tail or on a parser stop before the source ends.
// The typed iterator does not expose every malformed-operator failure.
self.out.cut |= !exhausted || !blank_tail(&window);
break;
}
if window.len() >= limit {
self.out.cut = true;
break;
}
let want = window.len().max(CONTENT_WINDOW).min(limit.saturating_sub(window.len()));
if window.try_reserve(want).is_err() {
self.out.cut = true;
break;
}
if content.read_into(&mut window, want) == 0 {
exhausted = true;
}
}
self.out.cut |= content.truncated();
self.states.truncate(saved);
}
@@ -465,7 +555,7 @@ impl Walker<'_, '_> {
| TypedInstruction::NextLineAndShowText(_)
| TypedInstruction::ShowTextWithParameters(_) => self.shown(),
TypedInstruction::XObject(x) => {
if !self.is_visible() || depth >= MAX_DEPTH {
if !self.is_visible() {
continue;
}
let Some(s) = res.get_x_object(x.0) else { continue };
@@ -473,23 +563,41 @@ impl Walker<'_, '_> {
if d.get::<Name<'_>>(b"Subtype").as_deref() != Some(b"Form") || d.get::<[f32; 4]>(b"BBox").is_none() {
continue;
}
let Ok(data) = s.decoded() else { continue };
if depth >= MAX_DEPTH {
self.out.cut = true;
return;
}
let Ok(data) = s.decoded() else {
self.out.cut = true;
return;
};
// Ordinary filtered form decoders may return a prefix at their limit.
// Reaching that bound cannot certify a complete form, even at an operator boundary.
if !s.filters().is_empty() && data.len() >= self.max_form_bytes {
self.out.cut = true;
return;
}
let oc = d.get::<Dict<'_>>(b"OC");
if let Some(oc) = &oc {
self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from));
self.hidden_form |= !self.is_visible();
}
if self.is_visible() {
if let Some(g) = d.get::<Dict<'_>>(b"Group")
&& self.out.groups.len() < MAX_GROUPS
{
if let Some(g) = d.get::<Dict<'_>>(b"Group") {
if self.out.groups.len() >= MAX_GROUPS {
self.out.cut = true;
return;
}
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
}
// (Its own resources, or without any those of what draws it: as the interpreter reads them.)
let inner = d.get::<Dict<'_>>(b"Resources").map(Resources::new).unwrap_or_else(|| res.clone());
let inner = Resources::from_scope(d.get::<Dict<'_>>(b"Resources"), res.clone());
self.save();
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
self.walk(data.as_ref(), &inner, depth + 1);
self.restore();
if self.out.cut {
return;
}
}
if oc.is_some() {
self.visible.pop();
@@ -522,11 +630,12 @@ fn embeds_glyphs(f: &Dict<'_>) -> bool {
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its
/// glyphs).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan {
walk_page(page, ocgs, all_on, fonts).out
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames, max_form_bytes: usize) -> Result<Scan, PdfError> {
let scan = walk_page(page, ocgs, all_on, fonts, max_form_bytes).out;
if scan.cut { Err(incomplete_content()) } else { Ok(scan) }
}
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames, max_form_bytes: usize) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker {
ocgs,
@@ -536,20 +645,23 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &
visible: vec![],
ops: 0,
max_ops: max_ops(),
max_form_bytes,
out: Scan::default(),
hidden_form: false,
states: vec![Overprint::default()],
text: TextOverprint::None,
};
w.walk(page.typed_operations(), page.resources(), 0);
w.page(page);
w.out.texts.push(w.text);
w
}
/// Does `page` draw a form XObject that is off by its own optional content (not a marked-content
/// sequence)? Its art couldn't be told apart by group once every group is on.
pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs) -> bool {
walk_page(page, ocgs, false, &mut HashMap::new()).hidden_form
pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs, max_form_bytes: usize) -> Result<bool, PdfError> {
let mut fonts = HashMap::new();
let walk = walk_page(page, ocgs, false, &mut fonts, max_form_bytes);
if walk.out.cut { Err(incomplete_content()) } else { Ok(walk.hidden_form) }
}
/// `bytes` with an update appended that drops the catalog's `/OCProperties`, so every group
+4 -4
View File
@@ -6,8 +6,8 @@
use std::sync::Arc;
use hayro_interpret::pattern::ShadingPattern;
use hayro_interpret::shading::{ShadingFunction, ShadingType};
use craft_pdf::interpret::pattern::ShadingPattern;
use craft_pdf::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};
@@ -34,7 +34,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) ->
let space = colors.shading_space(cs, at(0.0)?.len());
// RGB to decide which samples are stops; the stops keep the shading's model.
let sample = |t: f32| -> Option<Color> {
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0).components();
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
Some(Color::rgb(round3(r), round3(g), round3(b)))
};
// Sample densely, then drop samples that linear interpolation reproduces.
@@ -147,7 +147,7 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern
};
let space = colors.shading_space(&sp.shading.color_space, comps.len());
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0).components();
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
Color::rgb(round3(r), round3(g), round3(b))
})
}
+12
View File
@@ -220,6 +220,12 @@ pub enum PdfError {
PlaceholderOnly,
}
impl From<craft_pdf::write::WriteError> for PdfError {
fn from(error: craft_pdf::write::WriteError) -> Self {
Self::Write(error.to_string())
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
@@ -284,3 +290,9 @@ mod tests_strokeout;
mod tests_textstroke;
#[cfg(test)]
mod tests_webview;
#[cfg(test)]
mod tests_reader_streaming;
#[cfg(test)]
mod tests_reader_compat;
+1
View File
@@ -24,6 +24,7 @@
use std::sync::{Arc, OnceLock};
use craft_pdf::write::backend as krilla;
use krilla::image::ImageColorspace;
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms, CmykLut, Intent, Model, ProfileKind, ProofLut};
+37 -24
View File
@@ -3,10 +3,10 @@
use std::collections::HashMap;
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
use hayro_syntax::page::Page;
use hayro_syntax::xref::XRef;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
use craft_pdf::syntax::page::Page;
use craft_pdf::syntax::xref::XRef;
use craft_pdf::syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
use vectorcraft_doc::setup::MAX_BLEED;
@@ -37,13 +37,13 @@ pub fn is_postscript(bytes: &[u8]) -> bool {
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
}
/// The most functions and colour spaces that may be built on one another (a function stitching
/// functions together, an indexed space on a space with an alternate). The interpreter (hayro)
/// builds them by recursion, without a cap of its own: one built on itself, or thousands in a
/// chain, overflows the stack, which is an abort, not an error. Files have two or three.
/// The application's stricter refusal policy for functions and colour spaces built on one
/// another (a function stitching functions together, an indexed space on an alternate).
/// The shared interpreter also has function depth and node budgets; this preflight rejects
/// cyclic or excessively linked objects before interpretation. Files usually have two or three.
const MAX_LINKS: usize = 16;
/// The deepest the procedures of a calculator function may nest: read by recursion, too.
/// The application's calculator-procedure depth policy, layered over the shared reader's limits.
const MAX_PROCEDURES: usize = 64;
/// What the function or colour space `o` is built on: the functions a stitching function joins;
@@ -153,13 +153,24 @@ fn nests_without_end(pdf: &Pdf) -> bool {
/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions
/// password opens a file too.
///
/// What a hostile file nests or loops is the reader's to refuse (hayro 0.8 reads arrays and
/// What a hostile file nests or loops is the shared reader's to refuse (it reads arrays and
/// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the
/// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams
/// once). What it reads beyond that is left out, as an object it can't read. What the interpreter
/// builds by recursion with no cap, and streams it would decode without a ceiling or with a
/// predictor it can't apply, are refused here ([`hostile`], [`crate::streams`]).
/// once). What it reads beyond that is left out, as an object it can't read. The application also
/// rejects overly linked functions and colour spaces, oversized streams and unsupported
/// predictors before interpretation ([`hostile`], [`crate::streams`]), retaining its stricter
/// refusal policy alongside the shared backend's bounded decoding and construction.
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
open_with_options(bytes, password, &open_options())
}
/// Preserve the application's existing accepted file sizes. Ordinary stream decoding still
/// uses the shared reader's immutable per-document limits, and its input copy is fallible.
pub(crate) fn open_options() -> craft_pdf::OpenOptions {
craft_pdf::OpenOptions { max_input_bytes: usize::MAX, ..Default::default() }
}
pub(crate) fn open_with_options(bytes: &[u8], password: Option<&str>, options: &craft_pdf::OpenOptions) -> Result<Pdf, PdfError> {
if is_postscript(bytes) {
return Err(PdfError::PostScript);
}
@@ -167,20 +178,22 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError
return Err(PdfError::Parse(STREAM_NOTE.into()));
}
let password = password.unwrap_or_default();
let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw);
let read = |pw: &str| craft_pdf::open_slice_with_options(bytes, Some(pw), options);
read(password)
.or_else(|e| match e {
// The reader takes only the open password of RC4 and 128-bit AES files: the
// permissions password gives it.
LoadPdfError::Decryption(DecryptionError::PasswordProtected) if !password.is_empty() => {
craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) if !password.is_empty() => {
crate::encrypt::user_password(bytes, password).map_or(Err(e), |user| read(&user))
}
e => Err(e),
})
.map_err(|e| match e {
LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword,
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword,
e => PdfError::Parse(format!("{e:?}")),
craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) if password.is_empty() => {
PdfError::NeedsPassword
}
craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) => PdfError::WrongPassword,
e => PdfError::Parse(e.to_string()),
})
// Twice: looking at every object makes the reader repair a damaged table of objects (once
// only), and the objects are other ones after that.
@@ -200,10 +213,10 @@ pub fn info(bytes: &[u8], password: Option<&str>) -> Result<PdfInfo, PdfError> {
.map(|page| {
let (w, h) = page.render_dimensions();
let rotation = match page.rotation() {
hayro_syntax::page::Rotation::None => 0,
hayro_syntax::page::Rotation::Horizontal => 90,
hayro_syntax::page::Rotation::Flipped => 180,
hayro_syntax::page::Rotation::FlippedHorizontal => 270,
craft_pdf::syntax::page::Rotation::None => 0,
craft_pdf::syntax::page::Rotation::Horizontal => 90,
craft_pdf::syntax::page::Rotation::Flipped => 180,
craft_pdf::syntax::page::Rotation::FlippedHorizontal => 270,
};
let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c))).collect();
PageInfo { width: w as f64, height: h as f64, rotation, boxes }
@@ -232,7 +245,7 @@ pub(crate) fn picked(opts: &ImportOptions, count: usize) -> Result<Vec<usize>, P
Ok(v)
}
fn rect(r: hayro_syntax::object::Rect) -> Rect {
fn rect(r: craft_pdf::syntax::object::Rect) -> Rect {
Rect::new(r.x0, r.y0, r.x1, r.y1).abs()
}
@@ -249,7 +262,7 @@ pub(crate) fn page_box(page: &Page<'_>, which: CropTo) -> Rect {
CropTo::Trim => b"TrimBox",
CropTo::Art => b"ArtBox",
};
page.raw().get::<hayro_syntax::object::Rect>(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop)
page.raw().get::<craft_pdf::syntax::object::Rect>(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop)
}
/// Where `page` draws on its artboard: the page transform (PDF user space → y-down page space
+4
View File
@@ -390,6 +390,10 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
/// Print `doc` to a PDF as `opts` say.
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
craft_pdf::write::guard(|| print_inner(doc, opts))
}
fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
let plan = plan(doc, opts)?;
let doc = &*plan.doc;
let separations = opts.settings.output.mode == OutputMode::Separations;
+3 -3
View File
@@ -6,9 +6,9 @@
use std::borrow::Cow;
use std::io::Read;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, Stream};
use craft_pdf::syntax::content::TypedIter;
use craft_pdf::syntax::content::ops::TypedInstruction;
use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, Stream};
/// The most a stream may decode to: 256 MB, the pixels of the largest image the app reads (64
/// million, 4 bytes each).
+22 -3
View File
@@ -48,7 +48,7 @@ fn uncompressed(d: &Document) -> String {
}
fn page_count(bytes: &[u8]) -> usize {
hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse").pages().len()
craft_pdf::syntax::Pdf::new(bytes.to_vec()).expect("parse").pages().len()
}
#[test]
@@ -90,7 +90,7 @@ fn one_page_per_artboard_with_sizes() {
show_cross_hairs: false,
});
let bytes = export(&d, &PdfOptions::default()).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap();
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
assert_eq!(sizes, vec![(200.0, 100.0), (100.0, 400.0), (612.0, 792.0)]);
}
@@ -106,7 +106,7 @@ fn artboard_range_and_bad_index() {
show_cross_hairs: false,
});
let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap();
assert_eq!(pdf.pages().len(), 1);
assert_eq!(pdf.pages()[0].render_dimensions(), (100.0, 400.0));
assert_eq!(export(&d, &PdfOptions { artboards: Some(vec![5]), ..Default::default() }), Err(PdfError::BadArtboard(5)));
@@ -735,6 +735,25 @@ fn opacity_mask_exports_as_luminosity_soft_mask() {
assert!(text.contains("/Luminosity"));
}
#[test]
fn empty_and_populated_file_info_preserve_key_presence() {
let mut d = doc(100.0, 100.0);
d.title.clear();
let s = uncompressed(&d);
for key in ["/Title", "/Author", "/Keywords", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
assert!(!s.contains(key), "unexpected empty metadata {key}");
}
d.title = "Synthetic title".into();
d.metadata.author = " Test author ".into();
d.metadata.keywords = vec!["test keyword".into()];
let bytes = export(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap();
let s = String::from_utf8_lossy(&bytes);
for key in ["/Title", "/Author", "/Keywords", "/CreationDate", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
assert!(s.contains(key), "missing populated metadata {key}");
}
assert!(s.contains("Synthetic title") && s.contains("Test author") && s.contains("test keyword"));
}
/// A function or colour space built on itself is refused before the interpreter builds it (by
/// recursion, with no cap: a stack overflow), and those of real files are read.
#[test]
+2 -2
View File
@@ -4,8 +4,8 @@
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Name, Object, Stream};
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use serde_json::json;
use vectorcraft_color::cms::{self, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_doc::cmyk::Inks;
+1 -1
View File
@@ -195,7 +195,7 @@ fn ink_names_never_clash_with_the_document_swatches() {
#[test]
fn colour_spaces_are_read_from_the_interpreters_debug_form() {
let pdf = hayro_syntax::Pdf::new(pdf(&[PdfPage::new(10.0, 10.0, "")], None)).unwrap();
let pdf = craft_pdf::syntax::Pdf::new(pdf(&[PdfPage::new(10.0, 10.0, "")], None)).unwrap();
let mut c = Colors::new(&pdf, vec![]);
for (text, n, want) in [
("ColorSpace(DeviceCmyk)", 4, Space::Cmyk),
+9 -26
View File
@@ -315,16 +315,16 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
}
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
fn import_with_max_ops(bytes: &[u8], n: usize) -> Result<ImportReport, PdfError> {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
// (On this thread: the limit is this thread's.)
let r = crate::import::read(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r.unwrap()
r
}
#[test]
fn art_past_the_scan_limit_never_shows_a_hidden_layer() {
fn art_past_the_scan_limit_is_rejected_with_hidden_layers() {
// "Shapes" draws 100 operators before its square, then "Hidden" (off, not printed) its own.
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let filler = "q Q ".repeat(50);
@@ -339,33 +339,16 @@ fn art_past_the_scan_limit_never_shows_a_hidden_layer() {
let bytes = pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None);
// Within the limit, every group is told apart.
assert_eq!(names(&import(&bytes).unwrap()), ["Shapes", "Hidden", "Page 1"]);
let r = import_with_max_ops(&bytes, 60);
let d = &r.document;
assert_eq!(names(d), ["Shapes", "Page 1 (unsorted)", "Page 1"], "{:?}", r.warnings);
// The art drawn past the limit in a group already open stays in it.
assert!(d.layers[0].visible);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
// The hidden group's art, past it, can't be told apart: it neither shows nor prints.
let unsorted = &d.layers[1];
assert!(!unsorted.visible && !printable(unsorted));
assert_eq!(colors(unsorted), [Color::rgb(0.0, 0.0, 1.0)]);
// Art outside any group is the page's, as before.
assert!(d.layers[2].visible && printable(&d.layers[2]));
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("more than can be read") && w.contains("hidden, non-printing")), "{:?}", r.warnings);
let error = import_with_max_ops(&bytes, 60).unwrap_err();
assert!(error.to_string().contains("no document was imported"));
}
#[test]
fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
// Every group shows and prints: what can't be told apart goes to the page's layer, shown.
fn art_past_the_scan_limit_is_rejected_with_shown_layers() {
let bytes = three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], "");
let r = import_with_max_ops(&bytes, 4);
let d = &r.document;
assert_eq!(names(d), ["A", "Page 1"], "{:?}", r.warnings);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
assert!(d.layers[1].visible && printable(&d.layers[1]));
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
assert_eq!(names(&import(&bytes).unwrap()), ["A", "B", "C"]);
let error = import_with_max_ops(&bytes, 4).unwrap_err();
assert!(error.to_string().contains("no document was imported"));
}
/// Art reaching past the page keeps a clip to it in a plain PDF (as viewers show it), but not in
+7 -7
View File
@@ -2,7 +2,7 @@
//! visibility, print state and lock) and Advanced › Overprint (a graphics state that overprints
//! for the overprinting fills and strokes).
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, LayerColor, Node, NodeKind, TextObject};
@@ -43,18 +43,18 @@ fn export(d: &Document, v: Value) -> ExportReport {
}
/// The groups of `refs` (an array of the catalog's optional content properties) by name.
fn names(xref: &hayro_syntax::xref::XRef, refs: Option<Array<'_>>) -> Vec<String> {
fn names(xref: &craft_pdf::syntax::xref::XRef, refs: Option<Array<'_>>) -> Vec<String> {
let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>()).unwrap_or_default();
ids.into_iter()
.map(|id| {
let g = xref.get::<Dict<'_>>(id).unwrap();
String::from_utf8(g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes().to_vec()).unwrap()
String::from_utf8(g.get::<craft_pdf::syntax::object::String<'_>>(b"Name").unwrap().as_bytes().to_vec()).unwrap()
})
.collect()
}
/// The catalog's `/OCProperties` of `bytes` (opened with `password`), if any.
fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option<Dict<'_>>, &hayro_syntax::xref::XRef)) {
fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option<Dict<'_>>, &craft_pdf::syntax::xref::XRef)) {
let file = crate::pages::open(bytes, password).unwrap();
let xref = file.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
@@ -86,7 +86,7 @@ fn create_layers_writes_a_group_per_layer_with_its_states() {
assert_eq!(names(xref, config.get(b"Order")), ["Notes", "Hidden", "Back"]);
assert_eq!(names(xref, config.get(b"OFF")), ["Hidden"], "hidden layers are off");
assert_eq!(names(xref, config.get(b"Locked")), ["Hidden"]);
assert!(config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
assert!(config.get::<craft_pdf::syntax::object::String<'_>>(b"Name").is_some());
let auto = config.get::<Array<'_>>(b"AS").unwrap().iter::<Dict<'_>>().next().unwrap();
assert_eq!(auto.get::<Name<'_>>(b"Event").unwrap().as_ref(), b"Print");
let print_state = |name: &str| {
@@ -95,7 +95,7 @@ fn create_layers_writes_a_group_per_layer_with_its_states() {
let g = ids
.into_iter()
.map(|id| xref.get::<Dict<'_>>(id).unwrap())
.find(|g| g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
.find(|g| g.get::<craft_pdf::syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
let usage = g.unwrap().get::<Dict<'_>>(b"Usage").unwrap();
usage.get::<Dict<'_>>(b"Print").unwrap().get::<Name<'_>>(b"PrintState").unwrap().as_ref().to_vec()
};
@@ -228,7 +228,7 @@ fn pdf_a_layers_have_no_automatic_states() {
let r = export(&layered(), json!({"standard": "pdfA2b", "createLayers": true}));
oc_properties(&r.bytes, None, |props, _| {
let config = props.unwrap().get::<Dict<'_>>(b"D").unwrap();
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<craft_pdf::syntax::object::String<'_>>(b"Name").is_some());
});
}
+1
View File
@@ -1,5 +1,6 @@
//! PDF text in installed fonts imports as live type (one point type object per line).
use craft_pdf::write::backend as krilla;
use krilla::geom::{Point as KPoint, Transform};
use krilla::page::PageSettings;
use krilla::text::{Font, TextDirection};
+3 -3
View File
@@ -4,8 +4,8 @@
use std::io::Cursor;
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream};
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Paint};
@@ -266,7 +266,7 @@ fn the_output_intent_embeds_its_profile_and_trapped_is_written() {
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
output_intent(&r.bytes, password, |oi| {
assert_eq!(oi.get::<Name<'_>>(b"S").unwrap().as_ref(), b"GTS_PDFX");
let text = |k: &[u8]| oi.get::<hayro_syntax::object::String<'_>>(k).map(|s| s.as_bytes().to_vec());
let text = |k: &[u8]| oi.get::<craft_pdf::syntax::object::String<'_>>(k).map(|s| s.as_bytes().to_vec());
assert_eq!(text(b"OutputCondition").unwrap(), b"Coated (press)");
assert_eq!(text(b"RegistryName").unwrap(), b"http://example.com");
// Non-ASCII text is UTF-16 with its byte order mark.
+1 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use hayro_syntax::Pdf;
use craft_pdf::syntax::Pdf;
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent};
use vectorcraft_color::{Color, Paint};
+179
View File
@@ -0,0 +1,179 @@
//! Current-main import contracts across the shared reader's older Device API.
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, NodeKind};
use crate::{ImportOptions, TextAs, import_with_report};
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
fn stream(dictionary: &str, content: &[u8]) -> Vec<u8> {
let mut bytes = format!("<< {dictionary} /Length {} >>\nstream\n", content.len()).into_bytes();
bytes.extend_from_slice(content);
bytes.extend_from_slice(b"\nendstream");
bytes
}
fn assemble(objects: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![];
for (i, body) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(body);
bytes.extend_from_slice(b"\nendobj\n");
}
let start = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn page(resources: &str, content: &[u8], extra: &[Vec<u8>]) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Contents 4 0 R /Resources << {resources} >> >>").into_bytes(),
stream("", content),
];
objects.extend_from_slice(extra);
assemble(&objects)
}
pub(crate) fn resource_scope_cases() -> Vec<(&'static str, Vec<u8>, bool)> {
[
("absent", "", true),
("empty", "/Resources << >>", false),
("unrelated", "/Resources << /ProcSet [/PDF] >>", false),
("own-off", "/Resources << /ExtGState << /On << /OP false /op false /OPM 1 >> >> >>", false),
("own-on", "/Resources << /ExtGState << /On << /OP true /op true /OPM 1 >> >> >>", true),
]
.into_iter()
.map(|(name, scope, first)| {
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 60] {scope}"), b"/On gs 10 10 20 20 re f");
let bytes =
page("/XObject << /Outer 5 0 R >> /ExtGState << /On << /OP true /op true /OPM 1 >> >>", b"/Outer Do /On gs 50 10 20 20 re f", &[form]);
(name, bytes, first)
})
.collect()
}
pub(crate) fn resource_scope_contract() {
for (name, bytes, first) in resource_scope_cases() {
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let mut overprints = vec![];
report.document.walk(|node| {
if matches!(node.kind, NodeKind::Path { clipping: false, .. }) {
overprints.extend(node.appearance.fill().map(|fill| fill.overprint));
}
});
assert_eq!(overprints, [first, true], "form scope {name}; the caller's resources return after the form");
assert!(report.warnings.is_empty(), "{name}: {:?}", report.warnings);
}
}
#[test]
fn form_resource_scopes_keep_scanned_overprint_in_step_with_interpretation() {
resource_scope_contract();
}
pub(crate) fn text_pdf(content: &str) -> Vec<u8> {
page(
"/Font << /F1 5 0 R >> /ExtGState << /G << /ca 0.25 /CA 0.75 >> >>",
content.as_bytes(),
&[
b"<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 67 /Widths [600 600 600] /FontDescriptor 6 0 R >>".to_vec(),
b"<< /Type /FontDescriptor /FontName /Inter-Regular /Flags 32 /FontFile2 7 0 R >>".to_vec(),
stream("", INTER),
],
)
}
pub(crate) fn text_content(mode: u8, explicit: bool, following: bool) -> String {
let show = "1 0 0 1 10 20 Tm [(A) 400 (B)] TJ";
let paint = if explicit { format!("0 Tr {show} {} Tr {show}", if mode == 6 { 5 } else { 1 }) } else { format!("{mode} Tr {show}") };
// The green rectangle also exercises the text clip installed at ET for mode 6.
let after = if following { " (C) Tj" } else { "" };
format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {paint}{after} ET 0 1 0 rg 0 0 100 60 re f")
}
fn text_document(mode: u8, explicit: bool, text_as: TextAs) -> Document {
import_with_report(&text_pdf(&text_content(mode, explicit, false)), &ImportOptions { text_as, ..Default::default() }).unwrap().document
}
pub(crate) fn text_order_contract() {
for mode in [2, 6] {
for text_as in [TextAs::Text, TextAs::Outlines] {
let combined = text_document(mode, false, text_as);
let explicit = text_document(mode, true, text_as);
assert_eq!(
serde_json::to_value(&combined).unwrap(),
serde_json::to_value(&explicit).unwrap(),
"{mode} Tr, {text_as:?}: imported objects must match a complete fill pass followed by a complete stroke pass"
);
}
}
let live = text_document(2, false, TextAs::Text);
let mut texts = vec![];
live.walk(|node| {
if let NodeKind::Text(text) = &node.kind {
texts.push((node.opacity, text.plain_text(), text.first_style().clone()));
}
});
assert_eq!(texts.len(), 2, "the filled and stroked AB runs retain separate alpha: {texts:?}");
assert_eq!((texts[0].0, texts[0].1.as_str()), (64.0 / 255.0, "AB"));
assert_eq!((texts[1].0, texts[1].1.as_str()), (191.0 / 255.0, "AB"));
assert!(texts[0].2.stroke.is_none() && !texts[0].2.fill.is_none());
assert!(texts[1].2.fill.is_none() && !texts[1].2.stroke.is_none());
let outlined = text_document(2, false, TextAs::Outlines);
let mut paints = vec![];
outlined.walk(|node| {
if matches!(node.kind, NodeKind::Path { clipping: false, .. }) {
if let Some(fill) = node.appearance.fill() {
paints.push(('F', fill.opacity, fill.paint.clone()));
}
if let Some(stroke) = node.appearance.stroke() {
paints.push(('S', stroke.opacity, stroke.paint.clone()));
}
}
});
assert_eq!(paints.len(), 3, "one whole-run fill, one whole-run stroke, then the green rectangle");
assert_eq!((paints[0].0, paints[0].1), ('F', 64.0 / 255.0));
assert_eq!((paints[1].0, paints[1].1), ('S', 191.0 / 255.0));
assert_eq!(paints[2].0, 'F');
assert!(matches!(paints[0].2, Paint::Solid { .. }) && matches!(paints[1].2, Paint::Solid { .. }));
}
#[test]
fn filled_stroked_tj_runs_preserve_app_text_outline_alpha_and_clip_grouping() {
text_order_contract();
}
pub(crate) fn transfer_pdf(low: &str, high: &str) -> Vec<u8> {
page(
"/ExtGState << /G << /SMask << /S /Luminosity /G 5 0 R /TR 6 0 R >> >> >>",
b"/G gs 1 0 0 rg 0 0 100 60 re f",
&[
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 60] /Group << /S /Transparency /CS /DeviceRGB >>", b"1 1 1 rg 0 0 50 60 re f"),
format!("<< /FunctionType 2 /Domain [0 1] /C0 [{low}] /C1 [{high}] /N 1 >>").into_bytes(),
],
)
}
pub(crate) fn transfer_contract() {
for (low, high, expected) in [("0.5001", "0.5", false), ("1", "0", true), ("0", "1", false)] {
let report = import_with_report(&transfer_pdf(low, high), &ImportOptions::default()).unwrap();
let mut inverted = vec![];
report.document.walk(|node| inverted.extend(node.extras().mask.as_ref().map(|mask| mask.invert)));
assert_eq!(inverted, [expected], "sampled endpoints C0={low}, C1={high}");
}
}
#[test]
fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
transfer_contract();
}
+195
View File
@@ -0,0 +1,195 @@
//! Synthetic page-content regressions for the shared reader's windowed input.
use std::io::Write;
use crate::{ImportOptions, ImportReport, import_with_report};
use vectorcraft_doc::NodeKind;
fn stream(entries: &str, bytes: &[u8]) -> Vec<u8> {
let mut out = format!("<< /Length {} {entries} >>\nstream\n", bytes.len()).into_bytes();
out.extend_from_slice(bytes);
out.extend_from_slice(b"\nendstream");
out
}
/// A classic xref keeps the fixture's content-stream policy independent of xref decoding.
fn pdf(contents: &[Vec<u8>], resources: &str, catalog: &str, extra: &[Vec<u8>]) -> Vec<u8> {
let refs = (0..contents.len()).map(|i| format!("{} 0 R", i + 4)).collect::<Vec<_>>().join(" ");
let mut objects = vec![
format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << {resources} >> /Contents [{refs}] >>").into_bytes(),
];
objects.extend_from_slice(contents);
objects.extend_from_slice(extra);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n", i + 1).bytes());
out.extend_from_slice(object);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).bytes());
for offset in offsets.iter().skip(1) {
out.extend(format!("{offset:010} 00000 n \n").bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).bytes());
out
}
fn open(bytes: &[u8]) -> ImportReport {
import_with_report(bytes, &ImportOptions::default()).unwrap()
}
fn fills(report: &ImportReport) -> Vec<bool> {
let mut fills = vec![];
report.document.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
fills.extend(n.appearance.fill().map(|f| f.overprint));
}
});
fills
}
fn assert_incomplete(bytes: &[u8]) {
let error = import_with_report(bytes, &ImportOptions::default()).expect_err("known incomplete content must fail");
assert!(error.to_string().contains("no document was imported"), "{error}");
}
#[test]
fn content_windows_preserve_layers_and_saved_overprint_state() {
let mut content = b"/OC /Layer BDC q /On gs ".to_vec();
// Force both the marked-content and graphics states to cross several 64 KiB windows.
content.extend("q Q ".repeat(40_000).bytes());
content.extend_from_slice(b"10 10 20 20 re f Q 40 10 20 20 re f EMC");
let report = open(&pdf(
&[stream("", &content)],
"/Properties << /Layer 5 0 R >> /ExtGState << /On << /OP true /op true /OPM 1 >> >>",
"/OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >>",
&[b"<< /Type /OCG /Name (Across windows) >>".to_vec()],
));
assert_eq!(report.document.layers.len(), 1);
assert_eq!(report.document.layers[0].name.as_deref(), Some("Across windows"));
assert_eq!(fills(&report), [true, false]);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[test]
fn long_operands_and_contents_array_boundaries_are_read_whole() {
let mut first = b"/Span << /ActualText (".to_vec();
first.extend(std::iter::repeat_n(b'a', 96_000));
first.extend_from_slice(b") >> BDC 10 10");
let report = open(&pdf(&[stream("", &first), stream("", b" 20 20 re f EMC\0 % trailing comment")], "", "", &[]));
assert_eq!(fills(&report), [false]);
}
#[test]
fn inline_image_crossing_a_content_window_preserves_the_next_path() {
let mut content = b"q 10 0 0 10 0 0 cm BI /W 300 /H 100 /CS /RGB /BPC 8 ID ".to_vec();
content.extend(std::iter::repeat_n(127, 90_000));
content.extend_from_slice(b" EI Q 20 20 30 30 re f");
let report = open(&pdf(&[stream("", &content)], "", "", &[]));
let mut images = 0;
report.document.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(images, 1);
assert_eq!(fills(&report), [false]);
}
#[test]
fn undecidable_final_operands_are_rejected() {
for tail in [b" 10 20".as_slice(), b" (unfinished", b" [1 2", b" << /Key"] {
let mut content = b"10 10 20 20 re f".to_vec();
content.extend_from_slice(tail);
assert_incomplete(&pdf(&[stream("", &content)], "", "", &[]));
}
}
#[test]
fn incomplete_instruction_window_limit_is_an_error() {
let mut content = b"/Span << /ActualText (".to_vec();
content.extend(std::iter::repeat_n(b'a', 4096));
content.extend_from_slice(b") >> BDC 10 10 20 20 re f EMC");
let bytes = pdf(&[stream("", &content)], "", "", &[]);
assert_eq!(fills(&open(&bytes)), [false]);
let old = crate::import_scan::TEST_MAX_WINDOW.replace(1024);
// The override belongs to this thread; the public entry point uses a dedicated stack.
let result = crate::import::read(&bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_WINDOW.set(old);
assert!(result.unwrap_err().to_string().contains("no document was imported"));
}
#[test]
fn failed_content_and_form_decoders_are_errors() {
assert_incomplete(&pdf(&[stream("/Filter /ASCIIHexDecode", b"ZZ>")], "", "", &[]));
assert_incomplete(&pdf(
&[stream("", b"/Fm Do")],
"/XObject << /Fm 5 0 R >>",
"",
&[stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /ASCIIHexDecode", b"ZZ>")],
));
}
#[test]
fn truncated_flate_does_not_return_a_partial_document() {
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(b"10 10 20 20 re f ").unwrap();
let mut compressed = encoder.finish().unwrap();
compressed.truncate(compressed.len() - 6);
assert_incomplete(&pdf(&[stream("/Filter /FlateDecode", &compressed)], "", "", &[]));
}
#[test]
fn lower_document_decode_limit_rejects_run_length_prefixes() {
let content = b"10 10 20 20 re f 40 40 20 20 re f";
let mut encoded = vec![(content.len() - 1) as u8];
encoded.extend_from_slice(content);
encoded.push(128);
for is_form in [false, true] {
let bytes = if is_form {
pdf(
&[stream("", b"/Fm Do")],
"/XObject << /Fm 5 0 R >>",
"",
&[stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /RunLengthDecode", &encoded)],
)
} else {
pdf(&[stream("/Filter /RunLengthDecode", &encoded)], "", "", &[])
};
assert_eq!(fills(&open(&bytes)), [false, false]);
let mut options = crate::pages::open_options();
options.decode_limits = options.decode_limits.with_max_decoded_stream(16);
let error = crate::import::import_with_reader_options(&bytes, &ImportOptions::default(), &options).unwrap_err();
assert!(error.to_string().contains("no document was imported"), "is_form={is_form}: {error}");
}
}
#[test]
fn lowered_materialization_limit_rejects_incomplete_nested_content() {
let content = format!("/Span << /A {}0{} >> BDC 10 10 20 20 re f EMC", "[".repeat(300), "]".repeat(300));
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(content.as_bytes()).unwrap();
let bytes = pdf(&[stream("/Filter /FlateDecode", &encoder.finish().unwrap())], "", "", &[]);
let mut options = crate::pages::open_options();
options.decode_limits = options.decode_limits.with_max_decoded_stream(16);
let error = crate::import::import_with_reader_options(&bytes, &ImportOptions::default(), &options)
.expect_err("bounded operand parsing must reject incomplete streamed content");
assert!(
matches!(error, crate::PdfError::Parse(ref message) if message == "PDF content is incomplete or exceeds the reader's limits; no document was imported"),
"{error}"
);
}
#[test]
fn decoded_dictionary_delimiter_split_at_content_window_is_checked() {
let mut content = vec![b' '; (64 << 10) - 1];
content.extend(format!("{}0{} DP", "<< /A ".repeat(300), ">> ".repeat(300)).bytes());
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&content).unwrap();
let bytes = pdf(&[stream("/Filter /FlateDecode", &encoder.finish().unwrap())], "", "", &[]);
let error = import_with_report(&bytes, &ImportOptions::default()).unwrap_err();
assert!(
matches!(error, crate::PdfError::Parse(ref message) if message == "PDF content is incomplete or exceeds the reader's limits; no document was imported"),
"{error}"
);
}
+1 -1
View File
@@ -1261,7 +1261,7 @@ impl Renderer {
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) {
if self.pixel_fx.get(&old).is_none_or(|e| e.made.pixels.as_slice() != pm.data_as_u8_slice()) {
return;
}
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
+5 -1
View File
@@ -490,6 +490,9 @@ pub struct Renderer {
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
isolation_settled: bool,
/// The art the last frame drew with Overprint Preview or Separations Preview: the next frame
/// draws it again for the objects that have not changed ([`proof::prepare`]).
proof_art: Option<proof::Prepared>,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
@@ -592,6 +595,7 @@ impl Renderer {
raster: vello_cpu::RasterizerSettings::default(),
isolation: None,
isolation_settled: false,
proof_art: None,
}
}
@@ -616,7 +620,7 @@ impl Renderer {
}
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts);
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
+24 -8
View File
@@ -7,8 +7,9 @@
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
//! when its art shows more than itself (a symbol, another pattern, an image, type), while what
//! the document holds of those is what it held then ([`Deps`]).
//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while
//! what the document holds of those is what it held then ([`Deps`]), and while the color settings
//! are the ones it was drawn through.
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
@@ -17,6 +18,7 @@ use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Weak};
use vectorcraft_color::cms::Cms;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect, Shape};
@@ -35,13 +37,17 @@ const MAX_TILE_PX: f64 = 2048.0;
type TileKey = (String, u64);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, and the
/// fonts. Editing a symbol leaves the art of a pattern that shows it as it was, so the art alone
/// kept the tile from before (#1723). Held weakly: a weak reference keeps the allocation, so no
/// other value gets one of these addresses while the tile is kept.
/// the art of every pattern and symbol and the pixels of every image in the document, the
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
struct Deps {
art: Vec<Weak<Node>>,
pixels: Vec<Weak<Vec<u8>>>,
/// shortcut: any change to the document's other data draws the tile again, not only a brush
/// edit; compare the brush library alone if that ever costs a frame.
unknown: Weak<vectorcraft_brush::serde_json::Map<String, vectorcraft_brush::serde_json::Value>>,
fonts: u64,
}
@@ -58,6 +64,7 @@ impl Deps {
Self {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
fonts: vectorcraft_text::FontDb::global().generation(),
}
}
@@ -65,6 +72,7 @@ impl Deps {
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
self.fonts == vectorcraft_text::FontDb::global().generation()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
}
@@ -74,6 +82,8 @@ struct TileEntry {
art: Vec<Arc<Node>>,
/// `None` for art that shows only itself.
deps: Option<Deps>,
/// The color settings the tile was drawn through, held weakly as [`Deps`] holds its values.
cms: Weak<Cms>,
params: (Rect, TileType, Overlap),
sx: f64,
sy: f64,
@@ -199,13 +209,19 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let key = (name.to_string(), scale.to_bits());
let cms = vectorcraft_color::cms::active();
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles
.get(&key, t)
.filter(|e| e.params == params && same_art(&e.art, &def.art) && e.deps.as_ref().is_none_or(|d| d.current(doc)))
.filter(|e| {
e.params == params
&& same_art(&e.art, &def.art)
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
})
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
@@ -224,7 +240,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(key, TileEntry { art: def.art.clone(), deps, params, sx, sy, pixmap: pm.clone() }, bytes, t);
tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t);
});
(pm, sx, sy)
}
+157 -21
View File
@@ -18,14 +18,15 @@
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
use std::borrow::Cow;
use std::sync::RwLock;
use std::collections::HashMap;
use std::sync::{Arc, RwLock, Weak};
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
use vectorcraft_doc::Document;
use vectorcraft_color::{Color, Swatch, SwatchGroup};
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
use vectorcraft_doc::overprint::multiply_overprints;
use vectorcraft_doc::{Document, Node, NodeId, Shared};
use crate::RenderOptions;
@@ -63,29 +64,164 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String],
Color::Rgb { r, g, b }
}
/// The document as it should be drawn for these options (overprints, separations).
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
let overprint = overprints(opts) && (doc.layers.iter().any(|l| l.has_overprint()) || doc.symbols.iter().any(|s| s.art.has_overprint()));
if !overprint && seps.is_none() {
/// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame
/// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for
/// it, and what that art was made with. The kept nodes keep their addresses: no other node gets
/// one of them while they are kept.
pub(crate) struct Prepared {
inputs: Inputs,
layers: (Vec<Arc<Node>>, Vec<Arc<Node>>),
art: (Vec<Arc<Node>>, Vec<Arc<Node>>),
}
/// What [`prepare`] reads besides the art.
#[derive(PartialEq)]
struct Inputs {
discard_white: bool,
plates: Option<Plates>,
}
/// What the colors of Separations Preview's plates are made from ([`plate_color`]).
struct Plates {
/// The proof setup, with the visible plates.
proof: ProofSetup,
/// The document's swatches, swatch groups and Spot Colors option.
swatches: Shared<Vec<Swatch>>,
groups: Shared<Vec<SwatchGroup>>,
spot_use_lab: bool,
/// The color settings, held weakly: no other settings get their address while they are kept.
cms: Weak<Cms>,
/// [`cms::profiles_generation`].
profiles: u64,
}
impl PartialEq for Plates {
fn eq(&self, o: &Self) -> bool {
(&self.proof, &self.swatches, &self.groups, self.spot_use_lab, self.profiles)
== (&o.proof, &o.swatches, &o.groups, o.spot_use_lab, o.profiles)
&& Weak::ptr_eq(&self.cms, &o.cms)
}
}
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
/// print it ([`plate_color`]).
struct How<'a> {
doc: &'a Document,
discard_white: bool,
/// Separations Preview: the color settings, the proof setup and the visible plates.
plates: Option<(&'a Cms, &'a ProofSetup, &'a [String])>,
}
impl How<'_> {
/// Whether [`Self::apply`] can change `n`: with Separations Preview any node, else a node with
/// an overprinting fill or stroke.
fn changes(&self, n: &Node) -> bool {
self.plates.is_some() || n.appearance.items.iter().any(|i| i.overprint())
}
/// Change the fills, strokes and other colors of node `a`, which holds no members.
fn apply(&self, a: &mut Arc<Node>) {
multiply_overprints(a, self.discard_white);
if let Some((c, proof, visible)) = self.plates {
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
}
}
}
/// The document as it should be drawn for these options (overprints, separations). `kept` holds
/// the art of an earlier frame ([`Prepared`]): when that frame's inputs were the same, each layer,
/// symbol art and member that is still the node its kept art was made from is drawn from that art,
/// with its `Arc`. `kept` then holds this frame's art, or nothing when neither preview is on.
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions, kept: &mut Option<Prepared>) -> Cow<'a, Document> {
if !overprints(opts) {
*kept = None;
return Cow::Borrowed(doc);
}
let mut d = doc.clone();
if overprint {
let discard_white = doc.setup.discard_white_overprint;
for l in &mut d.layers {
multiply_overprints(l, discard_white);
}
for s in &mut d.symbols {
multiply_overprints(&mut s.art, discard_white);
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
let c = cms::active();
let inputs = Inputs {
discard_white: doc.setup.discard_white_overprint,
plates: seps.map(|(proof, _)| Plates {
proof: proof.clone(),
swatches: doc.swatches.clone(),
groups: doc.swatch_groups.clone(),
spot_use_lab: doc.spot_use_lab,
cms: Arc::downgrade(&c),
profiles: cms::profiles_generation(),
}),
};
let earlier = kept.take().filter(|k| k.inputs == inputs);
let how = How { doc, discard_white: inputs.discard_white, plates: seps.map(|(proof, visible)| (&*c, proof, visible.as_slice())) };
let none = (vec![], vec![]);
let (layers, art) = earlier.as_ref().map_or((&none, &none), |k| (&k.layers, &k.art));
let made_layers = prepare_list(&doc.layers, &layers.0, &layers.1, &how);
let symbol_art: Vec<Arc<Node>> = doc.symbols.iter().map(|s| s.art.clone()).collect();
let made_art = prepare_list(&symbol_art, &art.0, &art.1, &how);
let same = |made: &[Arc<Node>], from: &[Arc<Node>]| made.iter().zip(from).all(|(a, b)| Arc::ptr_eq(a, b));
let out = if same(&made_layers, &doc.layers) && same(&made_art, &symbol_art) {
Cow::Borrowed(doc)
} else {
let mut d = doc.clone();
d.layers.clone_from(&made_layers);
for (s, a) in d.symbols.iter_mut().zip(&made_art) {
s.art = a.clone();
}
Cow::Owned(d)
};
*kept = Some(Prepared { inputs, layers: (doc.layers.clone(), made_layers), art: (symbol_art, made_art) });
out
}
/// `nodes` as drawn with `how` ([`prepare_node`]), each against its earlier version in `was`: the
/// node at its place when that node has its id, else the node with its id. `made` holds the art
/// drawn for `was`, place by place.
fn prepare_list(nodes: &[Arc<Node>], was: &[Arc<Node>], made: &[Arc<Node>], how: &How) -> Vec<Arc<Node>> {
let mut places: Option<HashMap<NodeId, usize>> = None;
nodes
.iter()
.enumerate()
.map(|(i, n)| {
let at = match was.get(i) {
Some(w) if w.id == n.id => Some(i),
_ => places.get_or_insert_with(|| was.iter().enumerate().map(|(j, w)| (w.id, j)).collect()).get(&n.id).copied(),
};
prepare_node(n, at.and_then(|j| Some((was.get(j)?, made.get(j)?))), how)
})
.collect()
}
/// `n` as drawn with `how`: the art of `earlier` (a node and the art drawn for it) when that art
/// was made from `n` itself; else a copy of `n` with its own fills and strokes changed
/// ([`How::apply`]) and its members prepared against those of `earlier` ([`prepare_list`]), or `n`
/// itself when neither changes.
fn prepare_node(n: &Arc<Node>, earlier: Option<(&Arc<Node>, &Arc<Node>)>, how: &How) -> Arc<Node> {
if let Some((was, made)) = earlier
&& Arc::ptr_eq(was, n)
{
return made.clone();
}
if let Some((proof, visible)) = seps {
let c = cms::active();
let src = doc.clone();
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
let members = n.children().map(|ch| {
let (was, made) = earlier.and_then(|(w, m)| Some((w.children()?.as_slice(), m.children()?.as_slice()))).unwrap_or_default();
prepare_list(ch, was, made, how)
});
let same_members = match (&members, n.children()) {
(Some(m), Some(ch)) => m.iter().zip(ch).all(|(a, b)| Arc::ptr_eq(a, b)),
_ => true,
};
if same_members && !how.changes(n) {
return n.clone();
}
Cow::Owned(d)
let mut own = Node::clone(n);
if let Some(ch) = own.children_mut() {
ch.clear();
}
let mut a = Arc::new(own);
how.apply(&mut a);
if let (Some(m), Some(ch)) = (members, Arc::make_mut(&mut a).children_mut()) {
*ch = m;
}
a
}
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
+159 -2
View File
@@ -941,8 +941,8 @@ fn rasters_draw_pattern_edits() {
}
/// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself
/// doesn't: a symbol an instance in it shows, or another pattern it is painted with. So is a
/// raster effect on an object painted with such a pattern.
/// doesn't: a symbol an instance in it shows, another pattern it is painted with, or a brush it is
/// stroked with. So is a raster effect on an object painted with such a pattern.
#[test]
fn pattern_tiles_draw_edits_to_what_their_art_shows() {
let _fonts = fonts_steady();
@@ -981,6 +981,20 @@ fn pattern_tiles_draw_edits_to_what_their_art_shows() {
edit_draws_as_new(doc("Stamps", blur()), new_symbol);
edit_draws_as_new(doc("Outer", vec![]), new_checks);
edit_draws_as_new(doc("Outer", blur()), new_checks);
// Pattern `Brushed` is a line stroked with a calligraphic brush; the brush gets wider.
let mut d = Document::new(200.0, 100.0);
let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let brushed = Node::path(d.alloc_id(), vectorcraft_geom::shapes::line((2.0, 10.0).into(), (18.0, 10.0).into()), stroke);
d.patterns.push(tile("Brushed", brushed));
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint("Brushed"), vec![]);
edit_draws_as_new(with_art(d, vec![painted]), |d| {
let mut lib = vectorcraft_brush::library(d);
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
c.size = 12.0;
}
vectorcraft_brush::store(d, &lib);
});
}
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
@@ -1136,6 +1150,149 @@ fn rasters_follow_the_view_options() {
draws_as_new(&mut d, &isolated, &on_white, unchanged);
}
/// A document for the proof previews: a cyan square, a magenta circle over it whose black stroke
/// overprints, a white square over the cyan one that overprints, a blurred rectangle in spot color
/// `Spot`, and an instance of symbol `S`, a group holding a yellow square.
fn proof_doc() -> Document {
use vectorcraft_geom::shapes::{ellipse, rectangle};
let mut d = Document::new(200.0, 100.0);
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
d.swatches.push(vectorcraft_color::Swatch { name: "Spot".into(), paint: Paint::solid(orange), global: true, spot: true });
let cyan = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), vec![]);
let black = Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0));
let ring = vectorcraft_doc::Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), black, 4.0);
let mut circle = Node::path(d.alloc_id(), ellipse(Rect::new(40.0, 20.0, 100.0, 80.0)), ring);
circle.appearance.stroke_mut().unwrap().overprint = true;
let mut white = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 40.0, 50.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 0.0)), vec![]);
white.appearance.fill_mut().unwrap().overprint = true;
let spot = Paint::Solid { color: orange, swatch: Some("Spot".into()), tint: 1.0 };
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
let spot = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 150.0, 50.0)), spot, vec![blur]);
let yellow = filled(&mut d, rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)), vec![]);
let art = Node::group(d.alloc_id(), vec![Arc::new(yellow)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((175.0, 75.0)) });
with_art(d, vec![cyan, circle, white, spot, instance])
}
/// Overprint Preview, and Separations Preview showing `plates`, which turns Overprint Preview on
/// too (as the canvas draws them).
fn proof_views(plates: &[&str]) -> (RenderOptions, RenderOptions) {
let overprint = RenderOptions { overprint_preview: true, ..Default::default() };
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
(overprint.clone(), RenderOptions { proof: Some(proof), ..overprint })
}
/// [`proof_views`] with every plate of [`proof_doc`].
fn every_plate() -> (RenderOptions, RenderOptions) {
proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Spot"])
}
/// With Overprint Preview or Separations Preview on, a second frame of an unchanged document draws
/// the art the first prepared for the preview: it makes no geometry entries. After an object
/// moves, a frame makes them for the object and its layer only. A frame drawn with neither preview
/// lets go of the art, and each frame drawn with a preview draws what a new renderer draws.
#[test]
fn proof_previews_keep_their_art_between_frames() {
let _fonts = fonts_steady();
let d = proof_doc();
let (overprint, separations) = every_plate();
let frame = |r: &mut Renderer, d: &Document, opts: &RenderOptions| r.render(d, 200, 100, Affine::IDENTITY, opts).pixels;
let geometry = |r: &Renderer| r.geom.keys().copied().collect::<std::collections::HashSet<_>>();
for opts in [&overprint, &separations] {
let mut r = Renderer::new();
let first = frame(&mut r, &d, opts);
let kept = geometry(&r);
assert!(frame(&mut r, &d, opts) == first);
assert_eq!(geometry(&r), kept, "the second frame makes no geometry entries");
// The cyan square moves 5 pt right.
let mut moved = d.clone();
let square = moved.layers[0].children().unwrap()[0].id;
moved.node_mut(square).unwrap().transform(Affine::translate((5.0, 0.0)), false);
assert!(frame(&mut r, &moved, opts) == frame(&mut Renderer::new(), &moved, opts));
assert_eq!(geometry(&r).difference(&kept).count(), 2, "the square and its layer");
frame(&mut r, &moved, &RenderOptions::default());
assert!(r.proof_art.is_none(), "drawn without the previews, the renderer keeps no art for them");
}
}
/// With Overprint Preview or Separations Preview on, a renderer draws what a new renderer draws
/// after a change to the art or to what the previews read: an object recolored, Discard White
/// Overprint turned off, a symbol's art recolored (its instance shows the plates of the new art),
/// the spot color redefined, a spot color in a color group redefined, the Spot Colors option set
/// to CMYK values, a plate hidden, Simulate Paper Color, another proof profile, and one preview
/// after the other.
#[test]
fn proof_previews_draw_changes_as_a_new_renderer_does() {
let _fonts = fonts_steady();
let (overprint, separations) = every_plate();
let unchanged = |_: &mut Document| {};
let recolor_square = |d: &mut Document| {
let square = d.layers[0].children().unwrap()[0].id;
d.node_mut(square).unwrap().appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)));
};
let recolor_symbol = |d: &mut Document| {
let mut art = (*d.symbols[0].art).clone();
if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) {
Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
}
d.symbols[0].art = Arc::new(art);
};
for opts in [&overprint, &separations] {
draws_as_new(&mut proof_doc(), opts, opts, recolor_square);
draws_as_new(&mut proof_doc(), opts, opts, |d| d.setup.discard_white_overprint = false);
}
draws_as_new(&mut proof_doc(), &separations, &separations, recolor_symbol);
draws_as_new(&mut proof_doc(), &separations, &separations, |d| {
if let Some(s) = d.swatch_mut("Spot") {
s.paint = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0));
}
});
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
let lab_doc = || {
let mut d = proof_doc();
let lab = Color::lab(50.0, 60.0, 40.0);
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
let square = filled(&mut d, vectorcraft_geom::shapes::rectangle(Rect::new(115.0, 60.0, 155.0, 95.0)), fill, vec![]);
with_art(d, vec![square])
};
let lab_plate = proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Lab Spot"]).1;
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| {
if let Some(s) = d.swatch_mut("Lab Spot") {
s.paint = Paint::solid(Color::lab(70.0, -40.0, 30.0));
}
});
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| d.spot_use_lab = false);
let hidden = proof_views(&["Magenta", "Yellow", "Black", "Spot"]).1;
draws_as_new(&mut proof_doc(), &separations, &hidden, unchanged);
let with = |f: fn(&mut proof::ProofSetup)| {
let mut opts = separations.clone();
if let Some(p) = &mut opts.proof {
f(p);
}
opts
};
draws_as_new(&mut proof_doc(), &separations, &with(|p| p.simulate_paper = true), unchanged);
let device = with(|p| p.target = proof::ProofTarget::Cmyk(vectorcraft_color::cms::DEVICE_CMYK.into()));
draws_as_new(&mut proof_doc(), &separations, &device, unchanged);
let cyan = proof_views(&["Cyan"]).1;
draws_as_new(&mut proof_doc(), &overprint, &cyan, unchanged);
draws_as_new(&mut proof_doc(), &cyan, &overprint, unchanged);
// On the Yellow plate the instance is solid while its art is yellow, and blank once it is cyan.
let yellow = proof_views(&["Yellow"]).1;
let mut d = proof_doc();
let mut r = Renderer::new();
let instance = |r: &mut Renderer, d: &Document| {
let pixels = r.render(d, 200, 100, Affine::IDENTITY, &yellow).pixels;
Rendered { width: 200, height: 100, pixels }.pixel(175, 75)
};
assert_eq!(instance(&mut r, &d), [0, 0, 0, 255]);
recolor_symbol(&mut d);
assert_eq!(instance(&mut r, &d), [255, 255, 255, 255]);
}
/// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is
/// made again when the document's color mode changes: CMYK documents screen ink channels, RGB
/// documents light ones.
+55 -8
View File
@@ -1113,9 +1113,9 @@ impl Pen<'_> {
}
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the
/// next tab) on the stop.
fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 {
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
use vectorcraft_doc::TabAlign;
let rel = x - origin;
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect();
@@ -1128,14 +1128,52 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
};
let explicit = tabs.iter().find_map(|t| {
let at = target(t.position, t.align, t.align_on);
(at > rel + 1e-6).then_some(at)
(at > rel + 1e-6).then_some((at, t.leader.as_str()))
});
let at = explicit.unwrap_or_else(|| {
let (at, leader) = explicit.unwrap_or_else(|| {
let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
let base = rel.max(last);
((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL
(((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "")
});
(at - rel).max(0.0)
((at - rel).max(0.0), leader)
}
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
/// millions of outlines.
const MAX_LEADER_COPIES: f64 = 4096.0;
/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated
/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width
/// from `origin` (where tab stops are measured from), so the leaders of lines above one another
/// line up; only whole copies are drawn. Characters the font lacks draw nothing.
fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath {
let mut out = BezPath::new();
let glyphs: Vec<(u32, f64)> = leader
.chars()
.filter(|c| !c.is_control())
.map(|c| {
let gid = g.face.glyph_for(c);
(gid, g.face.advance(gid) * g.sx)
})
.collect();
let width: f64 = glyphs.iter().map(|(_, a)| a).sum();
if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) {
return out;
}
let first = ((from - origin) / width - EPS).ceil();
let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize;
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
for k in 0..copies {
let mut pen = origin + (first + k as f64) * width;
for &(gid, adv) in &glyphs {
if gid != 0 {
let m = Affine::translate((pen, y)) * local;
out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el));
}
pen += adv;
}
}
out
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
@@ -1734,8 +1772,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
if j + 1 == trimmed {
adv -= end_trim;
}
let mut leader = "";
if g.ch == '\t' {
adv = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
(adv, leader) = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < body {
if g.is_space() {
adv += per_space;
@@ -1750,6 +1789,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical);
cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li);
if !leader.is_empty() {
// The leader belongs to the tab's glyph: the gap stays one character for the
// caret, selection and hit testing.
let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y);
if let Some(tab) = cx.out.glyphs.last_mut() {
tab.outline.extend(dots);
}
}
x += adv;
if j < trimmed {
x_end = x;
+27
View File
@@ -466,6 +466,33 @@ fn tab_stops_position_text() {
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
#[test]
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
use vectorcraft_doc::{TabAlign, TabStop};
let mut t = point("A\t1\nAAAAA\t2", style(12.0));
t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }];
let lay = layout(db(), &t);
// The left edges of the dots of each line's tab.
let dots = |byte: usize| -> Vec<f64> {
let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
let mut xs: Vec<f64> = vec![];
for el in tab.outline.elements() {
if let kurbo::PathEl::MoveTo(p) = el {
xs.push(p.x);
}
}
xs.sort_by(f64::total_cmp);
xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
xs
};
let (short, long) = (dots(1), dots(9));
assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}");
// The longer line's dots are the shorter line's last ones, at the same x.
let tail = &short[short.len() - long.len()..];
assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}");
assert!(short.last().is_some_and(|&x| x < 120.0));
}
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
fn japanese_fonts() -> bool {
+16
View File
@@ -362,6 +362,22 @@ fn new_type_snaps_to_smart_guides() {
#[test]
fn layout_cache_follows_fonts_added_later() {
// Other tests can load system fonts; this assertion needs its own process-wide database.
#[cfg(not(target_arch = "wasm32"))]
if std::env::var("VECTORCRAFT_FONT_CACHE_TEST_CHILD").as_deref() != Ok("1") {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "text::tests::layout_cache_follows_fonts_added_later", "--nocapture", "--test-threads=1"])
.env("VECTORCRAFT_FONT_CACHE_TEST_CHILD", "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated font cache test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
// The web build adds fonts after startup: a caret layout cached before one arrived is stale.
let (d, id, tool) = editing("abc");
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("not text") };
+5
View File
@@ -33,6 +33,11 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is
# MIT OR Apache-2.0 and already in the build through eframe.
web-time = "1"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
+69 -3
View File
@@ -153,6 +153,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
// `hidden_chars` holds the marks Show Hidden Characters kept and the text nodes they were laid
// out from. Only the assignment right before the overlay call below puts them back. A frame
// that does not draw the overlay drops them, for example with the option off, with the edges
// hidden, on the Home screen or with no document open.
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
// A document that opened, closed or became active since Home was chosen replaces it.
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
app.ui.home = None;
@@ -438,6 +443,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
thread_overlay(app, &painter, &xf);
}
if app.ui.view.hidden_chars {
app.canvas.hidden_chars = hidden_chars;
hidden_chars_overlay(app, &painter, &xf);
}
}
@@ -1530,8 +1536,8 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
let kept = &mut app.canvas.hidden_chars;
// `last` holds the marks of the last frame that showed them. The marks of the objects this
// frame doesn't draw are dropped at the end.
// `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw
// are dropped at the end.
let mut last = std::mem::take(&mut kept.marks);
// The inputs are read before any layout: when fonts load during this frame, the next frame
// lays all type out again.
@@ -1569,7 +1575,7 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
});
}
/// The Show Hidden Characters marks of the text objects drawn in the last frame that showed them
/// The Show Hidden Characters marks of the text objects drawn in the last frame
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
/// fonts or the hyphenation exceptions change.
@@ -3763,6 +3769,66 @@ mod tests {
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
}
/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]).
fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
let blue = Color32::from_rgb(0x4f, 0x9d, 0xff);
shapes(app, ctx)
.into_iter()
.filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue))
.collect()
}
/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before.
/// Turning it off drops the marks with their text nodes, and turning it on again draws the
/// same marks.
#[test]
fn show_hidden_characters_off_releases_the_text_nodes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"});
let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap());
let ctx = egui::Context::default();
app.run("type.hiddenCharacters", json!({})).unwrap();
let on = hidden_char_marks(&mut app, &ctx);
assert!(on.len() > 2, "{on:?}");
// Tests running alongside add fonts and set hyphenation exceptions, which lays all type out
// again: frames are drawn until neither changed between two of them.
let kept = (0..100).any(|_| {
let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone());
hidden_char_marks(&mut app, &ctx);
let after = &app.canvas.hidden_chars;
after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks)
});
assert!(kept, "a frame keeps the marks of the frame before");
app.run("type.hiddenCharacters", json!({})).unwrap();
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off");
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept");
app.run("type.hiddenCharacters", json!({})).unwrap();
assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks");
}
/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and
/// the same type in a new document shows the same marks.
#[test]
fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
app.run("type.hiddenCharacters", json!({})).unwrap();
let open = |app: &mut VectorcraftApp| {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap();
hidden_char_marks(app, &ctx)
};
let first = open(&mut app);
assert!(first.len() > 2, "{first:?}");
assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node");
app.session.execute("file.close", &json!({})).unwrap();
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen");
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept");
assert_eq!(open(&mut app), first, "the same type in a new document");
}
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
+21 -3
View File
@@ -80,6 +80,7 @@ pub mod save_style_library;
pub mod save_swatch_library;
pub mod script_check;
mod shapes;
pub mod shortcut_import;
pub mod slices;
pub mod spot_colors;
pub(crate) mod svg_options;
@@ -305,6 +306,7 @@ registry! {
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
@@ -333,11 +335,24 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes
r
}
/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`).
/// The field of a dialog opened over another (Import Shortcut Set over Keyboard Shortcuts) that
/// holds the dialog under it, which Cancel and Escape show again.
pub(crate) const PARENT: &str = "__parent";
/// The dialog `d` was opened over, as it was.
pub(crate) fn parent(d: &Dialog) -> Option<Dialog> {
d.fields.get(PARENT).and_then(|v| serde::Deserialize::deserialize(v).ok())
}
/// Close the open dialog as Cancel does, rolling back a live preview and showing the dialog it was
/// opened over (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) {
let _ = app.session.cancel_interaction();
if let Some(d) = app.ui.dialog.take() {
if spec(&d.kind).preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = parent(&d);
}
settle(app);
}
@@ -541,3 +556,6 @@ mod tests_modal;
#[cfg(test)]
mod tests_rectangular_grid;
#[cfg(test)]
mod tests_shortcut_import;
+1 -1
View File
@@ -377,7 +377,7 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str],
for s in sections {
let sel = *s == section;
let label = egui::RichText::new(tl!(*s)).size(12.5).color(if sel { t.text_strong } else { t.text });
if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
if ui.add(egui::Button::selectable(sel, label).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
d.fields.insert("__section".into(), json!(s));
}
}
@@ -0,0 +1,899 @@
//! Import Shortcut Set: a shortcut set another app saved as text (`.kys`, read by `shortcutSet.read`),
//! reviewed before it fills Edit › Keyboard Shortcuts' working set.
//!
//! Each entry's name is matched against the tools, commands and panels Keyboard Shortcuts lists
//! ([`vectorcraft_engine::namematch`]). Its key and modifier codes are decoded from the user's earlier
//! answers (`importMatches`), from the two codes the format's public description gives (`/Key 48` is
//! the 0 key and `/Modifiers 0` is none), and by agreement with VectorCraft's default shortcuts: each
//! entry whose match has a default votes for what its codes stand for, and a code is proposed when at
//! least two entries vote for it and four votes in five agree. The user answers the rest in the Keys
//! section. Apply assigns the rows that are ready, in the file's order, through
//! [`shortcut_editor::assign`]; when two rows want one shortcut, the later row takes it. Keyboard
//! Shortcuts then shows the set, and its OK keeps it.
//!
//! Fields: `name`, `start` (`defaults` or `current`), `remember`, `rows` ([`Row`]s), `keys` and `mods`
//! ([`Code`]s by code), `notes` (`[{line, text}]`: the parts of the file not read), `truncated`,
//! `__parent` (the Keyboard Shortcuts dialog it was opened from, which Apply, Cancel and Escape go back
//! to), `__recording` (the key code waiting for a key press), `__keysOpen` (the Keys section is open:
//! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_engine::namematch::{Candidate, Matcher, Proposal};
use crate::shortcut_editor::{self, Entry};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{FilePick, VectorcraftApp, menus, widgets};
/// The dialog kind.
pub const KIND: &str = "shortcutImport";
/// The files Keyboard Shortcuts' Import… lists: VectorCraft's own sets and sets another app saved as
/// text.
pub const IMPORT_EXTS: &[&str] = &["json", "kys"];
/// The codes the format's public description gives: `/Key 48` is the 0 key.
const DESCRIBED_KEYS: &[(i64, &str)] = &[(48, "0")];
/// `/Modifiers 0` is none.
const DESCRIBED_MODS: &[(i64, &str)] = &[(0, "")];
/// The least votes a code needs for agreement to propose it.
const MIN_VOTES: usize = 2;
/// The share of the votes that must agree: four in five.
const AGREE: (usize, usize) = (4, 5);
/// The most entry names a code lists.
const NAMES_SHOWN: usize = 3;
/// The modifiers the Keys section offers, as Edit › Keyboard Shortcuts writes them ("" is none).
const MODIFIERS: &[&str] = &[
"",
"Cmd",
"Ctrl",
"Alt",
"Shift",
"Cmd+Ctrl",
"Cmd+Alt",
"Cmd+Shift",
"Ctrl+Alt",
"Ctrl+Shift",
"Alt+Shift",
"Cmd+Ctrl+Alt",
"Cmd+Ctrl+Shift",
"Cmd+Alt+Shift",
"Ctrl+Alt+Shift",
"Cmd+Ctrl+Alt+Shift",
];
/// The table's row height and column widths (points).
const ROW_H: f32 = 22.0;
const NAME_W: f32 = 200.0;
const TARGET_W: f32 = 230.0;
const CHORD_W: f32 = 90.0;
/// The tallest the Keys and Shortcuts lists get (points).
const KEYS_LIST_H: f32 = 140.0;
const ROWS_LIST_H: f32 = 280.0;
/// An entry of the file and what it becomes.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Row {
/// As the file writes it.
pub name: String,
/// The names of the blocks around it (`Menus › Edit`).
pub section: String,
pub line: usize,
pub key: Option<i64>,
pub modifiers: Option<i64>,
/// A key of [`shortcut_editor::entries`], or "" for no equivalent; none until chosen.
pub target: Option<String>,
/// Where the target came from: `saved` (an earlier import), `matched` (the name) or `you`.
pub from: String,
/// The candidates to choose from when the name matched none clearly, best first.
pub choices: Vec<String>,
/// Checked: Apply assigns it.
pub on: bool,
}
/// What a key or modifier code stands for.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Code {
/// A key, or modifiers, as Edit › Keyboard Shortcuts writes them: "" for a key with no equivalent
/// or for no modifiers. None until known.
pub text: Option<String>,
/// `saved` (an earlier import), `described` (the format's description), `agreement` or `you`; ""
/// while unknown.
pub from: String,
/// For `agreement`: the entries whose default shortcut agrees.
pub votes: usize,
/// The first few entries that use it.
pub names: Vec<String>,
}
/// What happens to a row on Apply.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Ready,
/// Unchecked.
Off,
NeedsChoice,
NeedsKey,
NoEquivalent,
/// A tool's shortcut with Cmd, Alt or Ctrl.
ToolKey,
/// A later row takes the same shortcut.
SameShortcut,
/// A later row gives the same tool, command or panel its shortcut.
SameTarget,
}
impl Status {
pub fn label(self) -> &'static str {
match self {
Self::Ready => tl!("Ready"),
Self::Off => tl!("Off"),
Self::NeedsChoice => tl!("Needs a choice"),
Self::NeedsKey => tl!("Needs a key"),
Self::NoEquivalent => tl!("No equivalent"),
Self::ToolKey => tl!("Tools take a single key (optionally with Shift)"),
Self::SameShortcut => tl!("A later row takes this shortcut"),
Self::SameTarget => tl!("Same item as a later row"),
}
}
/// The user has something to answer or change.
pub fn needs_attention(self) -> bool {
matches!(self, Self::NeedsChoice | Self::NeedsKey | Self::ToolKey | Self::SameShortcut | Self::SameTarget)
}
}
/// The rows and codes of a review.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Review {
pub rows: Vec<Row>,
pub keys: BTreeMap<i64, Code>,
pub mods: BTreeMap<i64, Code>,
}
impl Review {
/// The review of `read`, `shortcutSet.read`'s result, with names matched against `candidates`
/// ([`candidates`]).
pub fn build(read: &Value, candidates: &[Candidate]) -> Self {
let matcher = Matcher::new(candidates);
let id = |i: usize| candidates.get(i).map(|c| c.id.clone());
let mut rows = vec![];
for e in read["entries"].as_array().into_iter().flatten() {
let name = e["name"].as_str().unwrap_or_default().to_string();
let (target, from, choices) = match e["target"].as_str() {
Some(t) => (Some(t.to_string()), "saved", vec![]),
None => match matcher.propose(&name) {
Proposal::Match(i, _) => (id(i), "matched", vec![]),
Proposal::Choice(best) => (None, "", best.into_iter().filter_map(id).collect()),
},
};
rows.push(Row {
section: e["section"].as_array().into_iter().flatten().filter_map(Value::as_str).collect::<Vec<_>>().join(" › "),
line: e["line"].as_u64().unwrap_or(0) as usize,
key: e["key"].as_i64(),
modifiers: e["modifiers"].as_i64(),
target,
from: from.into(),
choices,
on: true,
name,
});
}
let codes = |list: &Value, field: &str, described: &[(i64, &str)], of: fn(&Row) -> Option<i64>| -> BTreeMap<i64, Code> {
let mut out = BTreeMap::new();
for c in list.as_array().into_iter().flatten() {
let Some(value) = c["value"].as_i64() else { continue };
let names = rows.iter().filter(|r| of(r) == Some(value)).take(NAMES_SHOWN).map(|r| r.name.clone()).collect();
let (text, from) = match (c[field].as_str(), described.iter().find(|(v, _)| *v == value)) {
(Some(t), _) => (Some(t.to_string()), "saved"),
(None, Some((_, t))) => (Some(t.to_string()), "described"),
(None, None) => (None, ""),
};
out.insert(value, Code { text, from: from.into(), votes: 0, names });
}
out
};
let keys = codes(&read["codes"]["keys"], "key", DESCRIBED_KEYS, |r| r.key);
let mods = codes(&read["codes"]["modifiers"], "mods", DESCRIBED_MODS, |r| r.modifiers);
let mut review = Self { rows, keys, mods };
review.decode();
review
}
/// The review `d` holds.
pub fn of(d: &Dialog) -> Self {
fn field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, k: &str) -> T {
d.fields.get(k).and_then(|v| T::deserialize(v).ok()).unwrap_or_default()
}
Self { rows: field(d, "rows"), keys: field(d, "keys"), mods: field(d, "mods") }
}
fn store(&self, d: &mut Dialog) {
d.fields.insert("rows".into(), serde_json::to_value(&self.rows).unwrap_or_else(|_| json!([])));
d.fields.insert("keys".into(), serde_json::to_value(&self.keys).unwrap_or_else(|_| json!({})));
d.fields.insert("mods".into(), serde_json::to_value(&self.mods).unwrap_or_else(|_| json!({})));
}
/// Codes that no earlier import, description or user answered take what agreement with
/// VectorCraft's default shortcuts proposes for them now.
pub fn decode(&mut self) {
let mut defaults: HashMap<&str, Option<(String, String)>> = HashMap::new();
let mut keys: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
let mut mods: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
for r in &self.rows {
let Some(t) = r.target.as_deref().filter(|t| !t.is_empty()) else { continue };
let default =
defaults.entry(t).or_insert_with(|| shortcut_editor::default_of(t).and_then(shortcut_editor::normalize).map(|c| split_chord(&c)));
let Some((m, k)) = default else { continue };
if let Some(code) = r.key {
*keys.entry(code).or_default().entry(k.clone()).or_default() += 1;
}
if let Some(code) = r.modifiers {
*mods.entry(code).or_default().entry(m.clone()).or_default() += 1;
}
}
for (codes, votes) in [(&mut self.keys, keys), (&mut self.mods, mods)] {
for (code, c) in codes.iter_mut().filter(|(_, c)| c.from.is_empty() || c.from == "agreement") {
let names = std::mem::take(&mut c.names);
*c = match votes.get(code).and_then(agreed) {
Some((text, votes)) => Code { text: Some(text), from: "agreement".into(), votes, names },
None => Code { names, ..Code::default() },
};
}
}
}
/// Row `r`'s shortcut as Edit › Keyboard Shortcuts writes it: `Some(None)` when its key has no
/// equivalent, none while one of its codes isn't known. An entry without `/Modifiers` takes none.
pub fn chord(&self, r: &Row) -> Option<Option<String>> {
let key = self.keys.get(&r.key?)?.text.as_deref()?;
// A key with no equivalent needs no modifiers.
if key.is_empty() {
return Some(None);
}
let mods = match r.modifiers {
Some(m) => self.mods.get(&m)?.text.as_deref()?,
None => "",
};
Some(shortcut_editor::normalize(&if mods.is_empty() { key.to_string() } else { format!("{mods}+{key}") }))
}
fn status(&self, r: &Row) -> (Status, Option<String>) {
if !r.on {
return (Status::Off, None);
}
let target = match r.target.as_deref() {
None => return (Status::NeedsChoice, None),
Some("") => return (Status::NoEquivalent, None),
Some(t) if !known(t) => return (Status::NeedsChoice, None),
Some(t) => t,
};
match self.chord(r) {
None => (Status::NeedsKey, None),
Some(None) => (Status::NoEquivalent, None),
Some(Some(c)) if target.starts_with("tool:") && !single_key(&c) => (Status::ToolKey, Some(c)),
Some(Some(c)) => (Status::Ready, Some(c)),
}
}
/// Each row's status, with the shortcut it takes. When ready rows give the same tool, command or
/// panel a shortcut, or want the same shortcut, the last of them does.
pub fn statuses(&self) -> Vec<(Status, Option<String>)> {
let mut out: Vec<(Status, Option<String>)> = self.rows.iter().map(|r| self.status(r)).collect();
let (mut targets, mut chords) = (HashSet::new(), HashSet::new());
for (r, (status, chord)) in self.rows.iter().zip(out.iter_mut()).rev() {
let (Status::Ready, Some(t), Some(c)) = (*status, r.target.as_deref(), chord.as_ref()) else { continue };
if targets.contains(t) {
*status = Status::SameTarget;
} else if chords.contains(c) {
*status = Status::SameShortcut;
} else {
targets.insert(t);
chords.insert(c.clone());
}
}
out
}
/// Some code the Keys section lists is not known yet.
fn asks(&self) -> bool {
let (keys, mods) = self.asked();
keys.iter().any(|k| self.keys.get(k).is_some_and(|c| c.text.is_none()))
|| mods.iter().any(|m| self.mods.get(m).is_some_and(|c| c.text.is_none()))
}
/// The codes the Keys section lists: those of the checked rows with a target, the unknown ones
/// first.
fn asked(&self) -> (Vec<i64>, Vec<i64>) {
let (mut keys, mut mods) = (BTreeSet::new(), BTreeSet::new());
for r in self.rows.iter().filter(|r| r.on && r.target.as_deref().is_some_and(|t| !t.is_empty())) {
keys.extend(r.key.filter(|k| self.keys.contains_key(k)));
mods.extend(r.modifiers.filter(|m| self.mods.contains_key(m)));
}
let unknown_first = |set: BTreeSet<i64>, codes: &BTreeMap<i64, Code>| {
let mut v: Vec<i64> = set.into_iter().collect();
v.sort_by_key(|c| codes.get(c).is_some_and(|c| c.text.is_some()));
v
};
(unknown_first(keys, &self.keys), unknown_first(mods, &self.mods))
}
}
/// The text most votes agree on, with its votes, when at least [`MIN_VOTES`] voted and four votes in
/// five agree.
fn agreed(votes: &HashMap<String, usize>) -> Option<(String, usize)> {
let total: usize = votes.values().sum();
let (text, n) = votes.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))?;
(total >= MIN_VOTES && n * AGREE.1 >= total * AGREE.0).then(|| (text.clone(), *n))
}
/// A shortcut as written (`Cmd+Shift+K`) split into its modifiers (`Cmd+Shift`, "" for none) and key.
fn split_chord(chord: &str) -> (String, String) {
match chord.rsplit_once('+') {
Some((m, k)) => (m.to_string(), k.to_string()),
None => (String::new(), chord.to_string()),
}
}
/// A shortcut a tool can take: one key, optionally with Shift.
fn single_key(chord: &str) -> bool {
crate::shortcuts::parse(chord).is_some_and(|sc| !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl))
}
/// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in
/// the interface language.
pub fn candidates() -> Vec<Candidate> {
shortcut_editor::entries()
.iter()
.map(|e| {
let shown = tl!(&e.label);
let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] };
Candidate { id: e.key.clone(), labels }
})
.collect()
}
/// Keyboard Shortcuts' entry `key`.
fn entry(key: &str) -> Option<&'static Entry> {
static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new();
BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied()
}
/// A tool, command or panel that Keyboard Shortcuts can give a shortcut.
fn known(key: &str) -> bool {
entry(key).is_some() || shortcut_editor::default_of(key).is_some()
}
/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu.
fn shown(e: &Entry) -> String {
if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }
}
/// What a row's target shows.
fn target_label(target: Option<&str>) -> String {
match target {
None => tl!("Choose…").to_string(),
Some("") => tl!("No equivalent").to_string(),
Some(t) => entry(t).map_or_else(|| t.to_string(), shown),
}
}
/// Modifiers as written (`Cmd+Shift`, "" for none) as the menus show them.
fn pretty_mods(mods: &str) -> String {
if mods.is_empty() {
return tl!("None").to_string();
}
let shown = menus::pretty_shortcut(&format!("{mods}+K"));
shown.strip_suffix("+K").or_else(|| shown.strip_suffix('K')).unwrap_or(&shown).to_string()
}
/// Where a code's answer came from, as the Keys section says it.
fn source(c: &Code) -> String {
match c.from.as_str() {
"saved" => tl!("from your earlier imports").to_string(),
"described" => tl!("from the format's public description").to_string(),
"agreement" => crate::i18n::tn(c.votes as u64, "agrees with {n} default shortcut", "agrees with {n} default shortcuts"),
"you" => String::new(),
_ => tl!("not known yet").to_string(),
}
}
/// A file named as a shortcut set another app saves (`.kys`).
pub fn is_kys(path: &str) -> bool {
vectorcraft_engine::cmd::shortcutset::EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(path).as_str())
}
/// A set another app saved as text: a `.kys` file, or text that starts with a slash name.
pub fn is_text_set(path: &str, bytes: &[u8]) -> bool {
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
is_kys(path) || text.trim_ascii_start().starts_with(b"/")
}
/// Open the review of the shortcut set in `bytes`, a file called `name` (a path or a file name) →
/// `{entries, unread}`. An open Keyboard Shortcuts dialog is kept to go back to.
pub fn open(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
// Only the part `shortcutSet.read` takes in.
let head = bytes.get(..vectorcraft_engine::cmd::shortcutset::READ_LIMIT).unwrap_or(bytes);
open_read(app, json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(head) }))
}
/// Open the review of the set `shortcutSet.read` reads with `params`.
fn open_read(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
let read = app.run("shortcutSet.read", params)?;
let review = Review::build(&read, &candidates());
let back = app.ui.dialog.take().filter(|d| d.kind == "shortcuts");
let mut d = Dialog::new(
KIND,
json!({
"name": read["set"],
"start": "defaults",
"remember": true,
"notes": read["unread"],
"truncated": read["truncated"],
super::PARENT: back.and_then(|b| serde_json::to_value(b).ok()),
"__recording": "",
"__query": "",
"__attention": false,
"__keysOpen": review.asks(),
}),
);
review.store(&mut d);
app.ui.dialog = Some(d);
Ok(json!({ "entries": review.rows.len(), "unread": read["unread"].as_array().map_or(0, Vec::len) }))
}
/// `ui.shortcutImportDialog`: the review of the file at `path`, or in `dataBase64` (called `name`),
/// else of a picked one. On the web the browser's file picker opens, and File › Open opens the
/// review of a `.kys`.
pub fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
if let Some(b64) = s("dataBase64") {
return open_read(app, json!({ "name": s("name").unwrap_or_default(), "dataBase64": b64 }));
}
let path = match s("path") {
Some(path) => path,
None if app.services.open_async.is_some() => return crate::io::open_dialog(app).map(|_| Value::Null),
None => {
let pick = FilePick { filters: vec![("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() };
crate::picks::open(app, &pick).ok_or("cancelled")?
}
};
open_read(app, json!({ "path": path }))
}
/// Apply: the ready rows assigned in the file's order over VectorCraft's defaults (or the current
/// shortcuts), and Keyboard Shortcuts shown with the result as its working set; its OK keeps it. With
/// Remember My Choices on, the matches and codes used are kept for later imports (`importMatches.set`).
/// → `{applied, rows}`
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let review = Review::of(d);
let statuses = review.statuses();
let back = super::parent(d);
let mut ov: BTreeMap<String, String> = match (d.str("start") == "current", &back) {
(true, Some(b)) => b.fields.get("overrides").and_then(|v| BTreeMap::deserialize(v).ok()).unwrap_or_default(),
(true, None) => app.ui.shortcut_overrides.clone(),
(false, _) => BTreeMap::new(),
};
let mut applied = 0usize;
for (r, (status, chord)) in review.rows.iter().zip(&statuses) {
if let (Status::Ready, Some(t), Some(c)) = (status, r.target.as_deref(), chord)
&& shortcut_editor::assign(&mut ov, t, Some(c), true).is_ok()
{
applied += 1;
}
}
let remembered = if d.bool("remember") { remember(app, &review, &statuses) } else { Ok(()) };
let name = d.str("name");
let set = Some(name.trim()).filter(|n| !n.is_empty()).unwrap_or(shortcut_editor::CUSTOM).to_string();
app.ui.dialog = back;
if app.ui.dialog.as_ref().is_none_or(|b| b.kind != "shortcuts") {
shortcut_editor::open(app);
}
if let Some(k) = app.ui.dialog.as_mut() {
k.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or_else(|_| json!({})));
k.fields.insert("set".into(), json!(set));
k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
}
if let Err(e) = remembered {
app.status(e);
}
Ok(json!({ "applied": applied, "rows": review.rows.len() }))
}
/// Keep for later imports the targets and codes the user chose, and those of the rows Apply assigns.
fn remember(app: &mut VectorcraftApp, review: &Review, statuses: &[(Status, Option<String>)]) -> Result<(), String> {
let mut matches = vec![];
let (mut used_keys, mut used_mods) = (BTreeSet::new(), BTreeSet::new());
for (r, (status, _)) in review.rows.iter().zip(statuses) {
let ready = *status == Status::Ready;
if let Some(t) = &r.target
&& r.from != "saved"
&& (ready || r.from == "you")
{
let mut m = json!({ "kind": "name", "name": r.name, "target": t });
if let Some(e) = entry(t) {
m["label"] = json!(e.label);
}
matches.push(m);
}
if ready {
used_keys.extend(r.key);
used_mods.extend(r.modifiers);
}
}
for (kind, codes, used) in [("key", &review.keys, &used_keys), ("modifiers", &review.mods, &used_mods)] {
for (code, c) in codes {
if let Some(text) = &c.text
&& (c.from == "you" || (c.from == "agreement" && used.contains(code)))
{
matches.push(json!({ "kind": kind, "name": code.to_string(), "target": text }));
}
}
}
if !matches.is_empty() {
app.run("importMatches.set", json!({ "matches": matches }))?;
}
Ok(())
}
/// The rows Apply leaves out and the parts of the file not read, a line each, after the set's name.
pub fn report(d: &Dialog, review: &Review, statuses: &[(Status, Option<String>)]) -> String {
let line = |n: usize, name: &str, status: &str| {
crate::i18n::fmt(tl!("Line {line}: {name}: {status}"), &[("line", &n.to_string()), ("name", name), ("status", status)])
};
let mut lines = vec![d.str("name")];
for (r, (status, _)) in review.rows.iter().zip(statuses) {
if *status != Status::Ready {
lines.push(line(r.line, &r.name, status.label()));
}
}
for n in d.fields.get("notes").and_then(Value::as_array).into_iter().flatten() {
lines.push(line(n["line"].as_u64().unwrap_or(0) as usize, n["text"].as_str().unwrap_or_default(), tl!("Not read")));
}
lines.join("\n") + "\n"
}
// ---------- dialog ----------
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let mut review = Review::of(&d);
let before = review.clone();
let t = Tokens::get(ctx);
// A key press answers the key code being recorded, before the widgets see it; Escape stops.
if let Ok(code) = d.str("__recording").parse::<i64>() {
let ev = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, .. } if !crate::shortcuts::is_modifier(*key) => Some(*key),
_ => None,
})
});
if let Some(key) = ev {
ctx.input_mut(|i| i.events.clear());
d.fields.insert("__recording".into(), json!(""));
if key != egui::Key::Escape
&& let Some(text) = shortcut_editor::chord_from_event(key, egui::Modifiers::NONE)
&& let Some(c) = review.keys.get_mut(&code)
{
c.text = Some(text);
c.from = "you".into();
}
}
}
let statuses = review.statuses();
// The Keys and Shortcuts lists get the window's height less the rest of the dialog, as the frame
// before drew it.
let id = egui::Id::new("dialog-shortcut-import");
let rest = ctx.data(|m| m.get_temp::<f32>(id.with("rest"))).unwrap_or(0.0);
let (keys, mods) = review.asked();
let keys_open = d.bool("__keysOpen") && (!keys.is_empty() || !mods.is_empty());
let (keys_h, rows_h) = list_heights(ctx.content_rect().height() - rest, keys_open);
let (mut apply, mut cancel, mut copy, mut lists) = (false, false, false, 0.0);
crate::dialogs::modal::show(ctx, tl!("Import Shortcut Set"), id, -20.0, 20, |ui| {
ui.set_width(NAME_W + TARGET_W + CHORD_W + 220.0);
crate::dialogs::modal::heading(ui, tl!("Import Shortcut Set"));
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(tl!(
"Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left."
))
.color(t.text_dim)
.size(11.5),
);
lists = keys_section(ui, &mut d, &mut review, keys_h) + rows_section(ui, &mut d, &mut review, &statuses, rows_h);
ui.add_space(8.0);
ui.horizontal(|ui| {
let current = d.str("start") == "current";
if widgets::radio(ui, tl!("Start from VectorCraft Defaults"), !current, true) {
d.fields.insert("start".into(), json!("defaults"));
}
ui.add_space(12.0);
if widgets::radio(ui, tl!("Add to the Current Shortcuts"), current, true) {
d.fields.insert("start".into(), json!("current"));
}
});
let remember = d.bool("remember");
if widgets::check(ui, tl!("Remember My Choices"), remember, true) {
d.fields.insert("remember".into(), json!(!remember));
}
ui.add_space(4.0);
let ready = statuses.iter().filter(|(s, _)| *s == Status::Ready).count();
let mut summary =
crate::i18n::fmt(tl!("{ready} of {total} ready"), &[("ready", &ready.to_string()), ("total", &review.rows.len().to_string())]);
let notes = d.fields.get("notes").and_then(Value::as_array).map_or(0, Vec::len);
if notes > 0 {
summary.push_str(" · ");
summary.push_str(&crate::i18n::tn(notes as u64, "{n} part of the file wasn't read", "{n} parts of the file weren't read"));
}
if d.bool("truncated") {
summary.push_str(" · ");
summary.push_str(tl!("The file is too large to read in full, so only its first part is listed."));
}
ui.label(egui::RichText::new(summary).color(t.text_dim).size(11.5));
ui.add_space(12.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Copy Report")).clicked() {
copy = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, tl!("Apply")).clicked() {
apply = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
cancel = true;
}
});
});
});
if let Some(window) = ctx.memory(|m| m.area_rect(id)) {
ctx.data_mut(|m| m.insert_temp(id.with("rest"), window.height() - lists));
}
if review != before {
if review.rows.iter().map(|r| &r.target).ne(before.rows.iter().map(|r| &r.target)) {
review.decode();
}
review.store(&mut d);
}
if copy {
app.clipboard_out = Some(report(&d, &review, &review.statuses()));
app.status(tl!("Report copied"));
}
app.ui.dialog = Some(d);
if cancel {
crate::dialogs::cancel(app);
} else if apply && let Err(e) = crate::dialogs::confirm(app) {
app.status(e);
}
}
/// The heights of the Keys list (when `keys_open`) and the Shortcuts list when `room` points of the
/// window are left for them: their tallest, or both shorter by the same share, down to a tenth.
fn list_heights(room: f32, keys_open: bool) -> (f32, f32) {
let tallest = if keys_open { KEYS_LIST_H + ROWS_LIST_H } else { ROWS_LIST_H };
let share = (room / tallest).clamp(0.1, 1.0);
(KEYS_LIST_H * share, ROWS_LIST_H * share)
}
/// The Keys section: the codes to answer or check, each with a field for its key or modifiers, where
/// its answer came from and the first entries that use it, in a list at most `height` points tall.
/// Not shown when there are none. → the list's height (0 while the section is closed).
fn keys_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, height: f32) -> f32 {
let (keys, mods) = review.asked();
if keys.is_empty() && mods.is_empty() {
return 0.0;
}
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
let open = d.bool("__keysOpen");
if widgets::section_toggle(ui, tl!("Keys"), open) {
d.fields.insert("__keysOpen".into(), json!(!open));
}
if !open {
return 0.0;
}
ui.label(
egui::RichText::new(tl!(
"The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet."
))
.color(t.text_dim)
.size(11.5),
);
let recording = d.str("__recording");
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
scrolling_list("shortcut-import-keys", height).auto_shrink([false, true]).show(ui, |ui| {
for code in keys {
let Some(c) = review.keys.get_mut(&code) else { continue };
let waiting = recording == code.to_string();
ui.horizontal(|ui| {
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Key {code}")).color(t.text_dim)));
let label = match (waiting, c.text.as_deref()) {
(true, _) => tl!("Press a key…").to_string(),
(false, None) => "?".to_string(),
(false, Some("")) => tl!("No equivalent").to_string(),
(false, Some(k)) => menus::pretty_shortcut(k),
};
let b = ui
.add_sized([120.0, ROW_H], egui::Button::new(label).selected(waiting))
.on_hover_text(tl!("Click, then press the key this code stands for (Esc cancels)"));
if b.clicked() {
d.fields.insert("__recording".into(), json!(code.to_string()));
}
if widgets::icon_button(ui, "x", tl!("No equivalent"), c.text.as_deref() == Some(""), 18.0).clicked() {
c.text = Some(String::new());
c.from = "you".into();
}
code_notes(ui, c);
});
}
for code in mods {
let Some(c) = review.mods.get_mut(&code) else { continue };
ui.horizontal(|ui| {
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Modifiers {code}")).color(t.text_dim)));
let current = c.text.as_deref().map_or_else(|| "?".to_string(), pretty_mods);
let options: Vec<String> = MODIFIERS.iter().map(|m| pretty_mods(m)).collect();
let names: Vec<&str> = options.iter().map(String::as_str).collect();
if let Some(m) =
widgets::dropdown_names(ui, ("shortcut-import-mods", code), &current, &names, 144.0).and_then(|i| MODIFIERS.get(i))
{
c.text = Some(m.to_string());
c.from = "you".into();
}
code_notes(ui, c);
});
}
})
});
list.inner.inner_rect.height()
}
/// A vertical list that scrolls when its contents are taller than `height` points, and is then
/// `height` points tall, also under egui's 64-point minimum.
fn scrolling_list(id: &str, height: f32) -> egui::ScrollArea {
egui::ScrollArea::vertical().id_salt(id).max_height(height).min_scrolled_height(height)
}
/// A cell `w` points wide with its contents on the left.
fn cell<R>(ui: &mut egui::Ui, w: f32, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.allocate_ui_with_layout(egui::vec2(w, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(w);
add(ui)
})
.inner
}
/// Where code `c`'s answer came from, and the first entries that use it.
fn code_notes(ui: &mut egui::Ui, c: &Code) {
let t = Tokens::get(ui.ctx());
let from = source(c);
if !from.is_empty() {
ui.label(egui::RichText::new(from).color(t.text_dim).size(11.0));
}
let names = c.names.join(", ");
ui.add(egui::Label::new(egui::RichText::new(names).color(t.text_dim).size(11.0).italics()).truncate());
}
/// The rows: a check box, the name as the file writes it, what it stands for in VectorCraft (a
/// searchable picker), the shortcut and the status, in a list `height` points tall. → the list's
/// height.
fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses: &[(Status, Option<String>)], height: f32) -> f32 {
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.horizontal(|ui| {
widgets::section_header(ui, tl!("Shortcuts"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let attention = d.bool("__attention");
if widgets::check(ui, tl!("Needs Attention Only"), attention, true) {
d.fields.insert("__attention".into(), json!(!attention));
}
ui.add_space(8.0);
let mut q = d.str("__query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(180.0)).changed() {
d.fields.insert("__query".into(), json!(q));
}
});
});
let query = d.str("__query").to_lowercase();
let attention = d.bool("__attention");
let visible: Vec<usize> = review
.rows
.iter()
.zip(statuses)
.enumerate()
.filter(|(_, (r, (status, _)))| {
(!attention || status.needs_attention())
&& (query.is_empty() || r.name.to_lowercase().contains(&query) || target_label(r.target.as_deref()).to_lowercase().contains(&query))
})
.map(|(i, _)| i)
.collect();
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
for (label, w) in [(tl!("In the File"), NAME_W), (tl!("In VectorCraft"), TARGET_W), (tl!("Shortcut"), CHORD_W)] {
cell(ui, w, |ui| ui.label(egui::RichText::new(label).color(t.text_dim).size(11.0)));
}
ui.label(egui::RichText::new(tl!("Status")).color(t.text_dim).size(11.0));
});
scrolling_list("shortcut-import-rows", height).auto_shrink([false, false]).show_rows(ui, ROW_H, visible.len(), |ui, range| {
for i in range.filter_map(|v| visible.get(v).copied()) {
let (status, chord) = statuses.get(i).cloned().unwrap_or((Status::NeedsChoice, None));
let Some(r) = review.rows.get_mut(i) else { continue };
ui.horizontal(|ui| {
if widgets::check(ui, "", r.on, true) {
r.on = !r.on;
}
let place = crate::i18n::fmt(tl!("Line {line}"), &[("line", &r.line.to_string())]);
let place = if r.section.is_empty() { place } else { format!("{} · {place}", r.section) };
cell(ui, NAME_W, |ui| ui.add(egui::Label::new(egui::RichText::new(&r.name).color(t.text)).truncate()).on_hover_text(place));
if let Some(target) = target_picker(ui, i, r) {
r.target = Some(target);
r.from = "you".into();
}
let shortcut = chord.as_deref().map_or_else(|| "—".to_string(), menus::pretty_shortcut);
cell(ui, CHORD_W, |ui| ui.label(egui::RichText::new(shortcut).color(t.text)));
let color = match status {
Status::Ready => t.text,
Status::Off | Status::NoEquivalent => t.text_dim,
_ => t.warning,
};
ui.add(egui::Label::new(egui::RichText::new(status.label()).color(color).size(11.5)).truncate());
});
}
})
});
list.inner.inner_rect.height()
}
/// Row `r`'s target picker: "No equivalent" and the row's choices, then Keyboard Shortcuts' entries
/// that match the search. → the target picked ("" for no equivalent).
fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<String> {
let current = target_label(r.target.as_deref());
widgets::combo(ui, ("shortcut-import-target", i), &current, TARGET_W, true, |ui| {
let field = ui.id().with("query");
if ui.memory(|m| m.focused().is_none()) {
ui.memory_mut(|m| m.request_focus(field.with("edit")));
}
let q = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
let mut picked = None;
let mut option = |ui: &mut egui::Ui, key: &str, label: &str| {
if ui.add(egui::Button::selectable(r.target.as_deref() == Some(key), label)).clicked() {
picked = Some(key.to_string());
}
};
option(ui, "", tl!("No equivalent"));
let list: Vec<&Entry> = if q.is_empty() {
for e in r.choices.iter().filter_map(|c| entry(c)) {
option(ui, &e.key, &shown(e));
}
ui.separator();
shortcut_editor::entries().iter().collect()
} else {
shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect()
};
egui::ScrollArea::vertical().max_height(300.0).show_rows(ui, ROW_H, list.len(), |ui, range| {
for e in range.filter_map(|k| list.get(k)) {
option(ui, &e.key, &shown(e));
}
});
if picked.is_some() {
ui.close();
}
picked
})
}
@@ -0,0 +1,467 @@
//! Import Shortcut Set: shortcut sets another app saved as text, reviewed and applied to Keyboard
//! Shortcuts' working set. Every set here is written for the test, with invented names and codes.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::shortcut_import::{Code, KIND, Review, Row, Status, report};
use super::*;
use crate::{io, shortcut_editor, theme};
fn app() -> VectorcraftApp {
VectorcraftApp::new(Session::new(), Default::default())
}
/// One headless frame of the dialog layer, with `events`.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn press(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
}
fn dialog(app: &VectorcraftApp) -> &Dialog {
app.ui.dialog.as_ref().unwrap()
}
fn set(app: &mut VectorcraftApp, key: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
}
fn remember(app: &mut VectorcraftApp, matches: Value) {
app.run("importMatches.set", json!({ "matches": matches })).unwrap();
}
fn row(name: &str, target: &str, key: i64, modifiers: Option<i64>) -> Row {
Row { name: name.into(), target: Some(target.into()), from: "matched".into(), key: Some(key), modifiers, on: true, ..Default::default() }
}
/// A review of `rows` with every code unknown, decoded.
fn decoded(rows: Vec<Row>) -> Review {
let mut r = Review { rows: rows.clone(), ..Default::default() };
for row in rows {
r.keys.insert(row.key.unwrap(), Code::default());
if let Some(m) = row.modifiers {
r.mods.insert(m, Code::default());
}
}
r.decode();
r
}
fn text(codes: &BTreeMap<i64, Code>, code: i64) -> Option<&str> {
codes[&code].text.as_deref()
}
#[test]
fn agreement_decodes_codes_that_defaults_share() {
for (id, default) in [("edit.undo", "Cmd+Z"), ("edit.redo", "Cmd+Shift+Z"), ("edit.copy", "Cmd+C"), ("edit.cut", "Cmd+X"), ("tool:pen", "P")] {
assert_eq!(shortcut_editor::default_of(id), Some(default), "{id}");
}
let r = decoded(vec![
row("Acme Undo", "edit.undo", 901, Some(11)),
row("Acme Redo", "edit.redo", 901, Some(12)),
row("Acme Copy", "edit.copy", 902, Some(11)),
row("Acme Cut", "edit.cut", 903, Some(11)),
row("Acme Pen", "tool:pen", 904, Some(10)),
]);
assert_eq!(text(&r.keys, 901), Some("Z"), "two entries agree");
assert_eq!((r.keys[&901].from.as_str(), r.keys[&901].votes), ("agreement", 2));
assert_eq!(text(&r.keys, 902), None, "one vote isn't enough");
assert_eq!(text(&r.mods, 11), Some("Cmd"));
assert_eq!((text(&r.mods, 12), text(&r.mods, 10)), (None, None));
let statuses = r.statuses();
assert_eq!(statuses[0], (Status::Ready, Some("Cmd+Z".into())));
assert_eq!(statuses[1].0, Status::NeedsKey, "its modifiers aren't known");
assert_eq!(statuses[2].0, Status::NeedsKey);
}
#[test]
fn disagreement_leaves_codes_undecided() {
// Three different keys for one code: nothing is proposed.
let r = decoded(vec![row("A", "edit.undo", 905, None), row("B", "edit.copy", 905, None), row("C", "edit.cut", 905, None)]);
assert_eq!(text(&r.keys, 905), None);
// Four in five agree: proposed. Three in four: not.
let five = ["edit.undo", "edit.redo", "edit.undo", "edit.redo", "edit.copy"];
let r = decoded(five.iter().enumerate().map(|(i, t)| row(&format!("E{i}"), t, 906, None)).collect());
assert_eq!((text(&r.keys, 906), r.keys[&906].votes), (Some("Z"), 4));
let four = ["edit.undo", "edit.redo", "edit.undo", "edit.copy"];
let r = decoded(four.iter().enumerate().map(|(i, t)| row(&format!("E{i}"), t, 907, None)).collect());
assert_eq!(text(&r.keys, 907), None);
// Rows without a target, or with no equivalent, don't vote; an answer that isn't agreement stays.
let mut r = decoded(vec![row("A", "edit.undo", 908, None), row("B", "", 908, None)]);
assert_eq!(text(&r.keys, 908), None);
r.keys.insert(908, Code { text: Some("Q".into()), from: "you".into(), ..Default::default() });
r.rows.push(row("C", "edit.redo", 908, None));
r.decode();
assert_eq!(text(&r.keys, 908), Some("Q"));
}
#[test]
fn a_key_with_no_equivalent_needs_no_modifiers() {
let mut r = decoded(vec![row("A", "edit.undo", 940, Some(41))]);
assert_eq!((text(&r.keys, 940), text(&r.mods, 41)), (None, None));
r.keys.insert(940, Code { text: Some(String::new()), from: "you".into(), ..Default::default() });
assert_eq!(r.statuses()[0], (Status::NoEquivalent, None), "the unknown modifiers don't matter");
}
const SET: &str = "/Menus {\n /Edit {\n /Acme\\ Undo { /Context 0 /Modifiers 11 /Represent 0 /Key 902 }\n /Acme\\ Copy { /Modifiers 11 /Key 902 }\n }\n /Acme\\ Pen { /Modifiers 11 /Key 901 }\n /Acme\\ Gadget { /Modifiers 0 /Key 48 }\n}\n";
/// The answers an earlier import left for [`SET`].
fn earlier_answers(app: &mut VectorcraftApp) {
remember(
app,
json!([
{"kind": "name", "name": "Acme Undo", "target": "edit.undo"},
{"kind": "name", "name": "Acme Copy", "target": "edit.copy"},
{"kind": "name", "name": "Acme Pen", "target": "tool:pen"},
{"kind": "name", "name": "Acme Gadget", "target": ""},
{"kind": "key", "name": "901", "target": "K"},
{"kind": "key", "name": "902", "target": "J"},
{"kind": "modifiers", "name": "11", "target": "Cmd"},
]),
);
}
#[test]
fn kys_files_open_the_review() {
let mut app = app();
earlier_answers(&mut app);
io::open_bytes(&mut app, "Acme Keys.kys", SET.as_bytes(), None).unwrap();
let d = dialog(&app);
assert_eq!((d.kind.as_str(), d.str("name"), d.str("start"), d.bool("remember")), (KIND, "Acme Keys".into(), "defaults".into(), true));
let r = Review::of(d);
let names: Vec<(&str, &str, Option<&str>)> = r.rows.iter().map(|r| (r.name.as_str(), r.section.as_str(), r.target.as_deref())).collect();
assert_eq!(
names,
[
("Acme Undo", "Menus › Edit", Some("edit.undo")),
("Acme Copy", "Menus › Edit", Some("edit.copy")),
("Acme Pen", "Menus", Some("tool:pen")),
("Acme Gadget", "Menus", Some(""))
]
);
assert!(r.rows.iter().all(|r| r.from == "saved"));
assert_eq!((text(&r.keys, 48), r.keys[&48].from.as_str()), (Some("0"), "described"), "the format's description");
assert_eq!((text(&r.mods, 0), r.mods[&0].from.as_str()), (Some(""), "described"));
let statuses: Vec<Status> = r.statuses().into_iter().map(|s| s.0).collect();
assert_eq!(statuses, [Status::SameShortcut, Status::Ready, Status::ToolKey, Status::NoEquivalent]);
frame(&mut app, vec![]);
assert_eq!(dialog(&app).kind, KIND);
// A file in another form says so, and opens nothing.
app.ui.dialog = None;
let e = io::open_bytes(&mut app, "Other.kys", b"<?xml version=\"1.0\"?><shortcuts/>", None).unwrap_err();
assert_eq!(e, vectorcraft_engine::cmd::shortcutset::NOT_READABLE);
assert!(app.ui.dialog.is_none());
}
#[test]
fn kys_rows_go_through_assign() {
let mut app = app();
earlier_answers(&mut app);
io::open_bytes(&mut app, "Acme Keys.kys", SET.as_bytes(), None).unwrap();
let d = dialog(&app).clone();
assert_eq!(confirm(&mut app).unwrap(), json!({"applied": 1, "rows": 4}));
// Keyboard Shortcuts shows the set: the later row took ⌘J from Join's default, and the tool kept P.
assert_eq!(shortcut_editor::default_of("path.join"), Some("Cmd+J"));
let k = dialog(&app);
assert_eq!((k.kind.as_str(), k.str("set")), ("shortcuts", "Acme Keys".into()));
let ov: BTreeMap<String, String> = serde_json::from_value(k.fields["overrides"].clone()).unwrap();
assert_eq!(ov, BTreeMap::from([("edit.copy".to_string(), "Cmd+J".to_string()), ("path.join".to_string(), String::new())]));
assert_eq!(k.str("__message"), "Imported. Press OK to keep the imported set.");
assert!(app.ui.shortcut_overrides.is_empty(), "nothing is kept before OK");
// The rows left out are reported, with their line.
let r = Review::of(&d);
let text = report(&d, &r, &r.statuses());
assert!(text.starts_with("Acme Keys\nLine 3: Acme Undo: A later row takes this shortcut\n"), "{text}");
assert!(text.contains("Line 6: Acme Pen: Tools take a single key (optionally with Shift)\n") && !text.contains("Acme Copy"), "{text}");
}
#[test]
fn rows_for_the_same_item_leave_the_earlier_out() {
let mut app = app();
earlier_answers(&mut app);
remember(&mut app, json!([{"kind": "name", "name": "Acme Copy Again", "target": "edit.copy"}]));
let kys = "/Menus {\n /Acme\\ Copy { /Modifiers 11 /Key 902 }\n /Acme\\ Copy\\ Again { /Modifiers 11 /Key 901 }\n}\n";
io::open_bytes(&mut app, "Twice.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::SameTarget, Status::Ready]);
assert_eq!(confirm(&mut app).unwrap()["applied"], 1);
let ov = &dialog(&app).fields["overrides"];
assert_eq!(ov["edit.copy"], "Cmd+K");
assert!(ov.get("path.join").is_none(), "Join keeps ⌘J: the earlier row isn't applied");
}
#[test]
fn cancel_and_apply_go_back_to_keyboard_shortcuts() {
let mut app = app();
earlier_answers(&mut app);
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
assert_eq!(dialog(&app).kind, KIND);
cancel(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
// Escape goes back the same way, and closes Keyboard Shortcuts after that.
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
let escape = |app: &mut VectorcraftApp| {
let ctx = egui::Context::default();
let mut out =
ctx.run_ui(egui::RawInput { events: vec![press(egui::Key::Escape)], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
out.textures_delta.clear();
};
escape(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
escape(&mut app);
assert!(app.ui.dialog.is_none());
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
// Added to the current shortcuts: the working set keeps its own change.
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
set(&mut app, "start", json!("current"));
confirm(&mut app).unwrap();
assert_eq!(dialog(&app).fields["overrides"], json!({"edit.copy": "Cmd+J", "edit.preferences": "Cmd+Alt+Shift+9", "path.join": ""}));
// OK keeps it.
confirm(&mut app).unwrap();
assert_eq!((app.ui.shortcut_set.as_str(), app.ui.shortcut_overrides.len()), ("Acme Keys", 3));
}
/// A document's Missing Fonts dialog that Keyboard Shortcuts replaced waits while Cancel goes back
/// from Import Shortcut Set to Keyboard Shortcuts, and opens again once Keyboard Shortcuts closes.
#[test]
fn cancel_goes_back_to_keyboard_shortcuts_before_a_waiting_missing_fonts_dialog() {
let mut app = app();
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font Family"})).unwrap();
app.run("ui.missingFontsDialog", json!({})).unwrap();
assert_eq!(dialog(&app).kind, missing_fonts::KIND);
shortcut_editor::open(&mut app);
set(&mut app, "overrides", json!({"edit.preferences": "Cmd+Alt+Shift+9"}));
super::shortcut_import::open(&mut app, "/sets/Acme Keys.kys", SET.as_bytes()).unwrap();
cancel(&mut app);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).fields["overrides"].clone()), ("shortcuts", json!({"edit.preferences": "Cmd+Alt+Shift+9"})));
cancel(&mut app);
assert_eq!(dialog(&app).kind, missing_fonts::KIND);
assert_eq!(dialog(&app).fields["fonts"][0]["family"], "No Such Font Family");
}
/// A set dropped on the window opens the review when no document is open, from its path (desktop)
/// or from its bytes (web).
#[test]
fn a_set_dropped_with_no_document_open_opens_the_review() {
let mut app = app();
let dir = vectorcraft_testkit::temp_dir("shortcut-import-drop");
let kys = dir.join("Dropped.kys");
std::fs::write(&kys, SET).unwrap();
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
let target = app.drop_target("Dropped.kys", None, false);
assert_eq!(target, crate::place::DropTarget::Open);
crate::place::drop_files(&mut app, vec![(target, ("Dropped.kys".into(), Some(kys.to_string_lossy().into_owned()), vec![]))]);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name"), Review::of(dialog(&app)).rows.len()), (KIND, "Dropped".into(), 4));
app.ui.dialog = None;
app.services.read = None;
crate::place::drop_files(&mut app, vec![(target, ("Sent.kys".into(), None, SET.as_bytes().to_vec()))]);
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name")), (KIND, "Sent".into()));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn remembered_choices_answer_the_next_import() {
let mut app = app();
let kys = "/Menus {\n /Acme\\ Undo { /Modifiers 21 /Key 931 }\n /Acme\\ Thing { /Modifiers 21 /Key 932 }\n}\n";
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert_eq!(r.rows[0].target.as_deref(), Some("edit.undo"), "proposed from the name");
assert_eq!(r.rows[0].from, "matched");
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::NeedsKey, Status::NeedsChoice]);
assert!(dialog(&app).bool("__keysOpen"), "codes to answer open the Keys section");
// The user answers the codes and picks the second row's target.
let mut r = r;
r.keys.insert(931, Code { text: Some("Z".into()), from: "you".into(), ..r.keys[&931].clone() });
r.mods.insert(21, Code { text: Some("Cmd+Alt".into()), from: "you".into(), ..r.mods[&21].clone() });
r.rows[1].target = Some(String::new());
r.rows[1].from = "you".into();
set(&mut app, "rows", serde_json::to_value(&r.rows).unwrap());
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
set(&mut app, "mods", serde_json::to_value(&r.mods).unwrap());
confirm(&mut app).unwrap();
let kept = app.run("importMatches.list", json!({})).unwrap();
let kept: Vec<(&str, &str, &str)> =
kept.as_array().unwrap().iter().map(|m| (m["kind"].as_str().unwrap(), m["name"].as_str().unwrap(), m["target"].as_str().unwrap())).collect();
assert_eq!(kept, [("name", "Acme Undo", "edit.undo"), ("name", "Acme Thing", ""), ("key", "931", "Z"), ("modifiers", "21", "Cmd+Alt")]);
assert_eq!(app.run("importMatches.list", json!({"kind": "name"})).unwrap()[0]["label"], "Undo");
// The next import asks nothing.
app.ui.dialog = None;
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
let r = Review::of(dialog(&app));
assert!(r.rows.iter().all(|r| r.from == "saved"));
assert_eq!(r.statuses().iter().map(|s| s.0).collect::<Vec<_>>(), [Status::Ready, Status::NoEquivalent]);
assert_eq!(r.statuses()[0].1.as_deref(), Some("Cmd+Alt+Z"));
// The saved codes stay listed, in a Keys section that starts closed, and can be answered again.
assert!(!dialog(&app).bool("__keysOpen"));
set(&mut app, "__keysOpen", json!(true));
frame(&mut app, vec![]);
let mut r = r;
r.keys.insert(931, Code { text: Some("Y".into()), from: "you".into(), ..r.keys[&931].clone() });
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
confirm(&mut app).unwrap();
assert_eq!(importmatches_target(&app, "key", "931"), Some("Y".to_string()));
// Without Remember My Choices nothing more is kept.
app.ui.dialog = None;
io::open_bytes(&mut app, "Fresh.kys", kys.as_bytes(), None).unwrap();
set(&mut app, "remember", json!(false));
let before = app.session.prefs.import_matches.clone();
let mut r = Review::of(dialog(&app));
r.keys.insert(931, Code { text: Some("W".into()), from: "you".into(), ..r.keys[&931].clone() });
set(&mut app, "keys", serde_json::to_value(&r.keys).unwrap());
confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.import_matches, before);
}
/// What the import matches say `name` of `kind` stands for.
fn importmatches_target(app: &VectorcraftApp, kind: &str, name: &str) -> Option<String> {
vectorcraft_engine::cmd::importmatches::targets(&app.session.prefs).get(&(kind, name)).map(|t| t.to_string())
}
#[test]
fn a_key_press_answers_the_code_being_recorded() {
let mut app = app();
let kys = "/Menus {\n /Acme\\ Undo { /Modifiers 21 /Key 931 }\n /Acme\\ Redo { /Modifiers 21 /Key 931 }\n}\n";
io::open_bytes(&mut app, "Keys.kys", kys.as_bytes(), None).unwrap();
frame(&mut app, vec![]);
set(&mut app, "__recording", json!("931"));
assert!(shortcut_editor::is_recording(&app), "Escape stops the recording, not the dialog");
// A modifier's own key press waits for the key.
frame(&mut app, vec![press(egui::Key::ShiftLeft)]);
assert_eq!(dialog(&app).str("__recording"), "931");
frame(&mut app, vec![press(egui::Key::K)]);
let r = Review::of(dialog(&app));
assert_eq!((text(&r.keys, 931), r.keys[&931].from.as_str(), dialog(&app).str("__recording")), (Some("K"), "you", String::new()));
// Escape stops recording and keeps the answer.
set(&mut app, "__recording", json!("931"));
frame(&mut app, vec![press(egui::Key::Escape)]);
assert_eq!((text(&Review::of(dialog(&app)).keys, 931), dialog(&app).str("__recording")), (Some("K"), String::new()));
}
#[test]
fn keyboard_shortcuts_import_reads_both_kinds_of_set() {
let mut app = app();
let dir = vectorcraft_testkit::temp_dir("shortcut-import-path");
let kys = dir.join("On Disk.kys");
std::fs::write(&kys, SET).unwrap();
let kys = kys.to_string_lossy().to_string();
let files: BTreeMap<&str, Vec<u8>> = BTreeMap::from([
(
"/sets/Mine.json",
serde_json::to_vec(&shortcut_editor::export_json("Mine", &BTreeMap::from([("tool:pen".into(), "Shift+9".into())]))).unwrap(),
),
("/sets/Text.json", b"\xEF\xBB\xBF /Menus { /Acme\\ Undo { /Key 1 } }".to_vec()),
]);
let reads = std::rc::Rc::new(std::cell::Cell::new(0));
let counted = reads.clone();
app.services.read = Some(Box::new(move |p| {
counted.set(counted.get() + 1);
files.get(p).cloned().ok_or_else(|| format!("no file {p}"))
}));
app.run("shortcuts.import", json!({"path": "/sets/Mine.json"})).unwrap();
assert_eq!(app.ui.shortcut_set, "Mine");
// A `.kys` file is read by `shortcutSet.read`, up to its cap, and not through the app's file reader.
let before = reads.get();
app.run("shortcuts.import", json!({ "path": kys })).unwrap();
assert_eq!((dialog(&app).kind.as_str(), dialog(&app).str("name"), reads.get()), (KIND, "On Disk".into(), before));
app.run("shortcuts.import", json!({"path": "/sets/Text.json"})).unwrap();
assert_eq!(dialog(&app).str("name"), "Text", "text that starts with a slash name is a text set");
// File › Open and the command open it from a path, the command also from its bytes.
app.ui.dialog = None;
let before = reads.get();
app.run("file.open", json!({ "path": kys })).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), reads.get()), (4, before));
assert_eq!(app.ui.recent_files.first(), Some(&kys), "File › Open lists it in Open Recent Files");
app.ui.dialog = None;
app.run("ui.shortcutImportDialog", json!({ "path": kys })).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), dialog(&app).str("name")), (4, "On Disk".into()));
let b64 = vectorcraft_format::base64_encode(SET.as_bytes());
let r = app.run("ui.shortcutImportDialog", json!({"dataBase64": b64, "name": "Sent.kys"})).unwrap();
assert_eq!((r["entries"].as_u64(), dialog(&app).str("name")), (Some(4), "Sent".into()));
// A file past the reader's cap opens with what fits, marked truncated.
let big = format!("/Menus {{ /Acme\\ Undo {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
io::open_bytes(&mut app, "Big.kys", big.as_bytes(), None).unwrap();
assert_eq!((Review::of(dialog(&app)).rows.len(), dialog(&app).bool("truncated")), (1, true));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn many_rows_draw_and_filter() {
let mut app = app();
let body: String = (0..2000).map(|i| format!(" /Acme\\ Entry\\ {i} {{ /Modifiers {} /Key {} }}\n", i % 7, 1000 + i % 90)).collect();
io::open_bytes(&mut app, "Big.kys", format!("/Menus {{\n{body}}}\n").as_bytes(), None).unwrap();
frame(&mut app, vec![]);
set(&mut app, "__attention", json!(true));
set(&mut app, "__query", json!("entry 19"));
frame(&mut app, vec![]);
assert_eq!(Review::of(dialog(&app)).rows.len(), 2000);
// Notes on the parts not read are kept for the report.
app.ui.dialog = None;
io::open_bytes(&mut app, "Odd.kys", b"/Menus { /Acme\\ Undo { /Key 1 /Bogus 2 } junk }", None).unwrap();
let d = dialog(&app).clone();
let r = Review::of(&d);
let text = report(&d, &r, &r.statuses());
assert!(text.contains("Line 1: /Bogus 2: Not read\n") && text.contains("Line 1: junk: Not read\n"), "{text}");
frame(&mut app, vec![]);
}
/// The texts the sixth frame of the dialog layer painted on `screen`, each with its rectangle and
/// the rectangle it was clipped to.
fn painted_on(app: &mut VectorcraftApp, screen: egui::Rect) -> Vec<(String, egui::Rect, egui::Rect)> {
fn collect(s: &egui::Shape, clip: egui::Rect, v: &mut Vec<(String, egui::Rect, egui::Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), t.visual_bounding_rect(), clip)),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, v)),
_ => {}
}
}
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut texts = vec![];
for _ in 0..6 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
texts.clear();
for c in &out.shapes {
collect(&c.shape, c.clip_rect, &mut texts);
}
}
texts
}
/// On the app's smallest window (800 × 500 points), a large set shows the heading, Copy Report,
/// Cancel and Apply, with the Keys section open or closed: its lists get shorter.
#[test]
fn a_large_set_keeps_its_buttons_on_a_small_window() {
let mut app = app();
let body: String = (0..400).map(|i| format!(" /Acme\\ Entry\\ {i} {{ /Modifiers {} /Key {} }}\n", i % 7, 1000 + i % 90)).collect();
io::open_bytes(&mut app, "Big.kys", format!("/Menus {{\n{body}}}\n").as_bytes(), None).unwrap();
// Every row has a target, so the Keys section lists the set's 97 codes.
let mut r = Review::of(dialog(&app));
for row in &mut r.rows {
row.target = Some("edit.undo".into());
}
set(&mut app, "rows", serde_json::to_value(&r.rows).unwrap());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0));
for open in [true, false] {
set(&mut app, "__keysOpen", json!(open));
let texts = painted_on(&mut app, screen);
for label in ["Import Shortcut Set", "Copy Report", "Cancel", "Apply"] {
let (_, rect, clip) = texts.iter().find(|(t, ..)| t == label).unwrap_or_else(|| panic!("Keys open: {open}: no {label}"));
assert!(screen.contains_rect(*rect) && clip.contains_rect(*rect), "Keys open: {open}: {label} at {rect:?}, clipped to {clip:?}");
}
}
}
+38
View File
@@ -113,6 +113,44 @@
Color Settings… Nastavení barev…
Assign Profile… Přiřadit profil…
Keyboard Shortcuts… Klávesové zkratky…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importovat sadu klávesových zkratek
Import Shortcut Set… Importovat sadu klávesových zkratek…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Každá položka souboru je přiřazena k nástroji, příkazu nebo panelu VectorCraftu. Zkontrolujte přiřazení a doplňte, co zbývá.
Keys Klávesy
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Soubor ukládá klávesy a modifikátory jako kódy. Zkontrolujte navržené významy a doplňte ty, které zatím nejsou známé.
Click, then press the key this code stands for (Esc cancels) Klikněte a stiskněte klávesu, kterou tento kód označuje (Esc zruší)
Press a key… Stiskněte klávesu…
from your earlier imports z vašich dřívějších importů
from the format's public description z veřejného popisu formátu
not known yet zatím není známo
Shortcuts Zkratky
In the File V souboru
In VectorCraft Ve VectorCraftu
Needs Attention Only Jen řádky vyžadující pozornost
Ready Připraveno
Needs a choice Vyžaduje volbu
Needs a key Vyžaduje klávesu
No equivalent Bez ekvivalentu
Tools take a single key (optionally with Shift) Nástroje používají jedinou klávesu (volitelně se Shift)
A later row takes this shortcut Tuto zkratku přebírá pozdější řádek
Same item as a later row Stejná položka jako v pozdějším řádku
Start from VectorCraft Defaults Vycházet z výchozích zkratek VectorCraftu
Add to the Current Shortcuts Přidat k aktuálním zkratkám
Remember My Choices Zapamatovat si moje volby
{ready} of {total} ready Připraveno {ready} z {total}
The file is too large to read in full, so only its first part is listed. Soubor je příliš velký na úplné přečtení, proto je uvedena jen jeho první část.
Report copied Zpráva zkopírována
Line {line} Řádek {line}
Line {line}: {name}: {status} Řádek {line}: {name}: {status}
Read Shortcut Set File Přečíst soubor sady klávesových zkratek
Import Matches Přiřazení z importů
Remember Import Matches Zapamatovat přiřazení z importů
Forget Import Matches Zapomenout přiřazení z importů
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts shoduje se s {n} výchozí zkratkou|shoduje se s {n} výchozími zkratkami|shoduje se s {n} výchozími zkratkami
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} část souboru nebyla přečtena|{n} části souboru nebyly přečteny|{n} částí souboru nebylo přečteno
@msg This shortcut file is in a form VectorCraft can't read. Tento soubor klávesových zkratek je ve formátu, který VectorCraft neumí přečíst.
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace nejde o sadu klávesových zkratek: text nezačíná názvem s lomítkem a otevírací složenou závorkou
Preferences… Předvolby…
Object Objekt
Transform Transformace
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1379,6 +1379,44 @@
Keyboard Shortcut Conflicts Konflikte bei Tastaturbefehlen
Keyboard Shortcut Set Tastaturbefehlssatz
Keyboard Shortcuts… Tastaturbefehle…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Tastaturbefehlssatz importieren
Import Shortcut Set… Tastaturbefehlssatz importieren…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Jeder Eintrag der Datei wird einem Werkzeug, einem Befehl oder einem Bedienfeld von VectorCraft zugeordnet. Prüfen Sie die Zuordnungen und beantworten Sie, was noch offen ist.
Keys Tasten
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Die Datei speichert Tasten und Sondertasten als Codes. Prüfen Sie die vorgeschlagenen Bedeutungen und beantworten Sie die Codes, die noch nicht bekannt sind.
Click, then press the key this code stands for (Esc cancels) Klicken Sie und drücken Sie dann die Taste, für die dieser Code steht (Esc bricht ab)
Press a key… Taste drücken…
from your earlier imports aus Ihren früheren Importen
from the format's public description aus der öffentlichen Beschreibung des Formats
not known yet noch nicht bekannt
Shortcuts Tastaturbefehle
Shortcut Tastaturbefehl
In the File In der Datei
In VectorCraft In VectorCraft
Needs Attention Only Nur Einträge mit Handlungsbedarf
Ready Bereit
Needs a choice Auswahl erforderlich
Needs a key Taste erforderlich
No equivalent Keine Entsprechung
Tools take a single key (optionally with Shift) Werkzeuge verwenden eine einzelne Taste (optional mit Shift)
A later row takes this shortcut Eine spätere Zeile übernimmt diesen Tastaturbefehl
Same item as a later row Gleiches Element wie eine spätere Zeile
Start from VectorCraft Defaults Von den VectorCraft-Standardwerten ausgehen
Add to the Current Shortcuts Zu den aktuellen Tastaturbefehlen hinzufügen
Remember My Choices Meine Auswahl merken
{ready} of {total} ready {ready} von {total} bereit
The file is too large to read in full, so only its first part is listed. Die Datei ist zu groß, um sie vollständig zu lesen. Daher wird nur ihr erster Teil aufgeführt.
Report copied Bericht kopiert
Line {line} Zeile {line}
Line {line}: {name}: {status} Zeile {line}: {name}: {status}
Imported. Press OK to keep the imported set. Importiert. Klicken Sie auf „OK“, um den importierten Satz zu behalten.
Read Shortcut Set File Tastaturbefehlssatz-Datei lesen
Import Matches Import-Zuordnungen
Remember Import Matches Import-Zuordnungen merken
Forget Import Matches Import-Zuordnungen vergessen
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts stimmt mit {n} Standard-Tastaturbefehl überein|stimmt mit {n} Standard-Tastaturbefehlen überein
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} Teil der Datei wurde nicht gelesen|{n} Teile der Datei wurden nicht gelesen
Keywords: Stichwörter:
Knife Messer
Knockout Group Aussparungsgruppe
@@ -3426,6 +3464,7 @@ theme Appearance Erscheinungsbild
@msg Split Into Grid: select paths large enough for the grid In Raster teilen: Wählen Sie Pfade aus, die groß genug für das Raster sind
@msg Targa files are RGB: colorModel rgb Targa-Dateien sind RGB: colorModel rgb
@msg Text Wrap: select a wrap object Textumfluss: Wählen Sie ein Umflussobjekt aus
@msg This shortcut file is in a form VectorCraft can't read. Diese Tastaturbefehlsdatei liegt in einem Format vor, das VectorCraft nicht lesen kann.
@msg `aspect` must be a number (%) `aspect` muss eine Zahl sein (%)
@msg `at` must be [x, y] `at` muss [x, y] sein
@msg `color` must be a colour `color` muss eine Farbe sein
@@ -3660,6 +3699,7 @@ theme Appearance Erscheinungsbild
@msg not a VectorCraft file: {0} keine VectorCraft-Datei: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) kein VectorCraft-Tastaturbefehlssatz (`overrides` fehlt)
@msg not a graphic style library (format `{_1}`) keine Grafikstil-Bibliothek (Format `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace kein Tastaturbefehlssatz: Der Text beginnt nicht mit einem Namen mit Schrägstrich und einer öffnenden geschweiften Klammer
@msg not a stored central record kein gespeicherter zentraler Eintrag
@msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0}
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -1397,6 +1397,42 @@
Keyboard Shortcut Conflicts Conflictos de métodos abreviados de teclado
Keyboard Shortcut Set Conjunto de métodos abreviados de teclado
Keyboard Shortcuts… Métodos abreviados de teclado…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importar conjunto de métodos abreviados
Import Shortcut Set… Importar conjunto de métodos abreviados…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Cada entrada del archivo se asocia con una herramienta, un comando o un panel de VectorCraft. Revisa las asociaciones y responde lo que falta.
Keys Teclas
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. El archivo guarda las teclas y los modificadores como códigos. Revisa los significados propuestos y responde los códigos que aún no se conocen.
Click, then press the key this code stands for (Esc cancels) Haz clic y luego presiona la tecla que representa este código (Esc cancela)
Press a key… Presiona una tecla…
from your earlier imports de tus importaciones anteriores
from the format's public description según la descripción pública del formato
not known yet aún desconocido
Shortcuts Métodos abreviados
In the File En el archivo
In VectorCraft En VectorCraft
Needs Attention Only Solo lo que requiere atención
Ready Listo
Needs a choice Requiere una elección
Needs a key Requiere una tecla
No equivalent Sin equivalente
Tools take a single key (optionally with Shift) Las herramientas usan una sola tecla (opcionalmente con Shift)
A later row takes this shortcut Una fila posterior usa este método abreviado
Same item as a later row Mismo elemento que una fila posterior
Start from VectorCraft Defaults Partir de los valores predeterminados de VectorCraft
Add to the Current Shortcuts Agregar a los métodos abreviados actuales
Remember My Choices Recordar mis elecciones
{ready} of {total} ready {ready} de {total} listos
The file is too large to read in full, so only its first part is listed. El archivo es demasiado grande para leerlo completo, así que solo se muestra su primera parte.
Report copied Informe copiado
Line {line} Línea {line}
Line {line}: {name}: {status} Línea {line}: {name}: {status}
Read Shortcut Set File Leer archivo de conjunto de métodos abreviados
Import Matches Asociaciones de importación
Remember Import Matches Recordar asociaciones de importación
Forget Import Matches Olvidar asociaciones de importación
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts coincide con {n} método abreviado predeterminado|coincide con {n} métodos abreviados predeterminados
@plural {n} part of the file wasn't read|{n} parts of the file weren't read no se leyó {n} parte del archivo|no se leyeron {n} partes del archivo
Keywords: Palabras clave:
Knife Cuchilla
Knockout Group Grupo de exclusión
@@ -3526,6 +3562,7 @@ axis Both Ambos
@msg Split Into Grid: select paths large enough for the grid Dividir en cuadrícula: selecciona trazados lo bastante grandes para la cuadrícula
@msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb
@msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir
@msg This shortcut file is in a form VectorCraft can't read. Este archivo de métodos abreviados tiene un formato que VectorCraft no puede leer.
@msg `aspect` must be a number (%) `aspect` debe ser un número (%)
@msg `at` must be [x, y] `at` debe ser [x, y]
@msg `color` must be a colour `color` debe ser un color
@@ -3759,6 +3796,7 @@ axis Both Ambos
@msg not a VectorCraft file: {0} no es un archivo de VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) no es un conjunto de atajos de VectorCraft (falta `overrides`)
@msg not a graphic style library (format `{_1}`) no es una biblioteca de estilos gráficos (formato `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace no es un conjunto de métodos abreviados: el texto no empieza con un nombre con barra y una llave de apertura
@msg not a stored central record no es un registro central almacenado
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
@msg not an EMF or WMF file no es un archivo EMF ni WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -1396,6 +1396,42 @@
Keyboard Shortcut Conflicts Conflits de raccourcis clavier
Keyboard Shortcut Set Jeu de raccourcis clavier
Keyboard Shortcuts… Raccourcis clavier…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importer un jeu de raccourcis
Import Shortcut Set… Importer un jeu de raccourcis…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Chaque entrée du fichier est mise en correspondance avec un outil, une commande ou un panneau de VectorCraft. Vérifiez les correspondances et répondez à ce qui reste.
Keys Touches
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Le fichier enregistre les touches et les modificateurs sous forme de codes. Vérifiez les significations proposées et indiquez les codes encore inconnus.
Click, then press the key this code stands for (Esc cancels) Cliquez, puis appuyez sur la touche que ce code représente (Échap pour annuler)
Press a key… Appuyez sur une touche…
from your earlier imports d’après vos importations précédentes
from the format's public description d’après la description publique du format
not known yet encore inconnu
Shortcuts Raccourcis
In the File Dans le fichier
In VectorCraft Dans VectorCraft
Needs Attention Only À vérifier uniquement
Ready Prêt
Needs a choice Choix nécessaire
Needs a key Touche nécessaire
No equivalent Aucun équivalent
Tools take a single key (optionally with Shift) Les outils prennent une seule touche (éventuellement avec Maj)
A later row takes this shortcut Une ligne suivante prend ce raccourci
Same item as a later row Même élément qu’une ligne suivante
Start from VectorCraft Defaults Partir des valeurs par défaut de VectorCraft
Add to the Current Shortcuts Ajouter aux raccourcis actuels
Remember My Choices Mémoriser mes choix
{ready} of {total} ready {ready} sur {total} prêts
The file is too large to read in full, so only its first part is listed. Le fichier est trop volumineux pour être lu en entier, seule sa première partie est donc listée.
Report copied Rapport copié
Line {line} Ligne {line}
Line {line}: {name}: {status} Ligne {line}, {name}, {status}
Read Shortcut Set File Lire un fichier de jeu de raccourcis
Import Matches Correspondances d’importation
Remember Import Matches Mémoriser les correspondances d’importation
Forget Import Matches Oublier les correspondances d’importation
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts concorde avec {n} raccourci par défaut|concorde avec {n} raccourcis par défaut
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} partie du fichier n’a pas été lue|{n} parties du fichier n’ont pas été lues
Keywords: Mots-clés :
Knife Cutter
Knockout Group Groupe perçant
@@ -3523,6 +3559,7 @@ axis Both Les deux
@msg Split Into Grid: select paths large enough for the grid Découper en grille : sélectionnez des tracés assez grands pour la grille
@msg Targa files are RGB: colorModel rgb Les fichiers Targa sont en RVB : colorModel rgb
@msg Text Wrap: select a wrap object Habillage de texte : sélectionnez un objet d’habillage
@msg This shortcut file is in a form VectorCraft can't read. Ce fichier de raccourcis est dans un format que VectorCraft ne sait pas lire.
@msg `aspect` must be a number (%) `aspect` doit être un nombre (%)
@msg `at` must be [x, y] `at` doit être [x, y]
@msg `color` must be a colour `color` doit être une couleur
@@ -3756,6 +3793,7 @@ axis Both Les deux
@msg not a VectorCraft file: {0} ce n’est pas un fichier VectorCraft : {0}
@msg not a VectorCraft shortcut set (missing `overrides`) ce n’est pas un jeu de raccourcis VectorCraft (`overrides` manquant)
@msg not a graphic style library (format `{_1}`) ce n’est pas une bibliothèque de styles graphiques (format `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace ce n’est pas un jeu de raccourcis, car le texte ne commence pas par un nom précédé d’une barre oblique et une accolade ouvrante
@msg not a stored central record ce n’est pas un enregistrement central stocké
@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0}
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -1784,6 +1784,44 @@
Keyboard Shortcut Set Billentyűparancs-készlet
Keyboard Shortcuts Billentyűparancsok
Keyboard Shortcuts… Billentyűparancsok…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Billentyűparancs-készlet importálása
Import Shortcut Set… Billentyűparancs-készlet importálása…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. A VectorCraft a fájl minden bejegyzését megfelelteti valamelyik eszközének, parancsának vagy paneljének. Ellenőrizd a megfeleltetéseket, és válaszolj a még nyitott kérdésekre.
Keys Billentyűk
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. A fájl a billentyűket és a módosítóbillentyűket kódként tárolja. Ellenőrizd a javasolt jelentéseket, és add meg a még ismeretlen kódok jelentését.
Click, then press the key this code stands for (Esc cancels) Kattints, majd nyomd le azt a billentyűt, amelyet ez a kód jelöl (Esc: mégse)
Press a key… Nyomj le egy billentyűt…
from your earlier imports a korábbi importálásaidból
from the format's public description a formátum nyilvános leírásából
not known yet még ismeretlen
Shortcuts Billentyűparancsok
In the File A fájlban
In VectorCraft A VectorCraftban
Needs Attention Only Csak a figyelmet igénylő sorok
Ready Kész
Needs a choice Választás szükséges
Needs a key Billentyű szükséges
No equivalent Nincs megfelelője
Tools take a single key (optionally with Shift) Eszközhöz egyetlen billentyű rendelhető (Shifttel vagy anélkül)
A later row takes this shortcut Ezt a billentyűparancsot egy későbbi sor kapja meg
Same item as a later row Ugyanaz az elem, mint egy későbbi sorban
Start from VectorCraft Defaults Kiindulás a VectorCraft-alapértékekből
Add to the Current Shortcuts Hozzáadás a jelenlegi billentyűparancsokhoz
Remember My Choices Választásaim megjegyzése
{ready} of {total} ready Kész: {ready}/{total}
The file is too large to read in full, so only its first part is listed. A fájl túl nagy a teljes beolvasáshoz, ezért csak az első része szerepel a listában.
Report copied Jelentés másolva
Line {line} {line}. sor
Line {line}: {name}: {status} {line}. sor: {name}: {status}
Read Shortcut Set File Billentyűparancs-készlet beolvasása fájlból
Import Matches Importálási megfeleltetések
Remember Import Matches Importálási megfeleltetések megjegyzése
Forget Import Matches Importálási megfeleltetések elfelejtése
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts {n} alapértelmezett billentyűparanccsal egyezik
@plural {n} part of the file wasn't read|{n} parts of the file weren't read Be nem olvasott fájlrész: {n}
@msg This shortcut file is in a form VectorCraft can't read. Ez a billentyűparancsfájl olyan formátumú, amelyet a VectorCraft nem tud beolvasni.
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace nem billentyűparancs-készlet: a szöveg nem perjeles névvel és nyitó kapcsos zárójellel kezdődik
Keywords: Kulcsszavak:
Kind Típus
Kinsoku Set Kinsoku-készlet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1397,6 +1397,44 @@
Keyboard Shortcut Conflicts Conflitti tra scelte rapide da tastiera
Keyboard Shortcut Set Set di scelte rapide da tastiera
Keyboard Shortcuts… Scelte rapide da tastiera…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importa set di scelte rapide
Import Shortcut Set… Importa set di scelte rapide…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Ogni voce del file viene abbinata a uno strumento, un comando o un pannello di VectorCraft. Controlla gli abbinamenti e rispondi a ciò che resta.
Keys Tasti
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Il file registra tasti e modificatori come codici. Controlla i significati proposti e indica i codici ancora sconosciuti.
Click, then press the key this code stands for (Esc cancels) Fai clic, quindi premi il tasto che questo codice rappresenta (Esc annulla)
Press a key… Premi un tasto…
from your earlier imports dalle tue importazioni precedenti
from the format's public description dalla descrizione pubblica del formato
not known yet ancora sconosciuto
Shortcuts Scelte rapide
Shortcut Scelta rapida
In the File Nel file
In VectorCraft In VectorCraft
Needs Attention Only Solo da controllare
Ready Pronto
Needs a choice Serve una scelta
Needs a key Serve un tasto
No equivalent Nessun equivalente
Tools take a single key (optionally with Shift) Gli strumenti accettano un solo tasto (facoltativamente con Shift)
A later row takes this shortcut Una riga successiva usa questa scelta rapida
Same item as a later row Stessa voce di una riga successiva
Start from VectorCraft Defaults Parti dai valori predefiniti di VectorCraft
Add to the Current Shortcuts Aggiungi alle scelte rapide attuali
Remember My Choices Ricorda le mie scelte
{ready} of {total} ready {ready} su {total} pronti
The file is too large to read in full, so only its first part is listed. Il file è troppo grande per essere letto per intero, quindi ne viene elencata solo la prima parte.
Report copied Rapporto copiato
Line {line} Riga {line}
Line {line}: {name}: {status} Riga {line}: {name}: {status}
Imported. Press OK to keep the imported set. Importato. Premi OK per mantenere il set importato.
Read Shortcut Set File Leggi file di set di scelte rapide
Import Matches Abbinamenti di importazione
Remember Import Matches Ricorda abbinamenti di importazione
Forget Import Matches Dimentica abbinamenti di importazione
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts concorda con {n} scelta rapida predefinita|concorda con {n} scelte rapide predefinite
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} parte del file non è stata letta|{n} parti del file non sono state lette
Keywords: Parole chiave:
Knife Coltello
Knockout Group Gruppo con foratura
@@ -3526,6 +3564,7 @@ axis Both Entrambi
@msg Split Into Grid: select paths large enough for the grid Dividi in griglia: seleziona tracciati abbastanza grandi per la griglia
@msg Targa files are RGB: colorModel rgb I file Targa sono RGB: colorModel rgb
@msg Text Wrap: select a wrap object Testo attorno: seleziona un oggetto di avvolgimento
@msg This shortcut file is in a form VectorCraft can't read. Questo file di scelte rapide è in un formato che VectorCraft non può leggere.
@msg `aspect` must be a number (%) `aspect` deve essere un numero (%)
@msg `at` must be [x, y] `at` deve essere [x, y]
@msg `color` must be a colour `color` deve essere un colore
@@ -3759,6 +3798,7 @@ axis Both Entrambi
@msg not a VectorCraft file: {0} non è un file VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) non è un set di scelte rapide VectorCraft (manca `overrides`)
@msg not a graphic style library (format `{_1}`) non è una libreria di stili grafici (formato `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace non è un set di scelte rapide: il testo non inizia con un nome preceduto da una barra e una parentesi graffa aperta
@msg not a stored central record non è un record centrale archiviato
@msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0}
@msg not an EMF or WMF file non è un file EMF o WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1603,6 +1603,44 @@
Keyboard Increment キー入力
Keyboard Shortcut Conflicts キーボードショートカットの競合
Keyboard Shortcut Set キーボードショートカットセット
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set ショートカットセットを読み込み
Import Shortcut Set… ショートカットセットを読み込み…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. ファイルの各項目を VectorCraft のツール、コマンド、パネルに対応付けます。対応を確認して、残りの項目を指定してください。
Keys キー
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. このファイルはキーと修飾キーをコードで記録しています。提案された意味を確認して、まだ分からないコードを指定してください。
Click, then press the key this code stands for (Esc cancels) クリックしてから、このコードが表すキーを押します (Esc でキャンセル)
Press a key… キーを押してください…
from your earlier imports 以前の読み込みより
from the format's public description 形式の公開されている説明より
not known yet 未確定
Shortcuts ショートカット
Shortcut ショートカット
In the File ファイル内の名前
In VectorCraft VectorCraft での対応
Needs Attention Only 要確認の行のみ
Ready 適用可能
Needs a choice 選択が必要
Needs a key キーが必要
No equivalent 対応なし
Tools take a single key (optionally with Shift) ツールには 1 つのキーのみ割り当てられます (Shift との組み合わせは可)
A later row takes this shortcut 後の行がこのショートカットを使用
Same item as a later row 後の行と同じ項目
Start from VectorCraft Defaults VectorCraft の初期設定から開始
Add to the Current Shortcuts 現在のショートカットに追加
Remember My Choices 選択内容を記憶
{ready} of {total} ready {total} 件中 {ready} 件が適用可能
The file is too large to read in full, so only its first part is listed. ファイルが大きすぎて全体を読み込めないため、最初の部分のみを表示しています。
Report copied レポートをコピーしました
Line {line} {line} 行目
Line {line}: {name}: {status} {line} 行目: {name}: {status}
Imported. Press OK to keep the imported set. 読み込みました。読み込んだセットを保持するには OK を押してください。
Read Shortcut Set File ショートカットセットファイルを読む
Import Matches 読み込み時の対応付け
Remember Import Matches 読み込み時の対応付けを記憶
Forget Import Matches 読み込み時の対応付けを消去
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts {n} 個の初期設定ショートカットと一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read ファイルの {n} 箇所を読み込めませんでした
Keywords: キーワード:
Knife ナイフ
Knockout Group グループの抜き
@@ -4008,6 +4046,7 @@ theme Appearance 外観
@msg Discarded {_1} recovered document(s) 復帰用データのドキュメントを {_1} 件破棄しました
@msg Restored {_1} recovered document(s): save them to keep them 復帰用データからドキュメントを {_1} 件復元しました: 残すには保存してください
@msg Saving recovery data for {_1} {_1} の復帰用データを保存中
@msg This shortcut file is in a form VectorCraft can't read. このショートカットファイルは VectorCraft で読み込めない形式です。
@msg bad shortcut ショートカットが正しくありません
@msg can't parse shortcut `{c}` ショートカット `{c}` を解釈できません
@msg can't parse shortcut `{s}` for `{k}` `{k}` のショートカット `{s}` を解釈できません
@@ -4015,6 +4054,7 @@ theme Appearance 外観
@msg no file reader ファイルの読み込み機能がありません
@msg no file writer ファイルの書き込み機能がありません
@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft のショートカットセットではありません (`overrides` がありません)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace ショートカットセットではありません: テキストの先頭にスラッシュ付きの名前と開き波かっこがありません
@msg shortcut for `{k}` must be a string `{k}` のショートカットは文字列で指定してください
@msg tool shortcuts must be a single key (optionally with Shift) ツールのショートカットには 1 つのキーを指定してください (Shift との組み合わせも可)
@msg unknown preset (one of {_1}) 不明なプリセットです ({_1} のいずれかを指定してください)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1555,6 +1555,44 @@
Import PDF Presets PDF 프리셋 가져오기
Import Perspective Grid Presets 원근 격자 프리셋 가져오기
Import Print Presets 인쇄 프리셋 가져오기
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 단축키 세트 가져오기
Import Shortcut Set… 단축키 세트 가져오기…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. 파일의 각 항목은 VectorCraft의 도구, 명령 또는 패널에 대응됩니다. 대응을 확인하고 남은 항목을 정하세요.
Keys 키
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. 파일은 키와 보조 키를 코드로 기록합니다. 제안된 의미를 확인하고 아직 알 수 없는 코드의 의미를 정하세요.
Click, then press the key this code stands for (Esc cancels) 클릭한 다음 이 코드에 해당하는 키를 누르세요(Esc로 취소)
Press a key… 키를 누르세요…
from your earlier imports 이전 가져오기에서
from the format's public description 형식의 공개 설명에서
not known yet 아직 알 수 없음
Shortcuts 단축키
Shortcut 단축키
In the File 파일에서
In VectorCraft VectorCraft에서
Needs Attention Only 확인이 필요한 항목만
Ready 준비됨
Needs a choice 선택 필요
Needs a key 키 필요
No equivalent 대응 항목 없음
Tools take a single key (optionally with Shift) 도구에는 키 하나만 지정할 수 있습니다(Shift 포함 가능)
A later row takes this shortcut 뒤의 행이 이 단축키를 사용합니다
Same item as a later row 뒤의 행과 같은 항목
Start from VectorCraft Defaults VectorCraft 기본값에서 시작
Add to the Current Shortcuts 현재 단축키에 추가
Remember My Choices 내 선택 기억
{ready} of {total} ready 준비됨 {ready}/{total}
The file is too large to read in full, so only its first part is listed. 파일이 너무 커서 전체를 읽을 수 없으므로 앞부분만 표시합니다.
Report copied 보고서를 복사했습니다
Line {line} {line}행
Line {line}: {name}: {status} {line}행: {name}: {status}
Imported. Press OK to keep the imported set. 가져왔습니다. 가져온 세트를 유지하려면 확인을 누르세요.
Read Shortcut Set File 단축키 세트 파일 읽기
Import Matches 가져오기 대응
Remember Import Matches 가져오기 대응 기억
Forget Import Matches 가져오기 대응 지우기
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts 기본 단축키 {n}개와 일치|기본 단축키 {n}개와 일치
@plural {n} part of the file wasn't read|{n} parts of the file weren't read 읽지 않은 파일 부분 {n}개|읽지 않은 파일 부분 {n}개
Import Transparency Flattener Presets 투명도 병합 프리셋 가져오기
Import… 가져오기…
In Tangent Handles 안쪽 접선 핸들
@@ -3993,6 +4031,8 @@ theme Appearance 외관
@msg no file reader 파일 판독기가 없습니다
@msg no file writer 파일 기록기가 없습니다
@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft 단축키 세트가 아닙니다(`overrides`가 없음)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace 단축키 세트가 아닙니다: 텍스트가 슬래시 이름과 여는 중괄호로 시작하지 않습니다
@msg This shortcut file is in a form VectorCraft can't read. 이 단축키 파일은 VectorCraft가 읽을 수 없는 형식입니다.
@msg shortcut for `{k}` must be a string `{k}`의 단축키는 문자열이어야 합니다
@msg tool shortcuts must be a single key (optionally with Shift) 도구 단축키는 키 하나여야 합니다(Shift 포함 가능)
@msg unknown preset (one of {_1}) 알 수 없는 프리셋({_1} 중 하나)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -3919,6 +3919,8 @@ imageTrace Outline Kontur
@msg not a DXF drawing to nie jest rysunek DXF
@msg not a VectorCraft file: {0} to nie jest plik VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) to nie jest zestaw skrótów VectorCraft (brak `overrides`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace to nie jest zestaw skrótów: tekst nie zaczyna się od nazwy z ukośnikiem i otwierającego nawiasu klamrowego
@msg This shortcut file is in a form VectorCraft can't read. Ten plik skrótów ma postać, której VectorCraft nie potrafi odczytać.
@msg not a graphic style library (format `{_1}`) to nie jest biblioteka stylów graficznych (format `{_1}`)
@msg not a stored central record to nie jest zapisany rekord centralny
@msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0}
@@ -4319,6 +4321,42 @@ imageTrace Outline Kontur
⚠ {chord} was already used by {names} — it has been removed there. Uwaga: skrót {chord} był już używany przez {names} — został tam usunięty.
VectorCraft Defaults Domyślne VectorCraft
@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} skrót jest przypisany więcej niż raz|{n} skróty są przypisane więcej niż raz|{n} skrótów jest przypisanych więcej niż raz
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Importuj zestaw skrótów
Import Shortcut Set… Importuj zestaw skrótów…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Każdy wpis pliku zostaje dopasowany do narzędzia, polecenia lub panelu VectorCraft. Sprawdź dopasowania i uzupełnij pozostałe.
Keys Klawisze
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Plik zapisuje klawisze i modyfikatory jako kody. Sprawdź proponowane znaczenia i uzupełnij kody, które nie są jeszcze znane.
Click, then press the key this code stands for (Esc cancels) Kliknij, a następnie naciśnij klawisz oznaczany tym kodem (Esc anuluje)
Press a key… Naciśnij klawisz…
from your earlier imports z wcześniejszych importów
from the format's public description z publicznego opisu formatu
not known yet jeszcze nieznany
Shortcuts Skróty
In the File W pliku
In VectorCraft W VectorCraft
Needs Attention Only Tylko wymagające uwagi
Ready Gotowe
Needs a choice Wymaga wyboru
Needs a key Wymaga klawisza
No equivalent Brak odpowiednika
Tools take a single key (optionally with Shift) Narzędzia przyjmują pojedynczy klawisz (opcjonalnie z Shift)
A later row takes this shortcut Ten skrót przejmuje dalszy wiersz
Same item as a later row Ten sam element co w dalszym wierszu
Start from VectorCraft Defaults Zacznij od domyślnych skrótów VectorCraft
Add to the Current Shortcuts Dodaj do bieżących skrótów
Remember My Choices Zapamiętaj moje wybory
{ready} of {total} ready Gotowe: {ready} z {total}
The file is too large to read in full, so only its first part is listed. Plik jest zbyt duży, aby odczytać go w całości, dlatego wyświetlana jest tylko jego pierwsza część.
Report copied Skopiowano raport
Line {line} Wiersz {line}
Line {line}: {name}: {status} Wiersz {line}: {name}: {status}
Read Shortcut Set File Odczytaj plik zestawu skrótów
Import Matches Dopasowania importu
Remember Import Matches Zapamiętaj dopasowania importu
Forget Import Matches Zapomnij dopasowania importu
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts zgadza się z {n} domyślnym skrótem|zgadza się z {n} domyślnymi skrótami|zgadza się z {n} domyślnymi skrótami
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} część pliku nie została odczytana|{n} części pliku nie zostały odczytane|{n} części pliku nie zostało odczytanych
Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). Symuluj drukuje nadruki tak, jak pokazuje je Podgląd nadruku; rozbarwienia zawsze je zachowują. Ustawienie spłaszcza przezroczystość przed drukowaniem (pliki PostScript zawsze ją spłaszczają).
Preserve Zachowaj
Simulate Symuluj
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1395,6 +1395,44 @@
Keyboard Shortcut Conflicts Конфликты сочетаний клавиш
Keyboard Shortcut Set Набор сочетаний клавиш
Keyboard Shortcuts… Сочетания клавиш…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Импорт набора сочетаний клавиш
Import Shortcut Set… Импортировать набор сочетаний клавиш…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Каждая запись файла сопоставляется с инструментом, командой или панелью VectorCraft. Проверьте сопоставления и ответьте на оставшиеся вопросы.
Keys Клавиши
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Файл записывает клавиши и модификаторы в виде кодов. Проверьте предложенные значения и укажите коды, которые ещё неизвестны.
Click, then press the key this code stands for (Esc cancels) Щёлкните, затем нажмите клавишу, которую обозначает этот код (Esc отменяет)
Press a key… Нажмите клавишу…
from your earlier imports по вашим прошлым импортам
from the format's public description по общедоступному описанию формата
not known yet пока неизвестно
Shortcuts Сочетания клавиш
Shortcut Сочетание клавиш
In the File В файле
In VectorCraft В VectorCraft
Needs Attention Only Только требующие внимания
Ready Готово
Needs a choice Нужен выбор
Needs a key Нужна клавиша
No equivalent Нет соответствия
Tools take a single key (optionally with Shift) Инструментам назначается одна клавиша (можно с Shift)
A later row takes this shortcut Это сочетание забирает строка ниже
Same item as a later row Тот же элемент, что и в строке ниже
Start from VectorCraft Defaults Начать со значений VectorCraft по умолчанию
Add to the Current Shortcuts Добавить к текущим сочетаниям клавиш
Remember My Choices Запомнить мой выбор
{ready} of {total} ready Готово: {ready} из {total}
The file is too large to read in full, so only its first part is listed. Файл слишком большой, чтобы прочитать его целиком, поэтому показана только его первая часть.
Report copied Отчёт скопирован
Line {line} Строка {line}
Line {line}: {name}: {status} Строка {line}: {name}: {status}
Imported. Press OK to keep the imported set. Импортировано. Нажмите «ОК», чтобы сохранить импортированный набор.
Read Shortcut Set File Прочитать файл набора сочетаний клавиш
Import Matches Сопоставления импорта
Remember Import Matches Запомнить сопоставления импорта
Forget Import Matches Забыть сопоставления импорта
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts совпадает с {n} сочетанием по умолчанию|совпадает с {n} сочетаниями по умолчанию|совпадает с {n} сочетаниями по умолчанию
@plural {n} part of the file wasn't read|{n} parts of the file weren't read не прочитана {n} часть файла|не прочитаны {n} части файла|не прочитано {n} частей файла
Keywords: Ключевые слова:
Knife Нож
Knockout Group Группа с вырезанием
@@ -3522,6 +3560,7 @@ axis Both Оба
@msg Split Into Grid: select paths large enough for the grid Разделение на сетку: выделите контуры, достаточно большие для сетки
@msg Targa files are RGB: colorModel rgb файлы Targa являются RGB: colorModel rgb
@msg Text Wrap: select a wrap object Обтекание текстом: выделите объект обтекания
@msg This shortcut file is in a form VectorCraft can't read. Этот файл сочетаний клавиш записан в формате, который VectorCraft не может прочитать.
@msg `aspect` must be a number (%) `aspect` должно быть числом (%)
@msg `at` must be [x, y] `at` должно быть [x, y]
@msg `color` must be a colour `color` должно быть цветом
@@ -3755,6 +3794,7 @@ axis Both Оба
@msg not a VectorCraft file: {0} не является файлом VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) не является набором сочетаний клавиш VectorCraft (отсутствует `overrides`)
@msg not a graphic style library (format `{_1}`) не является библиотекой графических стилей (формат `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace это не набор сочетаний клавиш: текст не начинается с имени с косой чертой и открывающей фигурной скобки
@msg not a stored central record не является сохранённой центральной записью
@msg not a valid plug-in module: {0} не является допустимым модулем: {0}
@msg not an EMF or WMF file не является файлом EMF или WMF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -1391,6 +1391,44 @@
Keyboard Shortcut Conflicts Конфлікти поєднань клавіш
Keyboard Shortcut Set Набір клавіш
Keyboard Shortcuts… Поєднання клавіш…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Імпорт набору сполучень клавіш
Import Shortcut Set… Імпортувати набір сполучень клавіш…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Кожен запис файлу зіставляється з інструментом, командою або панеллю VectorCraft. Перевірте зіставлення й дайте відповідь на решту.
Keys Клавіші
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Файл записує клавіші та модифікатори як коди. Перевірте запропоновані значення й укажіть коди, які ще невідомі.
Click, then press the key this code stands for (Esc cancels) Клацніть, потім натисніть клавішу, яку позначає цей код (Esc скасовує)
Press a key… Натисніть клавішу…
from your earlier imports з ваших попередніх імпортів
from the format's public description за загальнодоступним описом формату
not known yet ще невідомо
Shortcuts Сполучення клавіш
Shortcut Сполучення клавіш
In the File У файлі
In VectorCraft У VectorCraft
Needs Attention Only Лише ті, що потребують уваги
Ready Готово
Needs a choice Потрібен вибір
Needs a key Потрібна клавіша
No equivalent Немає відповідника
Tools take a single key (optionally with Shift) Інструментам призначається одна клавіша (можна із Shift)
A later row takes this shortcut Це сполучення забирає рядок нижче
Same item as a later row Той самий елемент, що й у рядку нижче
Start from VectorCraft Defaults Почати зі значень VectorCraft за промовчанням
Add to the Current Shortcuts Додати до поточних сполучень клавіш
Remember My Choices Запам’ятати мій вибір
{ready} of {total} ready Готово: {ready} з {total}
The file is too large to read in full, so only its first part is listed. Файл завеликий, щоб прочитати його повністю, тому показано лише його першу частину.
Report copied Звіт скопійовано
Line {line} Рядок {line}
Line {line}: {name}: {status} Рядок {line}: {name}: {status}
Imported. Press OK to keep the imported set. Імпортовано. Натисніть «ОК», щоб зберегти імпортований набір.
Read Shortcut Set File Прочитати файл набору сполучень клавіш
Import Matches Зіставлення імпорту
Remember Import Matches Запам’ятати зіставлення імпорту
Forget Import Matches Забути зіставлення імпорту
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts збігається з {n} сполученням за промовчанням|збігається з {n} сполученнями за промовчанням|збігається з {n} сполученнями за промовчанням
@plural {n} part of the file wasn't read|{n} parts of the file weren't read не прочитано {n} частину файлу|не прочитано {n} частини файлу|не прочитано {n} частин файлу
Keywords: Ключові слова:
Knife Ніж
Knockout Group Група з вирізуванням
@@ -3510,6 +3548,7 @@ axis Both Обидві
@msg Split Into Grid: select paths large enough for the grid Поділ на сітку: виділіть контури, досить великі для сітки
@msg Targa files are RGB: colorModel rgb Файли Targa є RGB: colorModel rgb
@msg Text Wrap: select a wrap object Обтікання текстом: виділіть об’єкт обтікання
@msg This shortcut file is in a form VectorCraft can't read. Цей файл сполучень клавіш записано у форматі, який VectorCraft не може прочитати.
@msg `aspect` must be a number (%) `aspect` має бути числом (%)
@msg `at` must be [x, y] `at` має бути [x, y]
@msg `color` must be a colour `color` має бути кольором
@@ -3743,6 +3782,7 @@ axis Both Обидві
@msg not a VectorCraft file: {0} не є файлом VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) не є набором поєднань клавіш VectorCraft (відсутнє `overrides`)
@msg not a graphic style library (format `{_1}`) не є бібліотекою графічних стилів (формат `{_1}`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace це не набір сполучень клавіш: текст не починається з імені зі скісною рискою та відкривної фігурної дужки
@msg not a stored central record не є збереженим центральним записом
@msg not a valid plug-in module: {0} не є допустимим модулем: {0}
@msg not an EMF or WMF file не є файлом EMF або WMF
Can't render this file because it contains an unexpected character in line 713 and column 40.
+40
View File
@@ -1515,6 +1515,44 @@
Import PDF Presets Nhập thiết lập sẵn PDF
Import Perspective Grid Presets Nhập thiết lập sẵn lưới phối cảnh
Import Print Presets Nhập thiết lập sẵn in
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set Nhập bộ phím tắt
Import Shortcut Set… Nhập bộ phím tắt…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Mỗi mục trong tệp được ghép với một công cụ, lệnh hoặc bảng của VectorCraft. Hãy kiểm tra các cặp ghép và trả lời những mục còn lại.
Keys Phím
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Tệp ghi phím và phím bổ trợ dưới dạng mã. Hãy kiểm tra các nghĩa được đề xuất và trả lời những mã chưa biết.
Click, then press the key this code stands for (Esc cancels) Bấm, rồi nhấn phím mà mã này biểu thị (Esc để hủy)
Press a key… Nhấn một phím…
from your earlier imports từ các lần nhập trước của bạn
from the format's public description từ mô tả công khai của định dạng
not known yet chưa biết
Shortcuts Phím tắt
Shortcut Phím tắt
In the File Trong tệp
In VectorCraft Trong VectorCraft
Needs Attention Only Chỉ mục cần chú ý
Ready Sẵn sàng
Needs a choice Cần chọn
Needs a key Cần một phím
No equivalent Không có tương đương
Tools take a single key (optionally with Shift) Công cụ chỉ nhận một phím đơn (tùy chọn với Shift)
A later row takes this shortcut Một hàng phía sau dùng phím tắt này
Same item as a later row Cùng mục với một hàng phía sau
Start from VectorCraft Defaults Bắt đầu từ mặc định VectorCraft
Add to the Current Shortcuts Thêm vào phím tắt hiện tại
Remember My Choices Ghi nhớ lựa chọn của tôi
{ready} of {total} ready Sẵn sàng {ready} trong {total}
The file is too large to read in full, so only its first part is listed. Tệp quá lớn để đọc hết, nên chỉ phần đầu được liệt kê.
Report copied Đã sao chép báo cáo
Line {line} Dòng {line}
Line {line}: {name}: {status} Dòng {line}: {name}: {status}
Imported. Press OK to keep the imported set. Đã nhập. Nhấn OK để giữ bộ đã nhập.
Read Shortcut Set File Đọc tệp bộ phím tắt
Import Matches Cặp ghép khi nhập
Remember Import Matches Ghi nhớ cặp ghép khi nhập
Forget Import Matches Quên cặp ghép khi nhập
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts khớp với {n} phím tắt mặc định
@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} phần của tệp chưa được đọc
Import Transparency Flattener Presets Nhập thiết lập sẵn làm phẳng trong suốt
Imported {what}: {} Đã nhập {what}: {}
Import… Nhập…
@@ -4002,6 +4040,8 @@ theme Appearance Giao diện
@msg not a valid plug-in module: {0} không phải mô-đun plug-in hợp lệ: {0}
@msg not a VectorCraft file: {0} không phải tệp VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) không phải bộ phím tắt VectorCraft (thiếu `overrides`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace không phải bộ phím tắt: văn bản không bắt đầu bằng một tên có dấu gạch chéo và một dấu ngoặc nhọn mở
@msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được.
@msg not an EMF or WMF file không phải tệp EMF hoặc WMF
@msg not found không tìm thấy
@msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết
Can't render this file because it contains an unexpected character in line 3682 and column 10.
+36
View File
@@ -3293,6 +3293,42 @@ imageTrace Outline 轮廓
Set: 集:
Shortcut 快捷键
Use Default 使用默认值
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 导入快捷键组
Import Shortcut Set… 导入快捷键组…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. 文件中的每个条目都会对应到 VectorCraft 的工具、命令或面板。请检查对应结果,并回答剩余的条目。
Keys 按键
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. 文件以代码记录按键和修饰键。请检查建议的含义,并回答尚未确定的代码。
Click, then press the key this code stands for (Esc cancels) 单击后按下此代码代表的键 (Esc 取消)
Press a key… 请按一个键…
from your earlier imports 来自你之前的导入
from the format's public description 依据格式的公开说明
not known yet 尚未确定
Shortcuts 快捷键
In the File 文件中的名称
In VectorCraft VectorCraft 中的对应
Needs Attention Only 仅显示需要处理的条目
Ready 就绪
Needs a choice 需要选择
Needs a key 需要按键
No equivalent 无对应项
Tools take a single key (optionally with Shift) 工具只能使用单个键 (可搭配 Shift)
A later row takes this shortcut 后面的行会使用此快捷键
Same item as a later row 与后面某一行的项目相同
Start from VectorCraft Defaults 从 VectorCraft 默认开始
Add to the Current Shortcuts 添加到当前快捷键
Remember My Choices 记住我的选择
{ready} of {total} ready {total} 个中有 {ready} 个就绪
The file is too large to read in full, so only its first part is listed. 文件太大,无法完整读取,因此只列出开头部分。
Report copied 已复制报告
Line {line} 第 {line} 行
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
Read Shortcut Set File 读取快捷键组文件
Import Matches 导入对应
Remember Import Matches 记住导入对应
Forget Import Matches 清除导入对应
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts 与 {n} 个默认快捷键一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read 文件中有 {n} 个部分未读取
RGB: RGB:
Anchors 锚点
Image Trace (Make and Expand) 图像描摹 (创建并扩展)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -1372,6 +1372,44 @@
Keyboard Shortcut Conflicts 鍵盤快速鍵衝突
Keyboard Shortcut Set 鍵盤快速鍵組
Keyboard Shortcuts… 鍵盤快速鍵…
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 匯入快速鍵組
Import Shortcut Set… 匯入快速鍵組…
Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. 檔案中的每個項目都會對應到 VectorCraft 的工具、指令或面板。請檢查對應結果,並回答其餘項目。
Keys 按鍵
The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. 檔案以代碼記錄按鍵與輔助鍵。請檢查建議的意義,並回答尚未得知的代碼。
Click, then press the key this code stands for (Esc cancels) 按一下,然後按下此代碼代表的按鍵 (Esc 取消)
Press a key… 請按下按鍵…
from your earlier imports 依據您先前的匯入
from the format's public description 依據格式的公開說明
not known yet 尚未得知
Shortcuts 快速鍵
Shortcut 快速鍵
In the File 檔案中的名稱
In VectorCraft VectorCraft 中的對應
Needs Attention Only 僅顯示需要處理的項目
Ready 就緒
Needs a choice 需要選擇
Needs a key 需要按鍵
No equivalent 沒有對應項目
Tools take a single key (optionally with Shift) 工具只能指定單一按鍵 (可搭配 Shift)
A later row takes this shortcut 後面的列會使用此快速鍵
Same item as a later row 與後面某一列的項目相同
Start from VectorCraft Defaults 從 VectorCraft 預設值開始
Add to the Current Shortcuts 加入目前的快速鍵
Remember My Choices 記住我的選擇
{ready} of {total} ready {total} 個中有 {ready} 個就緒
The file is too large to read in full, so only its first part is listed. 檔案太大,無法完整讀取,因此只列出開頭的部分。
Report copied 已複製報告
Line {line} 第 {line} 行
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
Imported. Press OK to keep the imported set. 已匯入。按一下「確定」以保留匯入的快速鍵組。
Read Shortcut Set File 讀取快速鍵組檔案
Import Matches 匯入對應
Remember Import Matches 記住匯入對應
Forget Import Matches 清除匯入對應
@plural agrees with {n} default shortcut|agrees with {n} default shortcuts 與 {n} 個預設快速鍵一致
@plural {n} part of the file wasn't read|{n} parts of the file weren't read 檔案中有 {n} 個部分未讀取
Keywords: 關鍵字:
Knife 美工刀
Knockout Group 去底群組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+23 -9
View File
@@ -16,10 +16,10 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
/// graphic style library files open in the library panel, Libraries panel files are added to it,
/// flattener, PDF, print and perspective grid presets files are imported, and a script written for
/// the reference app (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). →
/// `document.open`'s result for a document opened now (its `warnings` say what didn't come in as it
/// was), else null.
/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another
/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app
/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for
/// a document opened now (its `warnings` say what didn't come in as it was), else null.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
let opens = opens_as(name);
// A WebAssembly plug-in is installed.
@@ -29,6 +29,9 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
return Ok(Value::Null);
}
if opens == Some(OpensAs::ShortcutSet) {
return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null);
}
if opens == Some(OpensAs::Script) {
let p = match path {
Some(path) => json!({ "path": path }),
@@ -66,12 +69,14 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
open_document(app, name, bytes, path, &Value::Null)
}
/// The kinds of file File → Open installs, imports, checks or shows in the library panel or the
/// Libraries panel ([`opens_as`]).
/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the
/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OpensAs {
/// A WebAssembly plug-in: installed.
Plugin,
/// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set.
ShortcutSet,
/// A script written for the reference app: checked without running it (Check Script…).
Script,
/// Flattener, PDF, print or perspective grid presets: imported with command `import`, and
@@ -85,9 +90,10 @@ pub enum OpensAs {
StyleLibrary,
}
/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks or
/// shows in the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens
/// these files the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]).
/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks,
/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set
/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped
/// ([`VectorcraftApp::drop_target`]).
pub fn opens_as(name: &str) -> Option<OpensAs> {
let ext = fileio::extension(name);
let presets = [
@@ -98,6 +104,8 @@ pub fn opens_as(name: &str) -> Option<OpensAs> {
];
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
Some(OpensAs::Plugin)
} else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) {
Some(OpensAs::ShortcutSet)
} else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) {
Some(OpensAs::Script)
} else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
@@ -214,6 +222,12 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<Value, String>
/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks
/// first) is decoded off the UI thread ([`crate::opening::start`]).
pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result<Value, String> {
// A shortcut set is read by Import Shortcut Set, up to its reader's cap.
if dialogs::shortcut_import::is_kys(path) {
dialogs::shortcut_import::command(app, &json!({ "path": path }))?;
note_recent(app, path);
return Ok(Value::Null);
}
// A script is read by `script.check`, up to its limit.
if opens_as(path) == Some(OpensAs::Script) {
dialogs::script_check::command(app, &json!({ "path": path }))?;
+10 -5
View File
@@ -284,8 +284,8 @@ pub struct CanvasCache {
pub slices: Option<SliceCache>,
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
pub print_tiling: Option<PrintTilingCache>,
/// The marks Type → Show Hidden Characters drew on each text object in the last frame that
/// showed them.
/// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame
/// that does not draw them drops them ([`canvas::show`]).
pub hidden_chars: canvas::HiddenChars,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
@@ -964,6 +964,8 @@ impl VectorcraftApp {
}
}
/// Returns the milliseconds since the Unix epoch, or in the web build the milliseconds since the
/// first call, read from the page's `performance.now()`.
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -972,7 +974,10 @@ pub fn now_ms() -> f64 {
}
#[cfg(target_arch = "wasm32")]
{
0.0
use std::sync::LazyLock;
use web_time::Instant;
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
START.elapsed().as_secs_f64() * 1000.0
}
}
@@ -1143,8 +1148,8 @@ impl VectorcraftApp {
self.take_dropped_files(ctx);
}
/// Files dropped on the window: documents, libraries, presets and plug-ins opened, pictures
/// and text placed on the canvas ([`Self::drop_target`]).
/// Files dropped on the window: documents, libraries, presets, plug-ins and shortcut sets
/// opened, pictures and text placed on the canvas ([`Self::drop_target`]).
#[cfg(not(target_arch = "wasm32"))]
fn take_dropped_files(&mut self, ctx: &egui::Context) {
let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift));
+7
View File
@@ -181,6 +181,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
),
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
(
"ui.shortcutImportDialog",
"Import Shortcut Set…",
"",
"{path? | dataBase64?, name?} open Import Shortcut Set for a shortcut set another app saved as text (.kys; without a file, pick one): each entry matched to a tool, command or panel, and its key and modifier codes decoded (dialog `shortcutImport`: name, start: defaults|current, remember, rows: [{name, section, line, key, modifiers, target: entry id | \"\" (no equivalent) | null (not chosen), from, choices, on}], keys and mods: {code: {text, from, votes, names}}); Apply assigns the ready rows over Edit › Keyboard Shortcuts' working set, remembers the matches (importMatches.set) and shows Keyboard Shortcuts, whose OK keeps them. File › Open of a .kys opens it too",
),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"),
("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"),
@@ -1420,6 +1426,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned())
}
"ui.packageDialog" => crate::dialogs::package::open(app),
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
"file.showPackage" => crate::dialogs::package::show_package(app, p),
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
+1
View File
@@ -66,6 +66,7 @@ const DOCUMENT_FREE: &[&str] = &[
"window.graphicStyleLibrary.other",
"shortcuts.import",
"shortcuts.export",
"ui.shortcutImportDialog",
];
impl Entry {
+5 -3
View File
@@ -44,7 +44,7 @@ pub enum DropTarget {
Place(DropAt),
/// Opened as File → Open opens it: no document is open, the drop missed the canvas (the tab
/// bar), it is a document dropped where the platform doesn't say, or it is a library, a presets
/// file or a plug-in.
/// file, a plug-in or a shortcut set.
Open,
}
@@ -302,7 +302,8 @@ impl VectorcraftApp {
/// onto the canvas of the open document (Shift embeds it), else opened. Where the platform
/// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its
/// own and a picture or text is placed in the middle of the view. A library, a presets file, a
/// plug-in or a script opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]).
/// plug-in, a shortcut set or a script opens wherever it is dropped, as File → Open opens it
/// ([`io::opens_as`]).
pub fn drop_target(&self, name: &str, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open };
let at = match pos {
@@ -391,7 +392,8 @@ pub fn drain(app: &mut VectorcraftApp) {
/// Files dragged over the window: the canvas is outlined where they would be placed. On the web
/// the browser gives a dragged file's type but not its name, so the canvas is outlined for any
/// file dragged over it, including libraries, presets and plug-ins, which open when dropped.
/// file dragged over it, including libraries, presets, plug-ins and shortcut sets, which open when
/// dropped.
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
let pos = drag_pos(ctx);
let placed = ctx.input(|i| {
+29 -1
View File
@@ -167,7 +167,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text });
let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0));
let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0));
if ui.add(b).clicked() {
d.fields.insert("__category".into(), json!(c));
}
@@ -599,6 +599,34 @@ mod tests {
assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true));
}
/// #1866: the category shown is highlighted in the list while the pointer is elsewhere.
#[test]
fn the_category_shown_is_highlighted() {
fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) {
match s {
egui::Shape::Rect(r) => out.push((r.rect, r.fill)),
egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)),
_ => {}
}
}
let mut a = app();
open(&mut a, Some("Units"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// The window sizes itself on the first frame and draws on the second.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown));
// The list's rows, top to bottom: only the fourth (Units) is filled.
let rows: Vec<_> = shown.iter().filter(|(r, _)| r.width() == 184.0 && r.height() < 30.0).collect();
assert_eq!(rows.len(), PREF_CATEGORIES.len(), "{shown:?}");
let filled: Vec<usize> = rows.iter().enumerate().filter(|(_, (_, c))| c.a() > 0).map(|(i, _)| i).collect();
assert_eq!(filled, vec![3], "{rows:?}");
}
/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it
/// changes, and its checkbox is dimmed unless a unit is Picas.
#[test]
+20 -5
View File
@@ -494,9 +494,20 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
let data = if let Some(v) = p.get("data") {
Ok(v.clone())
} else {
let path = s("path").or_else(|| crate::picks::open(app, &crate::FilePick::default()));
let pick = crate::FilePick { filters: vec![("Shortcut sets", crate::dialogs::shortcut_import::IMPORT_EXTS)], ..Default::default() };
let path = s("path").or_else(|| crate::picks::open(app, &pick));
// A `.kys` file is read by Import Shortcut Set, up to its reader's cap.
if let Some(path) = path.as_deref().filter(|p| crate::dialogs::shortcut_import::is_kys(p)) {
return Some(crate::dialogs::shortcut_import::command(app, &json!({ "path": path })));
}
match (path, app.services.read.as_ref()) {
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
(Some(path), Some(r)) => match r(&path) {
// A set another app saved as text opens Import Shortcut Set.
Ok(b) if crate::dialogs::shortcut_import::is_text_set(&path, &b) => {
return Some(crate::dialogs::shortcut_import::open(app, &path, &b));
}
b => b.and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
},
(None, _) => Err("cancelled".into()),
(_, None) => Err("no file reader".into()),
}
@@ -518,9 +529,13 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
Some(r)
}
/// While recording, Escape cancels the recording instead of closing the dialog.
/// While recording (here or in Import Shortcut Set), Escape cancels the recording instead of closing
/// the dialog.
pub fn is_recording(app: &VectorcraftApp) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts" && !d.str("__recording").is_empty())
app.ui
.dialog
.as_ref()
.is_some_and(|d| (d.kind == "shortcuts" || d.kind == crate::dialogs::shortcut_import::KIND) && !d.str("__recording").is_empty())
}
// ---------- dialog ----------
@@ -748,7 +763,7 @@ fn import_into_dialog(app: &mut VectorcraftApp) {
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
sync(&app.ui);
if let Some(d) = app.ui.dialog.as_mut() {
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "shortcuts") {
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
d.fields.insert("set".into(), json!(s));
d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
+28
View File
@@ -77,6 +77,34 @@ fn control_requests_stay_inside_the_roots() {
let _ = std::fs::remove_dir_all(base);
}
/// A shortcut set another app saved (`.kys`) is read only from inside the read root, whichever way a
/// control request names it: `shortcutSet.read`, Import Shortcut Set, Keyboard Shortcuts' Import…
/// and File › Open.
#[test]
fn shortcut_sets_are_read_only_inside_the_read_root() {
let (mut app, tx, base) = confined("kys");
let set = "/Menus {\n /Acme\\ Undo { /Modifiers 0 /Key 48 }\n}\n";
std::fs::write(base.join("in/a.kys"), set).unwrap();
std::fs::write(base.join("outside/b.kys"), set).unwrap();
let execute = |command: &str, path: PathBuf| json!({"command": command, "params": {"path": s(path)}});
let r = request(&mut app, &tx, "engine.execute", execute("shortcutSet.read", base.join("in/a.kys")));
assert_eq!(r["result"]["entries"][0]["name"], "Acme Undo", "{r}");
for (method, params) in [
("engine.execute", execute("shortcutSet.read", base.join("outside/b.kys"))),
("engine.execute", execute("ui.shortcutImportDialog", base.join("outside/b.kys"))),
("engine.execute", execute("shortcuts.import", base.join("outside/b.kys"))),
("app.open", json!({"path": s(base.join("outside/b.kys"))})),
] {
let r = request(&mut app, &tx, method, params.clone());
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the read root")), "{method} {params}: {r}");
assert!(app.ui.dialog.is_none(), "{method} {params}: no dialog opens");
}
let r = request(&mut app, &tx, "engine.execute", execute("ui.shortcutImportDialog", base.join("in/a.kys")));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::shortcut_import::KIND));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn frames_with_injected_input_are_confined() {
let (mut app, tx, base) = confined("synthetic");
+47
View File
@@ -300,6 +300,32 @@ fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() {
assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]);
}
/// Keyboard Shortcuts' Import… opens a shortcut set another app saved as text (`.kys`) in Import
/// Shortcut Set over the dialog when its file dialog answers, and the dialog's working set stays in
/// Keyboard Shortcuts, which Cancel shows again.
#[test]
fn keyboard_shortcuts_import_opens_a_text_set_over_the_dialog() {
let (mut app, shown, _) = app();
let dir = std::env::temp_dir().join(format!("vc-picks-kys-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let set = dir.join("Mine.kys");
std::fs::write(&set, "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n").unwrap();
let path = set.to_string_lossy().into_owned();
app.run("edit.keyboardShortcuts", json!({})).unwrap();
click(&mut app, "Import…", dialogs);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
shown.answer(&[&path]);
poll(&mut app);
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("no dialog: {}", app.ui.status));
assert_eq!(d.kind.as_str(), crate::dialogs::shortcut_import::KIND, "{}", app.ui.status);
for field in ["overrides", "set", "__message"] {
assert!(!d.fields.contains_key(field), "Keyboard Shortcuts' `{field}` stays in Keyboard Shortcuts");
}
crate::dialogs::cancel(&mut app);
assert_eq!(dialog_kind(&app), Some("shortcuts"));
let _ = std::fs::remove_dir_all(dir);
}
/// A dialog's folder button answered after the dialog closed fills nothing, and the status says
/// why.
#[test]
@@ -496,6 +522,27 @@ fn an_open_answer_goes_on_after_the_active_document_closes() {
assert_ne!(app.ui.status, CLOSED);
}
/// Import Shortcut Set… acts on no document either: its answer opens the set picked in Import
/// Shortcut Set even when the document active at the time closed meanwhile (Linux).
#[test]
fn an_import_shortcut_set_answer_goes_on_after_the_active_document_closes() {
let (mut app, shown, _) = with_dialogs(false);
let dir = std::env::temp_dir().join(format!("vc-picks-kys-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let set = dir.join("Mine.kys");
std::fs::write(&set, "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n").unwrap();
let path = set.to_string_lossy().into_owned();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
assert!(app.run("ui.shortcutImportDialog", json!({})).is_err());
app.run("file.close", json!({})).unwrap();
assert!(app.session.documents().is_empty());
shown.answer(&[&path]);
poll(&mut app);
assert_eq!(dialog_kind(&app), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
assert_ne!(app.ui.status, CLOSED);
let _ = std::fs::remove_dir_all(dir);
}
/// Save Changes' Save, answered by its Save dialog, saves and closes the document it asked about,
/// even when an earlier document closed meanwhile (Linux), then asks about the next one.
#[test]
+20
View File
@@ -211,6 +211,26 @@ fn a_dropped_libraries_panel_file_is_added_as_a_new_library() {
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
}
/// A shortcut set dropped on the window while a document is open opens Import Shortcut Set, as
/// File → Open opens it: from its path (desktop) and from its bytes dropped on the canvas (web).
#[test]
fn a_dropped_shortcut_set_opens_import_shortcut_set() {
let mut app = app();
let ctx = egui::Context::default();
let rect = laid_out(&mut app, &ctx);
let set = "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n";
let path = temp_file("dropped.kys", set.as_bytes());
frame(&mut app, &ctx, vec![], &[&path], false);
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
app.ui.dialog = None;
let target = app.drop_target("sent.kys", Some(rect.center()), false);
assert_eq!(target, DropTarget::Open);
crate::place::drop_files(&mut app, vec![(target, ("sent.kys".into(), None, set.as_bytes().to_vec()))]);
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status);
assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed");
}
#[test]
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
let mut app = app();
+2 -1
View File
@@ -127,7 +127,7 @@ impl Settle {
}
let waited = now - self.since;
// The clock went back (a new egui context): don't wait for it to catch up.
if waited >= SETTLE_SECS || waited < 0.0 {
if !waited.is_nan() && !(0.0..SETTLE_SECS).contains(&waited) {
return true;
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64((SETTLE_SECS - waited).max(0.0)));
@@ -173,5 +173,6 @@ mod tests {
assert!(!s.settled(&ctx, 1, 2, 10.2), "a new revision starts it again");
assert!(!s.settled(&ctx, 2, 2, 10.25), "so does another document");
assert!(s.settled(&ctx, 2, 2, 1.0), "a clock that went back doesn't hold it up");
assert!(!s.settled(&ctx, 2, 2, f64::NAN), "an unknown elapsed time is still unsettled");
}
}
+11 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (shared PDF reader and writer; app output policy and editable-import adapter retained; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 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
@@ -59,6 +59,14 @@ reaches the status bar, and Quit waits for pending submissions. Windows 7 retain
and the installed viewer's Print/PrintTo verb;
its target excludes the shared backend because Windows.Data.Pdf is unavailable there.
PDF writing uses the optional `craft-pdf` writer at an immutable craft-libs revision. Shared
configuration, metadata, font preparation and guarded serialization leave drawing, colour
policy, layers, editing data and print composition in this app. `cargo xtask pdf-deps` verifies
the shared facade and patched Krilla and Hayro sources at the same revision.
The reader facade opens the PDF with per-document decode limits; the app scans layer and
overprint content in windows and rejects known incomplete content before returning a document.
See [pdf-backend.md](pdf-backend.md).
## Agent control
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
@@ -84,6 +92,8 @@ saves copies in the background. See [development.md](development.md#robustness-v
|---|---|---|
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. |
| 2026-10-13 | minor | PDF writer setup and guarded finalization use craft-pdf; drawing and output policy remain app-owned. |
| 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 |
+1 -1
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`) and plug-ins (`.wasm`) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
+1 -1
View File
@@ -29,7 +29,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame.
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
+2 -1
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -99,6 +99,7 @@ Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) |
| 2026-10-14 | minor | Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); scores unchanged (estimated) |
| 2026-10-13 | minor | Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); scores unchanged (estimated) |
| 2026-10-13 | minor | 3D lighting takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %), and a light behind the object; earlier lights read as the same ([three-d.md](three-d.md#lighting)); 3D stays ~25%, 25–45 h |
+5 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard andLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part isLine truncated
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
@@ -59,7 +59,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus |
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open | no | via Print to PostScript | |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598) |
@@ -90,9 +90,12 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | SVG import: `:not()` selectors (#1799, #1557), stacked descendant selectors over deep nesting no longer stall (#1834, #1703), linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); nesting capped at 32 objects with self-using symbols and patterns opened without the offending instance (#1852); deep and self-referencing PDF and `.ai` data (#1871), decompression bombs (#1873), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863) and huge ids (#1862) handled; a failed save is reported in a dialog and the old file is kept (#1865, #1685, #1693, #1694); type inside symbols gets its layout bounds back on opening a native file (#1833, #1738). Scores unchanged (estimated) |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-14 | minor | EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); scores unchanged |
| 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) |
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. |
| 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. |
| 2026-10-13 | minor | Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542) (the `.ai` row for #1528 was written by that PR); scores unchanged (estimated) |
| 2026-10-13 | minor | `.ai` read as its PDF part: a page whose art reaches past its artboard to the left (notes beside a design) moves right in the row until that art clears the page before it, so it is no longer taken for that page's art; a multi-artboard `.ai` with such notes opened as its PDF part ("the art on them differs from the page's"), without the art outside its artboards, and now opens from its editing data |
| 2026-10-13 | minor | TIFF: grayscale files with an alpha channel or other extra channels place, relink and open, read as CMYK files with alpha already were (8 and 16 bits, either polarity; #1489, part of #1466); the rows for #1434 (strokes kept apart from a faded or masked fill, a `.ai`'s RGB numbers) and #1435 (SVG knockout groups without masks, `--decimals`) were written by those PRs; scores unchanged (estimated) |
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more