Compare commits

...
Author SHA1 Message Date
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 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
skylar taylor-barrick e01a65d8a9 PDF.R1: integrate shared reader with bounded nesting checks 2026-10-13 21:58:01 -06:00
skylar taylor-barrick a699a26221 refactor(pdf): use shared writer for export and print (PDF.W1) 2026-10-13 20:41:14 -06:00
106 changed files with 5557 additions and 430 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
+77 -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",
@@ -4981,7 +4992,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -5004,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",
@@ -5019,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",
@@ -6091,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.
+4 -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 (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)
> **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
+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.
+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
+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,
}))
}
+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"]);
}
}
@@ -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 }
+35
View File
@@ -127,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}");
}
+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)));
+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)
}
+16 -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
+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") };
+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;
@@ -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 }))?;
+2 -2
View File
@@ -1148,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| {
+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).
+4 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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 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; arLine 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) |
@@ -94,6 +94,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| 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) |
+32 -2
View File
@@ -65,7 +65,7 @@ the suite) keep the agent to the folders you give it:
- Every file a command reads must lie inside the **read root**: `open_file`, `file.place`, relinking and
updating links, the images an SVG links to, swatch library, graphic style library and colour-profile loads, plug-ins,
`file.newFromTemplate`, preset imports, the folders `text.findFontFiles` and `links.findFiles` search, the
`file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the
scripts `script.check` reads and the files they include.
- Every file a command writes must lie inside the **write root**: `save_file`, `export`, the save and
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
@@ -2124,7 +2124,9 @@ the Actions panel leaves the date out: playing it later dates the document then)
command records the date it used (and any value it takes from the preferences) in its step of the batch's journal entry.
A batch may open, switch, close or revert documents: each document it edits gets one undo step, the batch is
journaled whichever document it ends in, and an error rolls every document back (the ones it opened close, the ones
it closed or reverted come back).
it closed or reverted come back). The rollback puts the documents back, not the disk: files its steps wrote (a save, a
Save As, an export) stay written, new files included, as Undo leaves a saved file on disk. A caller that wants nothing
written unless the whole batch succeeds puts the saves and exports last, or runs them after the batch.
```json
{"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}}
@@ -3438,3 +3440,31 @@ Envelope Options.
{"name":"run_command","arguments":{"command":"object.envelope.editContents","params":{"editing":true}}}
{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}}
```
## Shortcut sets from other apps
`shortcutSet.read {path? | data? | dataBase64?, name?}` reads a shortcut set another app saved as text (a `.kys` file
that starts with `/Menus {`) without changing the document or the preferences. Each block that sets `/Key` is an
entry, listed with its `name`, its `section` (the names of the blocks around it), its codes as the file writes them
(`key`, `modifiers`, `context`, `represent`) and its `line`. `codes` counts the entries that use each key and modifier
code, and `unread` lists the tokens the reader skipped, with their lines. The reader stops at 4 MiB, 16 open blocks,
20,000 entries and 8 MiB of names over the entries and their sections, cuts names at 1,024 bytes, and sets `truncated` when it reaches one of these limits. An XML shortcut
file fails with "This shortcut file is in a form VectorCraft can't read."
The import matches record what the names and codes in such files stand for in VectorCraft. They are kept with the
preferences, and resetting the preferences keeps them. `importMatches.set {matches: [{kind, name, target, label?}]}`
records them: kind `name` maps an entry name to a command id, `tool:<id>` or `panel:<id>`, and kinds `key` and
`modifiers` map a code, written in decimal, to a key or to modifiers as Edit › Keyboard Shortcuts writes them (`K`,
`Cmd+Shift`). A `target` of `""` records that there is no equivalent (for kind `modifiers`, no modifiers), and `null`
removes the match. `importMatches.list {kind?, query?}` lists them and `importMatches.reset {kind?}` removes them.
`shortcutSet.read` then adds the saved `target` to each entry, a `chord` to each entry whose codes are both known, and
the saved `key` or `mods` to each code.
In the desktop app, Edit › Keyboard Shortcuts › Import… and File › Open show such a file in Import Shortcut Set
(`ui.shortcutImportDialog {path? | dataBase64?, name?}` over the control channel). The dialog proposes targets from
the entry names and decodes the codes that VectorCraft's default shortcuts agree on.
```json
{"name":"run_command","arguments":{"command":"shortcutSet.read","params":{"path":"/Users/me/Studio.kys"}}}
{"name":"run_command","arguments":{"command":"importMatches.set","params":{"matches":[{"kind":"name","name":"Group","target":"object.group"},{"kind":"key","name":"901","target":"G"}]}}}
```
+76
View File
@@ -0,0 +1,76 @@
# PDF backend
PDF writing uses `craft-pdf` from `storytold/craft-libs`, pinned to
`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`. Its `write` feature supplies Krilla configuration,
metadata construction, fallible font preparation and serialization, and native unwind guards.
The dependency disables default features and enables `write` and `interpret`.
The production writer retains Krilla’s raster-image support without its optional text shaper.
PDF test fixtures enable `simple-text` through a dev dependency.
The PDF crate explicitly retains the `flate2` `zlib-rs` feature previously supplied
by the registry reader, preserving compressed export bytes and their derived document IDs.
VectorCraft owns document traversal, drawing, font selection and embedding policy, colour
conversion, presets, page boxes, layers, editing data, postprocessing and print composition.
Editable import keeps its VectorCraft adapter, layer routing, colour policy and text decisions.
Desktop printer submission remains in `craft-print`.
The shared writer vendors Krilla 0.8.2 with an unwind-safe cleanup and poisoning patch.
The root `[patch.crates-io]` selects that copy from the same immutable revision: Cargo does
not inherit a dependency workspace's patches. A failed page or surface cleanup poisons the
backend document, so serialization cannot return an incomplete PDF after unwinding.
The shared crate's compile-time backend marker and `cargo xtask pdf-deps` (included in CI)
check that the patched writer is used. Krilla retains its MIT OR Apache-2.0 licensing;
its upstream provenance and authored patch are recorded in the shared repository.
Export and print construction, drawing and postprocessing run inside the shared native
unwind guard. These guards do not catch WASM aborts or process-wide allocation failures.
VectorCraft retains its finite-coordinate checks and the existing behavior that omits art
outside the writer's supported coordinate range. Font preparation checks the shared
512 MiB per-font copy limit and variation coordinates before allocation; failure preserves
VectorCraft's reported outline fallback. The writer is not a peak-memory or runtime budget.
PDF reading uses the same shared revision's patched Hayro syntax 0.7.2, interpreter 0.7.0
and JBIG2 0.3.0. This snapshot also retains CCITT 0.3.0 and JPEG 2000 0.3.5.
Its established Lab conversion uses Bradford adaptation; calibrated gray/RGB and tint-function
conversion retain floating-point precision. This policy differs from the registry 0.8 reader's
Lab profile and byte-quantized conversion and is assessed against independent colour expectations.
The application explicitly preserves its existing accepted file sizes with
`OpenOptions { max_input_bytes: usize::MAX, ..Default::default() }`; this migration adds no
new file-size ceiling. Ordinary decoded streams use the shared backend's immutable
per-document limits. Those limits are separate from streaming page-content budgets and
do not bound aggregate document or process memory. Password classification and the
existing permissions-password fallback remain application policy.
The layer and overprint pre-scan consumes `ContentReader` windows through `TypedIter`,
retaining graphics and marked-content state between windows. It does not collect a whole
page before scanning. Reaching the scan's operator, nesting or instruction-window limit,
a reported failed/truncated stream, a cap-reaching filtered form, or an undecidable final
operand tail returns an import error and discards the partial document. Previously the
operator limit could return a partial layer routing with a warning. Interpreter completion
warnings also become errors before a document is returned. Whitespace and comments at
EOF remain valid. The typed iterator does not classify every malformed operator, so this
is known-incomplete rejection rather than a general PDF validity certificate.
The three patched Hayro crates and Krilla are selected in the root `[patch.crates-io]`
from the same immutable revision as `craft-pdf`. `cargo xtask pdf-deps` rejects missing,
duplicate, local-path, registry and mismatched revisions for those five packages.
`craft-print` and `craft-ui` keep their existing independent revisions.
Dependency asset hashes and sources are in `ASSETS.md`; redistributed license texts are
in `licenses/pdf-backend`. Package staging carries those texts together with `NOTICE`
and `ASSETS.md`. Fonts and profiles remain dependency data; this repository adds no font
or profile binaries. Native page interpretation has a shared unwind guard; WASM aborts
and process-wide allocation failures cannot be caught.
The shared parser retains current main's 64-level cap for direct arrays and dictionaries,
including decoded content and object streams. The application retains its two-pass traversal
of repaired object tables, 16-link function/colour-space cap, 64-level calculator-procedure cap,
256 MiB stream checks and predictor validation. Native interpretation uses the existing
16 MiB stack wrapper, and imported document depth remains bounded.
Known incomplete decoded content remains an import error, including deeply nested operands
that the bounded parser cannot finish. Lowered ordinary decode limits and dictionary delimiters
split across scan windows are covered by synthetic regressions; they require the typed
incomplete-content error and never return a partial document. The former 256-level lexical
preflight and its diagnostic are superseded by the parser cap and completion checks.
The scratch bound does not cover whole-filter fallback decoding or aggregate parser/process memory.
+5 -3
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates 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; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect Line truncated
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -52,7 +52,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (closed SPLINE flags round-trip, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (closed SPLINE flags round-trip, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
@@ -62,7 +62,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** |
@@ -86,6 +86,8 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| 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 | 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); 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); milestone rows and estimates 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); Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); 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); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenTypeLine truncated
| 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 | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged |
| 2026-10-13 | minor | M7: mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; other mixed paragraph fields, scores and hours unchanged |
| 2026-10-13 | minor | Interaction fidelity: Alt swaps a new ruler guide's orientation during creation; preview, snapping, release, cancellation and Undo/Redo have event regressions. Scores and hours unchanged (estimated). |
+6 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: 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; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #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; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: 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; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchangeLine 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: 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; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #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; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: 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; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files rLine truncated
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -83,7 +83,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated |
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320) |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated |
@@ -430,6 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
@@ -517,6 +518,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
- **Files (M4):**
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). 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). 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).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
@@ -587,6 +589,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| 2026-10-14 | minor | 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); the Image Trace row keeps 75% (estimated): the score was raised for the measured work on 2026-10-13 and these two are refinements of it |
| 2026-10-13 | minor | Files: 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 | Tools, UI, effects and type: Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); 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); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); The Polish translator confirmed the Polish rows the maintainers had added after the catalog was written (#1614); 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 | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged |
| 2026-10-13 | minor | Type core: mixed paragraph alignment indication implemented using the existing buttons and range command; headless regressions, full integration CI and visual review pass; scores and hours unchanged |
| 2026-10-13 | minor | G1: Alt switches the orientation of new ruler guides, including preview, snapping and release; scores and hours unchanged (estimated). |
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,7 @@
Copyright (c) The Hayro Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,28 @@
Copyright 1990-2023 Adobe. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
Neither the name of Adobe nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
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.
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,7 @@
Copyright (c) The Hayro Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
http://creativecommons.org/publicdomain/zero/1.0/
@@ -0,0 +1,27 @@
// Copyright 2014 PDFium Authors. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// 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.
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,7 @@
Copyright (c) The Hayro Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,7 @@
Copyright (c) The Hayro Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+5 -1
View File
@@ -47,9 +47,13 @@ warn() {
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ASSETS.md; do
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
done
if [ -d "$ROOT/licenses" ]; then
mkdir -p "$dest/licenses"
cp -R "$ROOT/licenses/." "$dest/licenses/"
fi
copy_font_licences "$dest"
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more