Merge main into feat/help-bar-visibility

This commit is contained in:
dotCooCoo
2026-11-08 09:10:51 -08:00
1004 changed files with 136123 additions and 7455 deletions
+7
View File
@@ -1,2 +1,9 @@
[alias]
xtask = "run --package xtask --"
# WebAssembly SIMD for the web build: without it vello_cpu's SIMD layer (fearless_simd) runs its
# scalar fallback on wasm32 and the canvas renders about 2.4x slower (#2786). The web build then
# needs Chrome/Edge 91+, Firefox 89+ or Safari 16.4+. A RUSTFLAGS environment variable replaces
# these flags.
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+9 -1
View File
@@ -20,6 +20,12 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
@@ -60,7 +66,7 @@ jobs:
PY
release:
needs: git-sources
needs: [git-sources, credits]
permissions:
contents: write
packages: write
@@ -72,6 +78,7 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -82,6 +89,7 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
+56 -5
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# VectorCraft — instructions for agents
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
@@ -6,10 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Start every session here
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
2. `plan/` is an optional gitignored notebook. Behaviour notes may be in `plan/illustrator/*.md`. Do not ask for it and do not stop when it is absent. Orient, implement, test, verify, then record in the public docs.
## Non-negotiables
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
@@ -45,7 +95,6 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
@@ -88,3 +137,5 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+34 -17
View File
@@ -1,5 +1,21 @@
# Asset attribution
## Indic hyphenation patterns
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT (selected option) | Unmodified; full license in file |
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT (selected option) | Unmodified; full license in file |
Every non-code asset in this repository (icons, images, fonts, example art, colour profiles, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
@@ -115,6 +131,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-contour.svg` | VectorCraft contributors | Original work, drawn for VectorCraft (a solid rounded square inside four corner arcs, its offset outline); not derived from any other application's icon | MIT OR Apache-2.0 | |
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -358,20 +375,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These
| 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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-cmap/assets/cmaps.brotli | 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/f8f8829cc7be65d466d687dae381d1ca60e31242/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 |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-cmap/assets/cmaps.brotli | 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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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 |
+17
View File
@@ -0,0 +1,17 @@
# Attribution
The complete historical asset manifest is [ASSETS.md](ASSETS.md). These hyphenation assets use the MIT option, with the complete copyright and permission notices preserved in each file.
| Asset | Author | Source | SPDX license | Notes |
|---|---|---|---|---|
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT | Unmodified; full license in file |
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT | Unmodified; full license in file |
+1 -76
View File
@@ -1,76 +1 @@
# VectorCraft — instructions for agents
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Non-negotiables
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
- Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
- Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
- Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
- Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
- If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
- Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
- Every image, icon, font or other asset must be one of:
- original work created for VectorCraft by a contributor (who licenses it MIT OR Apache-2.0);
- OSI open source;
- public domain / CC0;
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap and progress docs
The progress docs:
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
- Keep the README's Status section in step with the ROADMAP headline.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+54 -26
View File
@@ -970,17 +970,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1003,7 +1003,7 @@ dependencies = [
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"egui",
"serde",
@@ -1013,7 +1013,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"hayro-interpret",
"hayro-syntax",
@@ -1024,7 +1024,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -1038,7 +1038,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1085,7 +1085,7 @@ dependencies = [
[[package]]
name = "craft-vectorcraft-format"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"flate2",
"serde",
@@ -1093,10 +1093,21 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -2206,7 +2217,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2215,7 +2226,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2233,7 +2244,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2251,12 +2262,12 @@ dependencies = [
[[package]]
name = "hayro-postscript"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -2864,7 +2875,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2967,8 +2978,7 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
source = "git+https://github.com/storytold/craft-libs?rev=481f66d287b2a06845f3667a19daf35f0fe958ed#481f66d287b2a06845f3667a19daf35f0fe958ed"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
@@ -6100,6 +6110,7 @@ dependencies = [
"serde_json",
"smithay-clipboard",
"socket2",
"tempfile",
"vectorcraft-engine",
"vectorcraft-render",
"vectorcraft-text",
@@ -6163,6 +6174,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"serde_json",
"tempfile",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-engine",
@@ -6252,6 +6264,7 @@ dependencies = [
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-js",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
@@ -6270,6 +6283,7 @@ dependencies = [
name = "vectorcraft-eps"
version = "0.21.0"
dependencies = [
"craft-pdf",
"fax",
"flate2",
"hayro-ccitt",
@@ -6284,6 +6298,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
@@ -6318,6 +6333,14 @@ dependencies = [
"vectorcraft-testkit",
]
[[package]]
name = "vectorcraft-js"
version = "0.21.0"
dependencies = [
"proptest",
"unicode-general-category",
]
[[package]]
name = "vectorcraft-mcp"
version = "0.21.0"
@@ -6399,8 +6422,10 @@ dependencies = [
name = "vectorcraft-plugins"
version = "0.21.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
@@ -6458,6 +6483,7 @@ dependencies = [
"resvg",
"serde",
"serde_json",
"simplecss",
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
@@ -6467,6 +6493,7 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-testkit",
"vectorcraft-text",
]
@@ -6589,6 +6616,7 @@ dependencies = [
name = "vectorcraft-web"
version = "0.21.0"
dependencies = [
"craft-web-api",
"eframe",
"egui",
"js-sys",
@@ -6754,12 +6782,12 @@ dependencies = [
[[package]]
name = "wasm-encoder"
version = "0.262.0"
version = "0.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f"
dependencies = [
"leb128fmt",
"wasmparser 0.262.0",
"wasmparser 0.263.0",
]
[[package]]
@@ -6814,9 +6842,9 @@ dependencies = [
[[package]]
name = "wasmparser"
version = "0.262.0"
version = "0.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb"
dependencies = [
"bitflags 2.13.2",
"indexmap",
@@ -6825,9 +6853,9 @@ dependencies = [
[[package]]
name = "wast"
version = "262.0.0"
version = "263.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7"
dependencies = [
"bumpalo",
"leb128fmt",
@@ -6838,9 +6866,9 @@ dependencies = [
[[package]]
name = "wat"
version = "1.262.0"
version = "1.263.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7"
dependencies = [
"wast",
]
+15 -13
View File
@@ -11,16 +11,16 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["egui"] }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" }
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
@@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
vectorcraft-js = { path = "crates/js" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -147,12 +148,13 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
linesweeper = { git = "https://github.com/storytold/craft-libs", rev = "481f66d287b2a06845f3667a19daf35f0fe958ed" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+13 -3
View File
@@ -20,10 +20,15 @@ list (author, source, license) is in ASSETS.md.
(Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font
(MIT). Their license texts ship with that crate.
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, egui, eframe, …) are
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
linesweeper 0.5.0 (MIT OR Apache-2.0) is fetched from https://github.com/storytold/craft-libs at
revision 481f66d287b2a06845f3667a19daf35f0fe958ed. The copy in craft-libs' vendor/linesweeper is
the crates.io package with no file changed, and it keeps the package's LICENSE-MIT and
LICENSE-APACHE.
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
based in part on the work of the Independent JPEG Group.
@@ -72,10 +77,10 @@ Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0
and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
f8f8829cc7be65d466d687dae381d1ca60e31242. Their upstream project is
9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb. 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/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/README.craft-reader.md.
https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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.
@@ -136,3 +141,8 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
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.
Indic hyphenation patterns (crates/text/patterns/):
Copyright (C) 2016 Santhosh Thottingal, used under the MIT license option.
Original copyright and full license notices are preserved in each hyph-*.tex file.
Source: hyphenation/tex-hyphen, commit 5684c0f51c0b81133db2efbe60a408b4155a3ff5.
+2 -2
View File
@@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
**What's missing:**
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
@@ -317,7 +317,7 @@ Installers and executables are code-signed.
| Build | File | Notes |
|---|---|---|
| Static site | `vectorcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
| Static site | `vectorcraft-web-<ver>.zip` | Runs in a browser with WebAssembly SIMD (Chrome/Edge 91, Firefox 89, Safari 16.4 or newer), see `packaging/web/README.md`; host it on any static server |
## The Crafting Apps
+14 -4
View File
@@ -1,5 +1,9 @@
# VectorCraft Roadmap
- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged.
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged.
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
@@ -11,7 +15,7 @@
**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-11-03 · **Change:** minor (independent Help Bar visibility; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates 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
@@ -20,8 +24,8 @@ and the work list in [`docs/gaps.md`](docs/gaps.md).
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
editing data, the interaction details have never been checked side by side, and some machines fail to start the
own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that
keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
@@ -104,7 +108,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| English | `en` | full | 100% |
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
| Spanish | `es` | full | 100% |
| Hindi | `hi` | none | 0% |
| Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | full | 100% |
@@ -131,6 +135,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
@@ -140,6 +146,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `<name>_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged.
- 2026-11-03: Text Tweaker, the first tool of the Type Tools add-on (Object › Plug-ins › Type Tools): one character attribute (size, baseline shift, rotation, horizontal or vertical scale, tracking, leading or stroke weight) goes letter by letter from a start value to an end value along a straight ramp, an ease, a bow, a spike or at random with a seed, set as the Character panel sets it, while the text stays live; a dialog with Preview, one undo step (`addon.type.textTweaker`, #4924). A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged.
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
@@ -217,6 +224,9 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
| 2026-11-02 | minor | Closed font-family fields accept wheel input for selected text in Character, Properties and Control (#5253); scores and estimates unchanged. |
| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
+1
View File
@@ -45,5 +45,6 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[dev-dependencies]
tempfile = "3"
vectorcraft-testkit = { workspace = true }
serde_json = { workspace = true }
+45 -19
View File
@@ -2,9 +2,9 @@
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -81,11 +81,12 @@ USAGE:
allowed, and a kind left out grants none of its access. They imply --headless: a running app
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
is a template, written as file.saveAsTemplate writes it). Raster exports use the document's
raster effects resolution unless --scale (pixels per point) or --ppi (pixels per inch, wins
over --scale) is given; other formats leave them out. Prints one JSON result per step.
--commands reads steps from FILE, or from standard input for -, where it stands among the
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
@@ -94,10 +95,10 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects
resolution; other formats leave them out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
--artboard or --range).
@@ -279,13 +280,15 @@ fn commands() -> Result<(), String> {
}
fn convert(args: &[String]) -> Result<(), String> {
let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
let valued =
["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
if help_requested(args, &valued) {
return help();
}
let mut files = vec![];
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) =
(1.0f64, None::<u64>, None::<String>, false, false, false, false);
(None::<f64>, None::<u64>, None::<String>, false, false, false, false);
let mut ppi = None::<f64>;
let mut decimals = None::<u64>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
@@ -293,7 +296,15 @@ fn convert(args: &[String]) -> Result<(), String> {
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
"--scale" | "-s" => scale = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?),
"--ppi" => {
ppi = Some(
it.next()
.and_then(|v| v.parse().ok())
.filter(|n: &f64| n.is_finite() && *n > 0.0)
.ok_or("--ppi needs a positive finite number")?,
);
}
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
"--outline-text" => outline_text = true,
@@ -326,7 +337,7 @@ fn convert(args: &[String]) -> Result<(), String> {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale);
let mut params = with_pixel_scale(params, &output, scale, ppi);
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
@@ -372,12 +383,17 @@ fn is_template(path: &str) -> bool {
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
}
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
/// The export `params` for `path`, with explicit raster resolution options only for raster formats.
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
/// its units unset.
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
fn with_pixel_scale(mut params: Value, path: &str, scale: Option<f64>, ppi: Option<f64>) -> Value {
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
params["scale"] = json!(scale);
if let Some(scale) = scale {
params["scale"] = json!(scale);
}
if let Some(ppi) = ppi {
params["ppi"] = json!(ppi);
}
}
params
}
@@ -485,7 +501,7 @@ fn command_step(v: Value) -> Result<Step, String> {
}
fn run(args: &[String]) -> Result<(), String> {
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s"];
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s", "--ppi"];
if help_requested(args, &valued) {
return help();
}
@@ -494,7 +510,8 @@ fn run(args: &[String]) -> Result<(), String> {
// The step of the `--cmd` just given, which a `--params` sets.
let mut params_for: Option<usize> = None;
let mut stdin_read = false;
let mut scale = 1.0;
let mut scale = None::<f64>;
let mut ppi = None::<f64>;
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
@@ -531,7 +548,16 @@ fn run(args: &[String]) -> Result<(), String> {
}
"--scale" | "-s" => {
let raw = val("a number")?;
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
scale = Some(raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?);
}
"--ppi" => {
let raw = val("a number")?;
ppi = Some(
raw.parse::<f64>()
.ok()
.filter(|n| n.is_finite() && *n > 0.0)
.ok_or_else(|| format!("--ppi {raw}: needs a positive finite number"))?,
);
}
other => return Err(format!("unknown run option `{other}`")),
}
@@ -576,7 +602,7 @@ fn run(args: &[String]) -> Result<(), String> {
let r = if is_template(&path) {
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
} else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale, ppi))
};
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
+44
View File
@@ -187,6 +187,48 @@ fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
}
/// Tracked rows for pointing at things in an n-path document: what hovering, clicking, marquee
/// selection and snapping cost when nothing narrows the search.
fn pointing(n: usize, rows: &mut Vec<(String, f64)>) -> Result<(), String> {
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(n)?, None);
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
s.select_tool("selection", view).map_err(err)?;
// Off the art (nothing under the pointer), the case every hover over empty canvas pays.
let off = Point::new(-200.0, -200.0);
let hover = |s: &mut vectorcraft_engine::Session| {
let _ = s.pointer(&PointerEvent::new(PointerKind::Move, off.x, off.y), view);
let _ = s.cursor(off, Mods::default(), view);
};
rows.push((format!("hover over empty canvas ({n} paths)"), median_ms(5, || hover(&mut s))));
s.execute("select.all", &json!({})).map_err(err)?;
rows.push((format!("hover over empty canvas, all selected ({n} paths)"), median_ms(5, || hover(&mut s))));
let doc = s.doc().map_err(err)?.doc.clone();
rows.push((format!("hit test, a miss ({n} paths)"), median_ms(5, || drop(hit::hit_test(&doc, off, hit::HitOptions::default())))));
let r = Rect::new(600.0, 450.0, 1000.0, 750.0);
rows.push((format!("marquee 400×300 ({n} paths)"), median_ms(5, || drop(hit::marquee(&doc, r, None, false)))));
let view_rect = Rect::new(0.0, 0.0, 1600.0, 1200.0);
rows.push((
format!("smart guide targets in view ({n} paths)"),
median_ms(5, || drop(vectorcraft_engine::tools::guides::Targets::collect(&doc, &[], Some(view_rect)))),
));
// Adding one more object to a selection of half the document: the command a shift-click ends
// in, without the pointer handling and hit test before it.
let ids: Vec<u64> = doc.layers[0].children().map(|c| c.iter().map(|n| n.id.0).collect()).unwrap_or_default();
s.execute("select.set", &json!({"ids": ids[..ids.len() / 2]})).map_err(err)?;
let mut k = ids.len() / 2;
rows.push((
format!("select.toggle in a {}-object selection", ids.len() / 2),
median_ms(5, || {
k += 1;
let _ = s.execute("select.toggle", &json!({"id": ids[k % ids.len()]}));
}),
));
Ok(())
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
/// the peak so far where `rss_mb` reads one.
@@ -282,6 +324,8 @@ fn deep(n: usize) -> Result<(), String> {
}),
));
note("after typing");
pointing(n, &mut rows)?;
note("after pointing");
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
// worker and a panel each draw them.
{
+32
View File
@@ -105,6 +105,38 @@ fn images_open_at_their_pixel_size() {
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
}
#[test]
fn raster_exports_use_document_resolution_unless_overridden() {
let source = tmp("resolution.vectorcraft");
ok(&["run", "--cmd", "file.new", "--params", r#"{"width":72,"height":36,"rasterEffectsPpi":300}"#, "--export", &source]);
for (flags, width, height) in [(vec![], 300u32, 150u32), (vec!["--scale", "1"], 72, 36), (vec!["--ppi", "150", "--scale", "2"], 150, 75)] {
for mode in ["run", "convert"] {
for format in ["png", "psd"] {
let path = tmp(&format!("resolution-{mode}-{width}.{format}"));
let mut args = if mode == "run" { vec!["run", "--in", &source, "--export", &path] } else { vec!["convert", &source, &path] };
args.extend_from_slice(&flags);
ok(&args);
let bytes = std::fs::read(&path).unwrap();
let dimensions = if format == "png" { &bytes[16..24] } else { &bytes[14..22] };
let expected =
if format == "png" { [width.to_be_bytes(), height.to_be_bytes()] } else { [height.to_be_bytes(), width.to_be_bytes()] };
assert_eq!(dimensions, expected.concat(), "{args:?}");
}
}
}
}
#[test]
fn raster_export_rejects_invalid_ppi() {
for ppi in ["0", "-150", "NaN", "inf", "high"] {
for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] {
let out = Command::new(BIN).args(&args).output().unwrap();
assert!(!out.status.success(), "{args:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}");
}
}
}
#[test]
fn help_lists_readable_formats() {
let out = ok(&["--help"]);
+2
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
egui = { workspace = true }
+84 -60
View File
@@ -1,14 +1,18 @@
//! `window.vectorcraft`: the control channel for the page, as promises.
//!
//! `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` come from
//! `craft-web-api` (storytold/craft-libs#413), the same in every Craft app; VectorCraft adds its own
//! functions on the same object.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
//! await vectorcraft.execute("shape.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
//! await vectorcraft.request("ui.inspect") // control method → its result
//! await vectorcraft.commands() // command list
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
//! const {data} = await vectorcraft.screenshot() // the canvas as PNG (base64)
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
//! vectorcraft.captureDownloads(true) // saves stay in the page…
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
@@ -17,21 +21,24 @@
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
use vectorcraft_ui_egui::{ControlRequest, ControlTicket as Ticket};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, future_to_promise};
use crate::saved_files::SavedFiles;
use crate::web::Inbox;
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
thread_local! {
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
@@ -69,77 +76,96 @@ fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(v) => {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
} else {
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
}
false
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired) gives the request up if the app has not started it, so the app never runs it late.
struct Waiting {
reply: Receiver<Value>,
ticket: Ticket,
answered: bool,
}
impl Waiting {
/// The app's reply once it has answered or gone away; `None` while the request is waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
self.answered = true;
Some(json!({"ok": false, "error": "request dropped"}))
}
});
});
}
}
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
if sent {
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
} else {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
impl Drop for Waiting {
fn drop(&mut self) {
if !self.answered {
self.ticket.give_up();
}
}
}
/// Queue a control request for the app and wait for its reply, as the protocol's `{ok, result}` /
/// `{ok: false, error}` object.
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
let (req, rx) = ControlRequest::new(method, params);
let ticket = req.ticket();
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
if !sent {
return Box::pin(async { json!({"ok": false, "error": "VectorCraft is not running yet"}) });
}
repaint();
let mut waiting = Waiting { reply: rx, ticket, answered: false };
Box::pin(async move {
loop {
if let Some(reply) = waiting.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
repaint();
p
let _ = JsFuture::from(promise).await;
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.vectorcraft`.
/// Install `window.vectorcraft`: the crate's functions, then VectorCraft's own (`inspect` is its
/// `ui.inspect`).
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
let request: Request = Rc::new(|method, params| send(&method, params));
Rc::new(PageApi::new("vectorcraft", Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &"vectorcraft".into())?.unchecked_into();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
"inspect",
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("ui.inspect", json!({})).await) })).into_js_value(),
)?;
set_fn(
&obj,
@@ -178,7 +204,5 @@ pub fn install() -> Result<(), JsValue> {
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
Ok(())
}
+40
View File
@@ -27,6 +27,19 @@ pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt
vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers)
}
/// Leave a paste to its Paste event (#5555). eframe's web runner reports a Cmd+V press both as the
/// key press and, with the clipboard's text, as a Paste event (the browser's `paste` comes in the
/// same task as the `keydown`, so both are in one frame's input); the app pastes on each, as egui's
/// window integration sends only the Paste. With a Paste in `events`, the paste key's press is
/// dropped; its release stays, so the paste chord is still followed (`PasteChord`). A Cmd+V without
/// a Paste (a clipboard with no text) keeps its press.
pub(crate) fn drop_paste_key_presses(events: &mut Vec<egui::Event>) {
if !events.iter().any(|e| matches!(e, egui::Event::Paste(_))) {
return;
}
events.retain(|e| !matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command));
}
/// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`).
#[cfg(target_arch = "wasm32")]
fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers {
@@ -115,4 +128,31 @@ mod tests {
assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true));
assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true));
}
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
#[test]
fn a_paste_chord_pastes_once() {
// Cmd+V and Cmd+Shift+V (Paste in Place): the press goes, the Paste and the release stay.
for mods in [cmd(), cmd() | Modifiers::SHIFT] {
let mut events = vec![key(Key::V, true, mods), egui::Event::Paste("<svg/>".into()), key(Key::V, false, mods)];
drop_paste_key_presses(&mut events);
assert_eq!(events, vec![egui::Event::Paste("<svg/>".into()), key(Key::V, false, mods)], "{mods:?}");
}
}
#[test]
fn keys_without_a_paste_stay() {
// No text on the clipboard: no Paste event, so the press is the paste chord's only report.
let mut events = vec![key(Key::V, true, cmd()), key(Key::V, false, cmd())];
drop_paste_key_presses(&mut events);
assert_eq!(events.len(), 2);
// Typing V, and other chords, beside a paste.
let typed = vec![key(Key::V, true, Modifiers::NONE), key(Key::C, true, cmd()), egui::Event::Paste("a".into())];
let mut events = typed.clone();
drop_paste_key_presses(&mut events);
assert_eq!(events, typed);
}
}
+2 -2
View File
@@ -370,7 +370,6 @@ impl eframe::App for WebShell {
}
let Some(app) = &mut self.app else { return };
app.tick(ctx);
crate::api::poll_replies();
app.finish_ime_logic(ctx);
}
@@ -378,6 +377,8 @@ impl eframe::App for WebShell {
/// shared input hook before egui runs.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
// A Cmd+V comes as its key and as a Paste: one paste (#5555).
crate::keys::drop_paste_key_presses(&mut raw.events);
if let Some(app) = &mut self.app {
app.raw_input_hook(ctx, raw);
}
@@ -421,7 +422,6 @@ impl eframe::App for WebShell {
}
app.ui(ui);
}
crate::api::poll_replies();
}
}
+4 -2
View File
@@ -56,8 +56,9 @@ pollster = "1"
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the
# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which
# src/caps_lock.rs uses to read whether Caps Lock is on.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`,
# which src/host_services.rs uses to read the path PhotoCraft registers under App Paths.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# The renderer's worker count follows those cores (src/windows_cores.rs).
vectorcraft-render = { workspace = true }
@@ -118,6 +119,7 @@ zbus = "=5.19.0"
winresource = "0.1"
[dev-dependencies]
tempfile = "3"
# Bound socket buffers in real loopback transport deadline regressions.
socket2 = { workspace = true }
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
+10
View File
@@ -51,6 +51,9 @@ impl eframe::App for App {
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(windows)]
windows_stylus::raw_input_hook(raw);
// The characters Alt + keypad codes typed (#5108).
#[cfg(windows)]
super::windows_alt_codes::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
// that no mouse button is down.
@@ -122,6 +125,13 @@ impl App {
}
open_files(&mut self.app, files);
}
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
if let Some(w) = &self.winit_window
&& window::monitors_due(self.monitors_listed, now, zoom)
{
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
self.monitors_listed = Some((now, zoom));
}
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
+29 -20
View File
@@ -157,6 +157,10 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
None if vectorcraft_engine::file_access::current().is_none() => {}
None => return None,
}
// A device or pipe may never end: only a regular file is read whole.
if !std::fs::metadata(&path).ok()?.is_file() {
return None;
}
let mut head = Vec::with_capacity(FILE_HEAD);
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
@@ -172,8 +176,7 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::{
BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of,
windows_formats,
BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
@@ -207,8 +210,6 @@ mod win {
pub struct Clipboard {
/// The clipboard's sequence number right after our last write.
seq: Option<NonZeroU32>,
/// The text of our last write.
text: Option<String>,
}
impl SystemClipboard for Clipboard {
@@ -239,18 +240,17 @@ mod win {
}
}
self.seq = raw::seq_num();
self.text = text_of(flavours);
result
}
fn holds_ours(&mut self) -> bool {
if self.seq.is_some() && raw::seq_num() == self.seq {
return true;
}
// A clipboard manager may have copied our data again as its own.
let Some(ours) = self.text.as_deref() else { return false };
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
// The same text copied later by another app is a different clipboard entry. A
// clipboard manager that republishes our data is also a new entry.
self.seq.is_some() && raw::seq_num() == self.seq
}
fn change_count(&mut self) -> Option<u64> {
raw::seq_num().map(|n| u64::from(n.get()))
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
@@ -978,10 +978,17 @@ mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vectorcraft-clipboard-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
fn dir(name: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("vectorcraft-clipboard-{name}-")).tempdir().unwrap()
}
#[test]
fn test_directory_is_removed_when_its_handle_is_dropped() {
let dir = dir("cleanup");
let path = dir.path().to_path_buf();
assert!(path.exists());
drop(dir);
assert!(!path.exists());
}
fn png(w: u32, h: u32) -> Vec<u8> {
@@ -993,12 +1000,12 @@ mod tests {
#[test]
fn the_first_copied_file_that_is_art_pastes() {
let d = dir("art");
let (notes, red, art) = (d.join("notes.txt"), d.join("red image.png"), d.join("art.svg"));
let (notes, red, art) = (d.path().join("notes.txt"), d.path().join("red image.png"), d.path().join("art.svg"));
std::fs::write(&notes, "not art").unwrap();
std::fs::write(&red, png(3, 2)).unwrap();
std::fs::write(&art, "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
// Missing files, folders and text are skipped.
let f = copied_file(vec![d.join("gone.png"), d.clone(), notes.clone(), red.clone(), art.clone()], &PASTE_ORDER).unwrap();
let f = copied_file(vec![d.path().join("gone.png"), d.path().to_path_buf(), notes.clone(), red.clone(), art.clone()], &PASTE_ORDER).unwrap();
assert_eq!((f.mime, f.data), (PNG, png(3, 2)));
assert_eq!(copied_file(vec![art.clone(), red.clone()], &PASTE_ORDER).map(|f| f.mime), Some(SVG));
// Only what Paste asks for.
@@ -1032,7 +1039,8 @@ mod tests {
/// An .eps file copied in a file manager pastes as EPS.
#[test]
fn a_copied_eps_file_pastes() {
let eps = dir("eps").join("equation.eps");
let d = dir("eps");
let eps = d.path().join("equation.eps");
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
@@ -1040,7 +1048,8 @@ mod tests {
#[test]
fn a_crlf_left_by_the_uri_list_is_ignored() {
let red = dir("crlf").join("red.png");
let d = dir("crlf");
let red = d.path().join("red.png");
std::fs::write(&red, png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", red.display()));
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
+8 -10
View File
@@ -150,18 +150,16 @@ mod tests {
#[cfg(target_os = "linux")]
#[test]
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("vectorcraft-dev-char-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let dir = tempfile::Builder::new().prefix("vectorcraft-dev-char-").tempdir()?;
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
std::fs::create_dir_all(dir.join("226:128/device"))?;
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
std::fs::create_dir_all(dir.path().join("226:128/device"))?;
std::fs::write(dir.path().join("226:128/device/vendor"), "0x10de\n")?;
std::fs::write(dir.path().join("226:128/device/device"), "0x25ac\n")?;
// A device that isn't a PCI GPU (a virtual one) has no ids.
std::fs::create_dir_all(dir.join("226:0/device"))?;
let render = super::pci_ids(&dir, makedev(226, 128));
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
let missing = super::pci_ids(&dir, makedev(226, 1));
std::fs::remove_dir_all(&dir)?;
std::fs::create_dir_all(dir.path().join("226:0/device"))?;
let render = super::pci_ids(dir.path(), makedev(226, 128));
let virtual_gpu = super::pci_ids(dir.path(), makedev(226, 0));
let missing = super::pci_ids(dir.path(), makedev(226, 1));
assert_eq!(render, Some((0x10de, 0x25ac)));
assert_eq!(virtual_gpu, None);
assert_eq!(missing, None);
+28
View File
@@ -152,6 +152,7 @@ fn spawn_connection(
fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option<Value>) {
let Ok(read) = stream.try_clone() else { return };
let closer = stream.try_clone().ok();
let read = DeadlineReader::new(read, timeouts.idle, timeouts.line);
// Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining
// budget costs one socket option call per piece, not one per JSON token.
@@ -166,6 +167,33 @@ fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl Fn
},
DeadlineWriter::begin_reply,
);
if let Some(s) = closer {
close_gracefully(s);
}
}
/// How long [`close_gracefully`] waits for the peer to close its side.
const CLOSE_DRAIN: Duration = Duration::from_secs(1);
/// The most [`close_gracefully`] reads from a peer that keeps sending.
const MAX_DRAIN: usize = 1 << 20;
/// Ends a connection: no more replies (the peer reads them all, then end of stream), then what the
/// peer still sends is read and dropped until it closes too, for at most [`CLOSE_DRAIN`] and
/// [`MAX_DRAIN`] bytes. On Windows, dropping a socket with unread input resets the connection, and
/// the reset can discard replies the peer has not read yet.
fn close_gracefully(mut s: TcpStream) {
// Each step is best effort: a peer that is already gone needs none of them.
let _ = s.shutdown(std::net::Shutdown::Write);
let _ = s.set_read_timeout(Some(CLOSE_DRAIN));
let deadline = std::time::Instant::now() + CLOSE_DRAIN;
let (mut buf, mut left) = ([0u8; 4096], MAX_DRAIN);
while left > 0 && std::time::Instant::now() < deadline {
match s.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => left = left.saturating_sub(n),
}
}
}
/// One parsed line.
+10 -4
View File
@@ -58,7 +58,12 @@ fn idle_control_socket_releases_its_worker() {
fn idle_expiry_closes_without_writing_a_line() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
let mut got = Vec::new();
client.read_to_end(&mut got).unwrap();
match client.read_to_end(&mut got) {
// On Windows the server's close can reach the client as a reset.
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {}
Ok(_) => {}
Err(e) => panic!("reading the idle connection: {e}"),
}
assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection");
expect_socket_deadline(client, done, worker);
}
@@ -329,12 +334,13 @@ fn tcp_connection_is_closed_after_junk() {
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
serve_socket(s, ConnectionTimeouts::default(), |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(WAIT)).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
// The server closes gracefully ([`close_gracefully`]): the client reads both replies and then
// the end of the stream, not a reset.
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
+32 -7
View File
@@ -753,13 +753,22 @@ enum FirstFrame {
Shown,
/// Overdue.
Hung,
/// Overdue, but the UI is still building the first frame: the app's own work (on a slow
/// machine, or with antivirus reading every font file), not the adapter, which has been given
/// nothing to show yet.
Busy,
}
/// The window's start after `frames` frames run, `since` its first frame was due.
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
/// The window's start after `frames` frames run, `since` its first frame was due and
/// `since_built` the first frame's UI pass ended (`None` while it runs). A frame counts once its
/// UI pass ends, before it is painted, so the adapter's time starts there: with no frame run, the
/// UI thread is in the app's code, and starting again without the adapter wouldn't help (#5160).
fn first_frame(frames: u64, since: Duration, since_built: Option<Duration>, minimized: bool) -> FirstFrame {
if frames >= 2 || minimized {
FirstFrame::Shown
} else if since >= FIRST_FRAME_TIMEOUT {
} else if frames == 0 {
if since >= FIRST_FRAME_TIMEOUT { FirstFrame::Busy } else { FirstFrame::Wait }
} else if since_built.unwrap_or(since) >= FIRST_FRAME_TIMEOUT {
FirstFrame::Hung
} else {
FirstFrame::Wait
@@ -767,18 +776,34 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
}
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after its UI pass
/// ended, start the app again without the adapter, as [`finish`] does for one that failed, and end
/// this process, whose UI thread is stuck in the graphics driver. A first frame still being built
/// that long is waited for, with a note in the log.
pub fn watch_first_frame(startup: Arc<Startup>) {
let mut built: Option<Instant> = None;
let mut noted = false;
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
let Some(due) = startup.ready.get() else { continue };
let ui = startup.ui.get();
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
if frames >= 1 && built.is_none() {
built = Some(Instant::now());
}
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
match first_frame(frames, due.elapsed(), built.map(|t| t.elapsed()), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Busy => {
if !noted {
log::warn!(
"the window's first frame is still being built after {}s: waiting for it (the app's work, not the graphics adapter)",
FIRST_FRAME_TIMEOUT.as_secs()
);
noted = true;
}
continue;
}
FirstFrame::Shown => {
startup.presented();
return;
+36 -21
View File
@@ -242,8 +242,8 @@ fn internal_panels_are_edp_lvds_and_dsi_connectors() {
/// PCI ids. Each GPU is listed once, the one with the panel as primary.
#[test]
fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let temp = tempfile::Builder::new().prefix("vectorcraft-drm-").tempdir()?;
let dir = temp.path();
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name).join("device"))?;
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
@@ -262,11 +262,11 @@ fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()
connector("card1-DP-2", "connected")?;
connector("card2-Virtual-1", "connected")?;
std::fs::write(dir.join("renderD128"), "")?;
let mut gpus = linux_display_gpus(&dir);
let mut gpus = linux_display_gpus(dir);
gpus.sort_by_key(|g| g.pci);
assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]);
assert!(linux_display_gpus(&dir.join("no such dir")).is_empty());
std::fs::remove_dir_all(&dir)
Ok(())
}
#[test]
@@ -366,14 +366,19 @@ fn gpus_without_pci_ids_are_told_apart_by_name() {
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
let s = Duration::from_secs;
// The second frame began: the first was presented.
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
for frames in [0, 1] {
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
}
assert_eq!(first_frame(2, s(60), Some(s(1)), false), FirstFrame::Shown);
// The first frame built and not yet presented: waited for, then given up on (#964), counting
// from when its UI pass ended.
assert_eq!(first_frame(1, s(3), Some(s(1)), false), FirstFrame::Wait);
assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, Some(FIRST_FRAME_TIMEOUT), false), FirstFrame::Hung);
assert_eq!(first_frame(1, s(45), Some(s(5)), false), FirstFrame::Wait, "a first frame built slowly gets its full time to show");
assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Hung);
// The first frame still being built: the app's work, not the adapter's (#5160).
assert_eq!(first_frame(0, s(3), None, false), FirstFrame::Wait);
assert_eq!(first_frame(0, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Busy);
assert_eq!(first_frame(0, s(600), None, false), FirstFrame::Busy, "never a restart without an adapter");
// A window minimized at the start may not draw again until restored.
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
assert_eq!(first_frame(1, s(60), Some(s(60)), true), FirstFrame::Shown);
// A hung adapter is left out once, like one that failed.
let skip = vec!["Dx12:10de:28a0".to_string()];
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
@@ -611,11 +616,26 @@ fn a_normal_exit_or_another_failure_does_not_restart() {
/// A log folder of its own for each test, and the guard that keeps other tests from starting a
/// program while it runs: the test releases the start file's lock and takes it again, and a child
/// starting meanwhile would keep it held (`crate::no_spawns`).
fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) {
struct TestDir(tempfile::TempDir);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &Self::Target {
self.0.path()
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
self.0.path()
}
}
fn start_dir(tag: &str) -> (TestDir, std::sync::RwLockReadGuard<'static, ()>) {
let spawns = crate::no_spawns();
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
(d, spawns)
let dir = tempfile::Builder::new().prefix(&format!("vectorcraft-gpu-start-{tag}-")).tempdir().unwrap();
(TestDir(dir), spawns)
}
/// What a start in `dir` leaves out; it then ends without crashing.
@@ -665,7 +685,6 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
assert_eq!(left_out.crash, None, "the note is shown once");
third.ended();
let _ = std::fs::remove_dir_all(&dir);
}
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
@@ -688,7 +707,6 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
assert!(finish(Ok(Ok(())), &first).is_ok());
drop(first);
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
@@ -716,7 +734,6 @@ fn a_crash_while_listing_lists_opengl_alone() {
second.none_left();
second.ended();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
@@ -737,7 +754,6 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
explicit.picked(&c[0].key, &c[0].name, true);
explicit.presented();
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
let _ = std::fs::remove_dir_all(&dir);
}
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
@@ -772,10 +788,9 @@ fn a_damaged_or_unreadable_start_file_is_ignored() {
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
// A folder in the file's place.
let _ = std::fs::remove_dir_all(&dir);
std::fs::remove_file(dir.join(START_FILE)).unwrap();
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
+232 -45
View File
@@ -44,19 +44,50 @@ macro_rules! dialog_settings {
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// On Linux and the BSDs the dialog is GTK's or the desktop portal's, which match a file type's
/// `*.ext` patterns case-sensitively.
const CASE_SENSITIVE_FILTERS: bool =
cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"));
/// The extensions an open dialog's filter is given for `exts`: with `any_case`, each extension
/// that has a letter is followed by a pattern with each letter as a `[xX]` pair, so the filter for
/// `dib` also lists `image.DIB` and `image.Dib`.
fn open_extensions(exts: &[impl AsRef<str>], any_case: bool) -> Vec<String> {
let mut out = Vec::with_capacity(exts.len() * 2);
for e in exts {
let e = e.as_ref();
out.push(e.to_string());
if any_case && e.chars().any(|c| c.is_ascii_alphabetic()) {
out.push(
e.chars()
.map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() })
.collect(),
);
}
}
out
}
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
d.filter(
vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name),
&open_extensions(exts, CASE_SENSITIVE_FILTERS),
)
});
}
PickRequest::Folder => return d,
};
let any_case = CASE_SENSITIVE_FILTERS && matches!(request, PickRequest::Open(_));
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
d.filter(
vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()),
&open_extensions(exts, any_case),
)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
@@ -151,6 +182,46 @@ fn reveal_command(path: &str) -> std::process::Command {
c
}
/// Read a whole file. A device or pipe may never end: only a regular file is read.
fn read_file(path: &str) -> Result<Vec<u8>, String> {
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
return Err(format!("{path}: not a file"));
}
std::fs::read(path).map_err(|e| e.to_string())
}
#[cfg(test)]
#[test]
fn read_file_refuses_what_is_not_a_regular_file() {
assert!(read_file(&std::env::temp_dir().to_string_lossy()).is_err());
#[cfg(unix)]
assert!(read_file("/dev/zero").is_err());
}
/// Read the first `max` bytes of a file (all of it when it is shorter), and nothing past them. Only
/// a regular file is read.
fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>, String> {
use std::io::Read;
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
return Err(format!("{path}: not a file"));
}
let mut out = vec![];
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| e.to_string())?;
Ok(out)
}
#[cfg(test)]
#[test]
fn read_file_head_reads_no_more_than_it_is_asked_for() {
use std::io::Write as _;
let mut f = tempfile::Builder::new().prefix("vectorcraft-read-head-").tempfile().unwrap();
f.write_all(b"0123456789").unwrap();
let p = f.path().to_string_lossy();
assert_eq!(read_file_head(&p, 4).unwrap(), b"0123");
assert_eq!(read_file_head(&p, 20).unwrap(), b"0123456789", "a shorter file is read whole");
assert!(read_file_head(&std::env::temp_dir().to_string_lossy(), 4).is_err(), "a folder is not read");
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
@@ -227,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }),
Ok(mac_save_format::Begin::DeferToRfd) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic
// on that nil; treat the pick as cancelled instead (#5980).
Err(()) => Box::pin(async move { Vec::new() }),
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
@@ -277,7 +351,9 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(windows)]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
read: Some(Box::new(read_file)),
// Load Actions… refuses a file over its size limit without reading all of it.
read_head: Some(Box::new(read_file_head)),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
@@ -445,24 +521,13 @@ pub(crate) fn windows_candidates(
}
/// The paths PhotoCraft's installer registers under App Paths, for the current user and then for
/// everyone (`reg query`, so no extra dependency; a hidden console window).
/// everyone.
#[cfg(windows)]
fn registered_photocraft() -> Vec<std::path::PathBuf> {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// reg.exe by its full path, so nothing named `reg` in the app's folder or on PATH runs instead.
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into());
["HKCU", "HKLM"]
use windows::Win32::System::Registry::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
[HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
.into_iter()
.filter_map(|hive| {
let key = format!(r"{hive}\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe");
let out = std::process::Command::new(root.join("System32").join("reg.exe"))
.args(["query", &key, "/ve"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
registered_path(&String::from_utf8_lossy(&out.stdout))
})
.filter_map(|hive| registry_default_path(hive, r"Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe"))
.collect()
}
@@ -471,11 +536,35 @@ fn registered_photocraft() -> Vec<std::path::PathBuf> {
Vec::new()
}
/// The path in `reg query … /ve` output like
/// ` (Default) REG_SZ C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`.
/// The default `REG_SZ` value of `subkey` under `hive` as a path ([`registered_path`]), or `None`
/// when the key or the value is missing. The value is read as UTF-16, so a folder name in any
/// script comes back as stored.
#[cfg(windows)]
#[allow(unsafe_code)]
fn registry_default_path(hive: windows::Win32::System::Registry::HKEY, subkey: &str) -> Option<std::path::PathBuf> {
use windows::Win32::System::Registry::{RRF_RT_REG_SZ, RegGetValueW};
use windows::core::PCWSTR;
let subkey: Vec<u16> = subkey.encode_utf16().chain([0]).collect();
let mut size = 0u32;
// SAFETY: `subkey` is a NUL-terminated UTF-16 string that outlives the call, a null value name
// reads the default value, and with no buffer the call only writes the value's size to `size`.
unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, None, Some(&mut size)) }.ok().ok()?;
let mut value = vec![0u16; (size as usize).div_ceil(2)];
let mut size = u32::try_from(value.len() * 2).ok()?;
// SAFETY: as above, and `value` holds the `size` bytes the call may write.
unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, Some(value.as_mut_ptr().cast()), Some(&mut size)) }
.ok()
.ok()?;
registered_path(&value)
}
/// The path in a registry string value read as UTF-16, up to its first NUL, without surrounding
/// spaces or quotes, like `C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`.
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
pub(crate) fn registered_path(text: &str) -> Option<std::path::PathBuf> {
text.lines().find_map(|l| l.split_once("REG_SZ")).map(|(_, v)| v.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from)
pub(crate) fn registered_path(value: &[u16]) -> Option<std::path::PathBuf> {
let end = value.iter().position(|&c| c == 0).unwrap_or(value.len());
let text = String::from_utf16(&value[..end]).ok()?;
Some(text.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from)
}
/// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on
@@ -509,22 +598,20 @@ mod open_with_tests {
use super::{check_app, find_photocraft, photocraft_candidates, registered_path, windows_candidates};
/// A fresh, empty folder under the system's temporary folder.
fn scratch(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-open-with-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
fn scratch(name: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("vc-open-with-{name}-")).tempdir().unwrap()
}
/// #5236: only an application counts as PhotoCraft, never a document, a folder or a missing
/// path.
#[test]
fn only_applications_are_started() {
let d = scratch("check");
let temp = scratch("check");
let d = temp.path();
let doc = d.join("notes.txt");
std::fs::write(&doc, "hi").unwrap();
assert!(check_app(&doc).is_err(), "a document");
assert!(check_app(&d).is_err(), "a folder");
assert!(check_app(d).is_err(), "a folder");
assert!(check_app(&d.join("Missing.app")).is_err(), "a missing path");
if cfg!(target_os = "macos") {
let app = d.join("Editor.app");
@@ -540,19 +627,19 @@ mod open_with_tests {
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(check_app(&tool).is_ok(), "an executable file");
}
let _ = std::fs::remove_dir_all(&d);
}
/// PhotoCraft is looked for only where it installs.
#[test]
fn photocraft_is_found_where_it_installs() {
let d = scratch("find");
let temp = scratch("find");
let d = temp.path();
if cfg!(target_os = "macos") {
let candidates = photocraft_candidates(Some(&d), None);
let candidates = photocraft_candidates(Some(d), None);
assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app"));
assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}");
std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap();
assert!(find_photocraft(Some(&d), None).unwrap().ends_with("PhotoCraft.app"));
assert!(find_photocraft(Some(d), None).unwrap().ends_with("PhotoCraft.app"));
} else {
let bin = d.join("bin");
std::fs::create_dir_all(&bin).unwrap();
@@ -576,7 +663,6 @@ mod open_with_tests {
assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe));
}
}
let _ = std::fs::remove_dir_all(&d);
}
/// #5327 review: on Windows PhotoCraft is looked for where its installer puts it: the path it
@@ -600,17 +686,118 @@ mod open_with_tests {
);
}
/// The registered path is read from `reg query … /ve` output, spaces and quotes included;
/// no value, no path.
/// The registered path is read from the UTF-16 value up to its NUL, spaces, quotes and
/// characters outside ASCII included; an empty value or one that is not UTF-16 gives no path.
#[test]
fn the_registered_path_is_read_from_reg_query() {
let out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\ArtCraft\\PhotoCraft\\photocraft.exe\r\n";
assert_eq!(registered_path(out), Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")));
fn the_registered_path_is_read_from_the_utf16_value() {
let wide = |s: &str| s.encode_utf16().chain([0, 0]).collect::<Vec<u16>>();
assert_eq!(
registered_path(" (Standard) REG_SZ \"C:\\A B\\photocraft.exe\""),
Some(std::path::PathBuf::from(r"C:\A B\photocraft.exe"))
registered_path(&wide(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")),
Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe"))
);
assert_eq!(registered_path("ERROR: The system was unable to find the specified registry key or value."), None);
assert_eq!(registered_path(" (Default) REG_SZ "), None);
assert_eq!(
registered_path(&wide(" \"C:\\Prögram Fïles\\Тест\\写真\\photocraft.exe\" ")),
Some(std::path::PathBuf::from(r"C:\Prögram Fïles\Тест\写真\photocraft.exe"))
);
assert_eq!(registered_path(&wide("")), None);
assert_eq!(registered_path(&[0xD800, 0]), None);
}
/// On Windows a registered path with characters outside the console's code page is read as
/// stored, from a scratch key under the current user's hive.
#[cfg(windows)]
#[test]
fn a_registered_path_outside_ascii_is_read_as_stored() {
let key = format!(r"Software\VectorCraftTest-app-paths-{}", std::process::id());
let path = r"C:\Prögram Fïles\Тест\写真\photocraft.exe";
let reg = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()).join(r"System32\reg.exe");
let full = format!(r"HKCU\{key}");
let added = std::process::Command::new(&reg).args(["add", &full, "/ve", "/t", "REG_SZ", "/d", path, "/f"]).output().unwrap();
assert!(added.status.success(), "{added:?}");
let read = super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key);
let _ = std::process::Command::new(&reg).args(["delete", &full, "/f"]).output();
assert_eq!(read, Some(std::path::PathBuf::from(path)));
assert_eq!(super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key), None, "deleted");
}
}
#[cfg(test)]
mod dialog_tests {
use super::{CASE_SENSITIVE_FILTERS, DialogSettings, FilePick, PickRequest, dialog_for, fileio, open_extensions};
/// The file types a dialog was given, by label.
#[derive(Default)]
struct Filters(Vec<(String, Vec<String>)>);
impl DialogSettings for Filters {
fn parent(self, _window: &winit::window::Window) -> Self {
self
}
fn title(self, _title: &str) -> Self {
self
}
fn filter(mut self, name: &str, exts: &[impl ToString]) -> Self {
self.0.push((name.to_string(), exts.iter().map(ToString::to_string).collect()));
self
}
fn folder(self, _folder: &str) -> Self {
self
}
fn file_name(self, _name: &str) -> Self {
self
}
}
/// Does the case-sensitive pattern `*.{ext}` match `name`? `ext` may hold literal characters,
/// `*` and `[..]` sets.
fn matches(ext: &str, name: &str) -> bool {
fn here(p: &[char], n: &[char]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some(('*', rest)) => (0..=n.len()).any(|i| here(rest, &n[i..])),
Some(('[', rest)) => {
let Some(end) = rest.iter().position(|&c| c == ']') else { return false };
n.split_first().is_some_and(|(c, n)| rest[..end].contains(c) && here(&rest[end + 1..], n))
}
Some((c, rest)) => n.split_first().is_some_and(|(d, n)| d == c && here(rest, n)),
}
}
let pattern: Vec<char> = format!("*.{ext}").chars().collect();
here(&pattern, &name.chars().collect::<Vec<_>>())
}
/// Where filters match case-sensitively, every extension of File › Open and Place lists files
/// whose extension is in capitals or mixed case, `image.DIB` and `image.Pns` included.
#[test]
fn open_and_place_filters_list_extensions_in_any_case() {
let all = fileio::open_filters().chain(fileio::place_filters());
for (label, exts) in all {
let patterns = open_extensions(exts, true);
for ext in exts {
let mut mixed = ext.to_ascii_uppercase();
mixed.replace_range(..1, &ext[..1]);
for name in [format!("a.{ext}"), format!("a.{}", ext.to_ascii_uppercase()), format!("a.{mixed}")] {
assert!(patterns.iter().any(|p| matches(p, &name)), "{label}: {patterns:?} don't match {name}");
}
}
}
assert_eq!(open_extensions(&["dib", "jp2", "*"], true), ["dib", "[dD][iI][bB]", "jp2", "[jJ][pP]2", "*"]);
assert_eq!(open_extensions(&["dib", "PNS"], false), ["dib", "PNS"]);
}
/// Open and Place dialogs get the any-case patterns on Linux and the BSDs; a save dialog's file
/// types stay as listed.
#[test]
fn open_dialogs_get_any_case_patterns_where_filters_are_case_sensitive() {
let dib = vec![FilePick::filter("BMP", &["bmp", "dib"])];
let pick = FilePick { filters: dib.clone(), ..Default::default() };
let expected = |any_case: bool| open_extensions(&["bmp", "dib"], any_case);
let opened = dialog_for(Filters::default(), &PickRequest::Open(pick.clone())).0;
assert_eq!(opened[0].1, expected(CASE_SENSITIVE_FILTERS));
let saved = dialog_for(Filters::default(), &PickRequest::Save(pick)).0;
assert_eq!(saved, dib);
let placed = dialog_for(Filters::default(), &PickRequest::OpenMany).0;
let first = fileio::place_filters().next().map(|(_, exts)| open_extensions(exts, CASE_SENSITIVE_FILTERS));
assert_eq!(placed.first().map(|(_, exts)| exts.clone()), first);
}
}
+23 -6
View File
@@ -1,12 +1,29 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
struct TestPath {
_parent: tempfile::TempDir,
path: PathBuf,
}
impl std::ops::Deref for TestPath {
type Target = Path;
fn deref(&self) -> &Self::Target {
&self.path
}
}
impl AsRef<Path> for TestPath {
fn as_ref(&self) -> &Path {
&self.path
}
}
fn temp_dir(tag: &str) -> TestPath {
let parent = tempfile::Builder::new().prefix(&format!("vectorcraft-logging-{tag}-")).tempdir().unwrap();
let path = parent.path().join("log");
TestPath { _parent: parent, path }
}
fn read(path: &Path) -> String {
+2 -4
View File
@@ -63,11 +63,10 @@ mod tests {
/// fonts, is listed while it is registered.
#[test]
fn fonts_registered_from_other_folders_are_listed() {
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = tempfile::Builder::new().prefix("vectorcraft-mac-fonts-").tempdir().unwrap();
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
// installs.
let file = dir.join("Registered Font.ttf");
let file = dir.path().join("Registered Font.ttf");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
let want = std::fs::canonicalize(&file).unwrap();
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
@@ -78,7 +77,6 @@ mod tests {
let registered = listed();
// SAFETY: as above.
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
std::fs::remove_dir_all(&dir).unwrap();
assert!(registered);
}
}
+58 -11
View File
@@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
@@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
/// How [`begin`] decided to show a save sheet.
#[derive(Debug)]
pub enum Begin {
/// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled).
Shown(Receiver<Vec<String>>),
/// Fewer than two named file types: the caller should show an rfd panel instead.
DeferToRfd,
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu.
///
/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has
/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel.
/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel`
/// panics on the same nil (#5980).
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result<Begin, ()> {
let Some(mtm) = MainThreadMarker::new() else {
return Ok(Begin::DeferToRfd);
};
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
return Ok(Begin::DeferToRfd);
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else {
return Ok(Begin::DeferToRfd);
};
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let Some(parent) = view.window() else {
return Ok(Begin::DeferToRfd);
};
let Some(panel) = new_panel(mtm) else {
log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)");
return Err(());
};
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
@@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
Ok(Begin::Shown(rx))
}
/// An `NSSavePanel` without panicking when AppKit returns nil (#5980).
///
/// Prefers `-[NSSavePanel init]` (Apple's current Swift API) over `+[NSSavePanel savePanel]`,
/// which objc2 turns into a panic on nil. Falls back to the class method, still as `Option`.
fn new_panel(mtm: MainThreadMarker) -> Option<Retained<NSSavePanel>> {
// SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses.
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] };
if panel.is_some() {
return panel;
}
// SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a
// panic (unlike `NSSavePanel::savePanel`).
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::class(), savePanel] };
panel
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
@@ -234,6 +271,16 @@ mod tests {
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
}
/// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here
/// (#5980); `new_panel` types the same message send as `Option`.
#[test]
fn a_null_save_panel_pointer_is_none_not_a_panic() {
let null: *mut NSSavePanel = std::ptr::null_mut();
// SAFETY: retaining a null object pointer is defined to yield None.
let panel: Option<Retained<NSSavePanel>> = unsafe { Retained::retain(null) };
assert!(panel.is_none());
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
+11 -2
View File
@@ -56,6 +56,7 @@ mod system_fonts;
mod tablet;
mod window;
mod window_frame;
mod windows_alt_codes;
#[cfg(windows)]
mod windows_cores;
mod windows_stylus;
@@ -110,6 +111,8 @@ struct App {
pending_maximize: bool,
/// Frames left to retry [`window::apply_pending_maximize`].
maximize_tries: u8,
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
monitors_listed: Option<(f64, f32)>,
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
@@ -314,13 +317,18 @@ fn main() -> std::process::ExitCode {
};
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
// characters Alt + keypad codes type (`windows_alt_codes`).
#[cfg(windows)]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_msg_hook(windows_stylus::msg_hook);
builder.with_msg_hook(|msg| {
// Alt + keypad codes (#5108), which winit drops; neither hook consumes a message.
windows_alt_codes::msg_hook(msg);
windows_stylus::msg_hook(msg)
});
}));
options
};
@@ -499,6 +507,7 @@ fn main() -> std::process::ExitCode {
winit_window,
pending_maximize,
maximize_tries: 8,
monitors_listed: None,
}))
}),
)
+14 -5
View File
@@ -96,6 +96,12 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
// A name that isn't UTF-8 can't be written as picked: replacing its bytes would save over (and
// ask to replace) another file. The dialog ends without a file instead.
let Ok(path) = String::from_utf8(path) else {
log::warn!("the portal's save dialog picked a file name that isn't UTF-8: nothing is saved");
return Ok(None);
};
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
@@ -103,9 +109,9 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
/// or another scheme.
fn file_uri_path(uri: &str) -> Option<String> {
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere
/// (`file://host/`) or another scheme.
fn file_uri_path(uri: &str) -> Option<Vec<u8>> {
let rest = uri.strip_prefix("file://")?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
@@ -126,8 +132,7 @@ fn file_uri_path(uri: &str) -> Option<String> {
}
}
}
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
Some(String::from_utf8_lossy(&out).into_owned())
Some(out)
}
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
@@ -406,6 +411,7 @@ mod tests {
#[test]
fn file_uris_become_local_paths() {
let file_uri_path = |uri: &str| super::file_uri_path(uri).map(|b| String::from_utf8(b).unwrap());
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
@@ -413,6 +419,9 @@ mod tests {
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
assert_eq!(file_uri_path("file://host/x.svg"), None);
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
// Not UTF-8: the bytes as they are, which `answer` refuses rather than changing them.
assert_eq!(super::file_uri_path("file:///tmp/a%FF.svg"), Some(b"/tmp/a\xff.svg".to_vec()));
assert!(matches!(answer(0, &results("file:///tmp/a%FF.svg", None)), Ok(None)));
}
#[test]
+19 -1
View File
@@ -62,7 +62,12 @@ pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str)
use std::io::Write as _;
for n in 1..100u32 {
let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") };
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
// The preferences hold recent document paths: the copy is owner-only, whatever the umask.
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
match options.open(path.with_file_name(name)) {
Ok(mut file) => {
if file.write_all(bytes).is_ok() {
return;
@@ -324,6 +329,8 @@ pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
/// [`prefs_bytes`] as JSON.
fn prefs_value(app: &VectorcraftApp) -> serde_json::Result<serde_json::Value> {
let mut ui = app.ui.clone();
// An open dialog is never restored, and its fields can hold passwords (PDF import and save).
ui.dialog = None;
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_value(&ui)
}
@@ -336,6 +343,17 @@ mod tests {
/// Two windows open at once (#4625): the one that saves last keeps the other's changes and
/// adds its own, instead of putting back the preferences it started from.
#[cfg(unix)]
#[test]
fn a_kept_preferences_copy_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::Builder::new().prefix("vc-prefs-copy-").tempdir().unwrap();
let dir = temp.path();
super::keep_prefs_copy(&dir.join("ui.json"), b"{}", "ui.unreadable");
let mode = std::fs::metadata(dir.join("ui.unreadable.json")).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_window_saves_only_what_it_changed_over_another_windows_save() {
let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}});
+48 -8
View File
@@ -149,6 +149,15 @@ fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
}
}
/// Make folder `dir`, owner-only on Unix.
fn private_dir(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
let entries = match std::fs::read_dir(from) {
Ok(entries) => entries,
@@ -165,7 +174,12 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
// A symbolic link is copied as what it points to, or skipped when that is a folder.
let copied = match entry.file_type() {
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
// The copy keeps the folder's permissions (a private recovery folder stays private):
// owner-only while it fills, then the source's.
Ok(kind) if kind.is_dir() => private_dir(&dst).and_then(|()| {
copy_dir_into(&src, &dst, skip);
std::fs::set_permissions(&dst, std::fs::metadata(&src)?.permissions())
}),
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
Err(e) => Err(e),
};
@@ -178,7 +192,22 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct TestDir(tempfile::TempDir);
impl std::ops::Deref for TestDir {
type Target = Path;
fn deref(&self) -> &Self::Target {
self.0.path()
}
}
impl AsRef<Path> for TestDir {
fn as_ref(&self) -> &Path {
self.0.path()
}
}
fn environment(key: &str) -> Option<OsString> {
match key {
@@ -273,12 +302,8 @@ mod tests {
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
}
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
fn temp(tag: &str) -> TestDir {
TestDir(tempfile::Builder::new().prefix(&format!("vectorcraft-profile-{tag}-")).tempdir().unwrap())
}
#[test]
@@ -387,4 +412,19 @@ mod tests {
let _ = std::fs::remove_dir_all(&from);
let _ = std::fs::remove_dir_all(&to);
}
#[cfg(unix)]
#[test]
fn a_copied_folder_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt as _;
let from = temp("copy-mode-from");
let to = temp("copy-mode-to");
std::fs::create_dir_all(from.join("recovery")).unwrap();
std::fs::write(from.join("recovery/copy.vectorcraft"), b"unsaved").unwrap();
std::fs::set_permissions(from.join("recovery"), std::fs::Permissions::from_mode(0o700)).unwrap();
copy_dir(&from, &to).unwrap();
assert_eq!(std::fs::metadata(to.join("recovery")).unwrap().permissions().mode() & 0o777, 0o700);
let _ = std::fs::remove_dir_all(&from);
let _ = std::fs::remove_dir_all(&to);
}
}
+25 -17
View File
@@ -263,15 +263,23 @@ mod tests {
use super::*;
struct TestDir(tempfile::TempDir);
impl std::ops::Deref for TestDir {
type Target = Path;
fn deref(&self) -> &Self::Target {
self.0.path()
}
}
/// A fresh folder for one test, with an existing file in it.
fn folder(name: &str) -> (PathBuf, PathBuf, String) {
let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("a b.svg");
fn folder(name: &str) -> (PathBuf, TestDir, String) {
let dir = tempfile::Builder::new().prefix(&format!("vc-single-instance-{name}-")).tempdir().unwrap();
let file = dir.path().join("a b.svg");
fs::write(&file, "<svg/>").unwrap();
let file = file.to_string_lossy().into_owned();
(dir.join(FILE_NAME), dir, file)
(dir.path().join(FILE_NAME), TestDir(dir), file)
}
fn primary(path: &Path) -> (Primary, mpsc::Receiver<Vec<String>>) {
@@ -303,12 +311,11 @@ mod tests {
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
drop(primary);
assert!(!path.exists(), "the endpoint file goes with the app");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
let (path, dir, file) = folder("refused");
let (path, _dir, file) = folder("refused");
let (_primary, rx) = primary(&path);
let token = read_endpoint(&path).unwrap().token;
let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] }));
@@ -323,16 +330,20 @@ mod tests {
// The listener still serves a launch with the right token.
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_stale_endpoint_file_is_ignored_and_replaced() {
let (path, dir, file) = folder("stale");
// No listener on the recorded port any more.
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
let (path, _dir, file) = folder("stale");
// The recorded app is gone: whatever holds the port closes without a greeting. (A freed
// port can be taken by another test's listener meanwhile, which would then accept this
// connection in place of its own client's.)
let gone = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = gone.local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
let closed = std::thread::spawn(move || drop(gone.accept()));
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
closed.join().unwrap();
// A listener that doesn't know the greeting gets neither the token nor the paths.
let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = impostor.local_addr().unwrap().port();
@@ -352,18 +363,16 @@ mod tests {
let (_primary, rx) = primary(&path);
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() {
let (path, dir, _) = folder("replaced");
let (path, _dir, _) = folder("replaced");
let (first, _rx1) = primary(&path);
let (_second, _rx2) = primary(&path);
let second_token = read_endpoint(&path).unwrap().token;
drop(first);
assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token));
let _ = fs::remove_dir_all(dir);
}
#[test]
@@ -378,9 +387,8 @@ mod tests {
#[test]
fn the_endpoint_file_is_the_users_alone() {
use std::os::unix::fs::PermissionsExt as _;
let (path, dir, _) = folder("mode");
let (path, _dir, _) = folder("mode");
let _primary = primary(&path);
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = fs::remove_dir_all(dir);
}
}
+2 -3
View File
@@ -284,8 +284,8 @@ mod tests {
#[test]
fn fonts_only_gdi_has_loaded_are_listed_by_their_files() {
use windows::Win32::Graphics::Gdi::{AddFontResourceExW, FR_PRIVATE, RemoveFontResourceExW};
let dir = std::env::temp_dir().join(format!("vc-gdi-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let temp = tempfile::Builder::new().prefix("vc-gdi-fonts-").tempdir().unwrap();
let dir = temp.path();
let file = dir.join("29457");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf");
let mut data = std::fs::read(source).unwrap();
@@ -303,7 +303,6 @@ mod tests {
assert_eq!(unsafe { AddFontResourceExW(&path, FR_PRIVATE, None) }, 1);
let files = super::font_files();
let _ = unsafe { RemoveFontResourceExW(&path, FR_PRIVATE.0, None) };
let _ = std::fs::remove_dir_all(&dir);
let wanted = file.to_string_lossy().to_lowercase();
assert!(files.iter().any(|f| f.to_lowercase() == wanted), "{wanted} not in {} files", files.len());
}
+2 -4
View File
@@ -99,9 +99,8 @@ fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
#[test]
fn an_unreadable_ui_json_is_kept_beside_it() {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let temp = tempfile::Builder::new().prefix("vectorcraft-ui-prefs-").tempdir().unwrap();
let dir = temp.path();
let path = dir.join("ui.json");
super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable");
super::keep_prefs_copy(&path, b"not json", "ui.unreadable");
@@ -109,7 +108,6 @@ fn an_unreadable_ui_json_is_kept_beside_it() {
let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap();
let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap();
let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced");
assert_eq!(second, b"not json");
assert_eq!(before, b"{}");
+85
View File
@@ -3,6 +3,7 @@
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
//! onto a monitor that is still connected.
use vectorcraft_ui_egui::DockMonitor;
use vectorcraft_ui_egui::state::WindowGeometry;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
@@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs.
const MONITORS_EVERY: f64 = 1.0;
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
@@ -67,6 +71,15 @@ impl Monitor {
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
/// This monitor for floating panels, in the points egui reports for a window on it: physical
/// pixels divided by the monitor's scale factor and the UI zoom.
fn docking(&self, name: Option<String>, zoom: f32) -> DockMonitor {
let scale = self.scale * zoom;
let [x, y] = self.pos;
let [w, h] = self.size;
DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale }
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
@@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most
/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom.
pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool {
listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY)
}
/// The connected monitors for floating panels (`Services::dock_monitors`).
pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec<DockMonitor> {
let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect();
docking_list(&monitors, zoom, cfg!(target_os = "macos"))
}
/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is
/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the
/// scale of the monitor it is created on, which can put a window saved on another scale off every
/// monitor. With an empty list craft-ui opens the window on the main window's monitor.
///
/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS
/// can give two displays of one model the same name.
fn docking_list(monitors: &[(Monitor, Option<String>)], zoom: f32, shared_points: bool) -> Vec<DockMonitor> {
if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) {
return Vec::new();
}
let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1;
monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect()
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
/// until the host reports maximized (fixes #3635).
@@ -204,6 +244,51 @@ mod tests {
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() {
let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom);
let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0);
assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 });
// A window at (3000, 200) on this monitor reports (1500, 100).
let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0);
assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 });
// The UI zoom divides what egui reports as well.
let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25);
assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 });
}
#[test]
fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() {
let named = |m: Monitor, name: &str| (m, Some(name.to_string()));
let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")];
assert_eq!(docking_list(&same, 1.0, false).len(), 2);
let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")];
assert_eq!(docking_list(&mixed, 1.0, false), Vec::new());
assert_eq!(docking_list(&mixed, 1.0, true).len(), 2);
assert_eq!(docking_list(&[], 1.0, false), Vec::new());
}
#[test]
fn a_name_that_two_monitors_share_is_left_out_of_the_list() {
let list = [
(monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
(monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
(monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())),
(monitor(5760, 0, 1920, 1080, 1.0), None),
];
let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect();
assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]);
}
#[test]
fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() {
assert!(monitors_due(None, 0.0, 1.0));
assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0));
assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0));
assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0));
assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25));
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
+225
View File
@@ -0,0 +1,225 @@
//! Alt + numeric-keypad codes on Windows (#5108): holding Alt, typing a decimal code on the
//! numeric keypad (NumLock on) and releasing Alt types that character (`Alt+0176` → `°`).
//!
//! Windows does the work: when Alt comes up after keypad digits, `TranslateMessage` posts a
//! `WM_CHAR` holding the character the code stands for (in the system's ANSI code page for a code
//! with a leading 0, else its OEM code page), the convention every Windows text field follows
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input>). winit
//! 0.30 reads a `WM_CHAR` only as part of the key press before it, so it drops this one, which
//! follows a key release, and egui never sees the text. winit's message hook ([`msg_hook`]) sees
//! the message before winit handles it: [`AltCodes`] notes keypad digits typed with Alt held, takes
//! the character that follows Alt's release, and [`raw_input_hook`] hands it to the frame as typed
//! text, so the Type tool (or a panel's text field) types it like any other character, in the
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
//! so they don't run a shortcut bound to Alt and a digit.
//!
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
//! platforms, whose systems have no such convention, can't tell them apart either.
#![cfg_attr(not(windows), allow(dead_code))]
use std::cell::RefCell;
use egui::{Event, Key};
const WM_KEYDOWN: u32 = 0x0100;
const WM_KEYUP: u32 = 0x0101;
const WM_CHAR: u32 = 0x0102;
const WM_SYSKEYDOWN: u32 = 0x0104;
const WM_SYSKEYUP: u32 = 0x0105;
const WM_SYSCHAR: u32 = 0x0106;
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
/// are Insert, End, … and type no code.
const VK_NUMPAD0: usize = 0x60;
const VK_NUMPAD9: usize = 0x69;
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
thread_local! {
/// The UI thread's sequence: [`msg_hook`] and [`raw_input_hook`] both run on it.
static CODES: RefCell<AltCodes> = RefCell::new(AltCodes::default());
}
/// Where an Alt + keypad code stands, from the keyboard messages of the thread's queue.
#[derive(Default)]
struct AltCodes {
/// Keypad digits typed while Alt has been held.
digits: usize,
/// Alt came up after keypad digits: the next character message is the code's character.
armed: bool,
/// A high surrogate waiting for its low half.
high: Option<u16>,
/// Characters the codes typed, for the next frame.
typed: String,
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
/// as Alt and a digit: taken out of the frame.
keypad_events: usize,
}
impl AltCodes {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
fn message(&mut self, message: u32, wparam: usize) {
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
match message {
// Windows sends a key pressed with Alt held as a system key.
WM_SYSKEYDOWN if keypad => {
self.digits += 1;
self.keypad_events += 1;
self.armed = false;
}
WM_SYSKEYUP if keypad => self.keypad_events += 1,
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
// Any other key ends the sequence and keeps its own meaning.
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
WM_KEYUP | WM_SYSKEYUP if ALT_KEYS.contains(&wparam) => {
self.armed = self.digits > 0;
self.digits = 0;
}
WM_CHAR | WM_SYSCHAR if self.armed => {
let unit = (wparam & 0xffff) as u16;
if (0xd800..0xdc00).contains(&unit) {
self.high = Some(unit);
return;
}
let units: Vec<u16> = self.high.take().into_iter().chain([unit]).collect();
self.typed.extend(char::decode_utf16(units).filter_map(Result::ok).filter(|c| !c.is_control()));
self.armed = false;
}
_ => {}
}
}
fn reset(&mut self) {
self.digits = 0;
self.armed = false;
self.high = None;
}
/// Hand the frame `events` the characters typed since the last frame, as typed text, and take
/// out the keypad digits typed with Alt held.
fn drain(&mut self, events: &mut Vec<Event>) {
let mut keypad = std::mem::take(&mut self.keypad_events);
if keypad > 0 {
events.retain(|e| {
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
if digit && keypad > 0 {
keypad -= 1;
return false;
}
true
});
}
if !self.typed.is_empty() {
events.push(Event::Text(std::mem::take(&mut self.typed)));
}
}
}
/// winit's message hook for the keyboard: notes Alt + keypad codes and the characters they type.
/// It never consumes the message.
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) {
use windows::Win32::UI::WindowsAndMessaging::MSG;
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
// call; `as_ref` gives `None` for a null pointer.
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
return;
};
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.message(msg.message, msg.wParam.0);
}
});
}
}
/// Before egui reads the main window's frame: the characters Alt + keypad codes typed, as text.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.drain(&mut raw.events);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
const ALT: usize = 0x12;
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT);
for d in code.bytes() {
let vk = VK_NUMPAD0 + usize::from(d - b'0');
c.message(WM_SYSKEYDOWN, vk);
c.message(WM_SYSKEYUP, vk);
}
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(posted));
}
fn key(key: Key, alt: bool, pressed: bool) -> Event {
let modifiers = egui::Modifiers { alt, ..Default::default() };
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
}
#[test]
fn an_alt_code_types_its_character_when_alt_comes_up() {
let mut c = AltCodes::default();
alt_code(&mut c, "0176", 0xB0);
alt_code(&mut c, "0151", 0x2014);
let mut events = vec![key(Key::Num0, true, true), key(Key::Num0, true, false), key(Key::Num1, false, true)];
c.drain(&mut events);
// The keypad digits typed with Alt are gone (eight presses and eight releases, two here);
// the digit typed without Alt stays, and the characters follow.
assert_eq!(events, vec![key(Key::Num1, false, true), Event::Text("°—".into())]);
let mut events = vec![key(Key::Num0, true, true)];
c.drain(&mut events);
assert_eq!(events.len(), 1, "counts don't carry over to a later frame");
}
#[test]
fn a_character_without_a_code_before_it_is_left_to_winit() {
let mut c = AltCodes::default();
// A plain key press's character (winit types it), and Alt pressed and released alone.
c.message(WM_KEYDOWN, 0x41);
c.message(WM_CHAR, usize::from(b'a'));
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(b'b'));
let mut events = vec![];
c.drain(&mut events);
assert!(events.is_empty(), "{events:?}");
}
#[test]
fn another_key_while_alt_is_held_cancels_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
// Alt+F: a shortcut, not a code.
c.message(WM_SYSKEYDOWN, 0x46);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0x41);
let mut events = vec![];
c.drain(&mut events);
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
}
#[test]
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0xD83D);
c.message(WM_CHAR, 0xDE00);
let mut events = vec![];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("😀".into())]);
}
}
+50 -13
View File
@@ -16,6 +16,14 @@
//! mouse message that Windows did not make from a pen or a touch
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
//! clears the feed.
//!
//! The same `POINTER_PEN_INFO` gives the pen's tilt and barrel rotation (#372: a 6D Art Pen turns a
//! Calligraphic brush's nib with its barrel), each only where its `PEN_MASK_*` bit says the pen
//! reports it (<https://learn.microsoft.com/en-us/windows/win32/inputmsg/pen-mask-constants>):
//! `tiltX` and `tiltY` in degrees −90..90, positive to the right and toward the user, and
//! `rotation` in degrees 0..359 clockwise
//! (<https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-pointer_pen_info>).
//! Toward the user is down the screen, so they are [`PenPose`]'s axes and direction as they come.
#![cfg_attr(not(windows), allow(dead_code))]
use std::cell::RefCell;
@@ -37,6 +45,11 @@ const PEN_FLAG_INVERTED: u32 = 0x0002;
const PEN_FLAG_ERASER: u32 = 0x0004;
/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure.
const PEN_MASK_PRESSURE: u32 = 0x0001;
/// `PEN_MASK_ROTATION`, `PEN_MASK_TILT_X` and `PEN_MASK_TILT_Y` (`winuser.h`): the pen reports its
/// barrel rotation, its tilt along x and its tilt along y.
const PEN_MASK_ROTATION: u32 = 0x0002;
const PEN_MASK_TILT_X: u32 = 0x0004;
const PEN_MASK_TILT_Y: u32 = 0x0008;
/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows
/// made from a pen or a touch.
const MI_WP_SIGNATURE: u32 = 0xFF51_5700;
@@ -94,7 +107,8 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
// pointer that is not a pen.
unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?;
let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0;
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact))
let pose = pen_pose(info.penMask, info.tiltX, info.tiltY, info.rotation);
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact, pose))
};
// SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message.
let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32);
@@ -106,15 +120,25 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags`
/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen
/// in contact that reports none; and the eraser end while it is inverted or its eraser button is
/// pressed.
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample {
/// in contact that reports none; the eraser end while it is inverted or its eraser button is
/// pressed; and how it is held, `pose` ([`pen_pose`]).
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool, pose: PenPose) -> PenSample {
let pressure = match pressure {
1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0,
_ if in_contact => 1.0,
_ => 0.0,
};
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose }
}
/// How a pen whose `POINTER_PEN_INFO` holds `pen_mask`, `tilt_x`, `tilt_y` and `rotation` is
/// held: the tilt when the pen reports it along either axis (0 along an axis it doesn't), the
/// rotation when it reports one, each kept within [`PenPose`]'s ranges.
fn pen_pose(pen_mask: u32, tilt_x: i32, tilt_y: i32, rotation: u32) -> PenPose {
let along = |mask: u32, t: i32| if pen_mask & mask != 0 { t as f32 } else { 0.0 };
let tilt = (pen_mask & (PEN_MASK_TILT_X | PEN_MASK_TILT_Y) != 0).then(|| [along(PEN_MASK_TILT_X, tilt_x), along(PEN_MASK_TILT_Y, tilt_y)]);
let rotation = (pen_mask & PEN_MASK_ROTATION != 0).then_some(rotation as f32);
PenPose { tilt, rotation }.sanitized()
}
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
@@ -390,7 +414,7 @@ mod tests {
#[test]
fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() {
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser;
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true, PenPose::default()).eraser;
assert!(!eraser(0), "the tip");
assert!(!eraser(PEN_FLAG_BARREL), "the barrel button");
assert!(eraser(PEN_FLAG_INVERTED), "turned over");
@@ -399,19 +423,32 @@ mod tests {
assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL));
}
#[test]
fn a_pens_tilt_and_rotation_come_where_it_reports_them() {
let all = PEN_MASK_TILT_X | PEN_MASK_TILT_Y | PEN_MASK_ROTATION;
assert_eq!(pen_pose(all, 30, -45, 90), PenPose { tilt: Some([30.0, -45.0]), rotation: Some(90.0) });
assert_eq!(pen_pose(PEN_MASK_PRESSURE, 30, -45, 90), PenPose::default(), "a pen that reports neither");
assert_eq!(pen_pose(PEN_MASK_TILT_X, 30, -45, 90), PenPose { tilt: Some([30.0, 0.0]), rotation: None }, "one axis");
assert_eq!(pen_pose(PEN_MASK_ROTATION, 0, 0, 359), PenPose { tilt: None, rotation: Some(359.0) }, "a 6D Art Pen's barrel");
let wild = pen_pose(all, 400, i32::MIN, 720);
assert_eq!(wild, PenPose { tilt: Some([90.0, -90.0]), rotation: Some(0.0) }, "kept within the ranges");
let pose = pen_pose(all, 10, 20, 180);
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE | all, 0, true, pose).pose, pose);
}
#[test]
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 0.5);
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0);
assert_eq!(
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false, PenPose::default()),
PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },
"hovering"
);
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0);
assert_eq!(pen_sample(0, 0, 0, true, PenPose::default()).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
assert_eq!(pen_sample(300, 0, 0, true, PenPose::default()).pressure, 1.0, "a pressure without the pressure mask is not one");
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0, "as winit, which gives no force for 0");
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false, PenPose::default()).pressure, 0.0);
}
#[test]
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke-dasharray="7.854 7" stroke-dashoffset="7.854"/></svg>

After

Width:  |  Height:  |  Size: 347 B

+27
View File
@@ -193,3 +193,30 @@ sync_github_name = true
[people.JonRC]
real_name = "Jonathan Cardoso"
display_name = "Jonathan Cardoso"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.FrancoJacquet]
real_name = "Franco Jacquet"
[people.FractalEncrypt]
sync_github_name = true
[people.sadkotheguest]
real_name = "Alexander Buraga"
display_name = "Alexander Buraga"
sync_github_name = false
[people.adikshan11]
real_name = "Adithya Shankaran"
sync_github_name = true
[people.slafebre]
real_name = "Sven Lafebre"
[people.kylekanouse]
real_name = "Kyle Kanouse"
sync_github_name = true
+29 -17
View File
@@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g)),
Some(m) => Some((img.transform.then(m), g, stop_reach(g))),
None => return false,
},
Source::Solid(_) => None,
@@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
}
let (s, sa) = match (solid, &to_unit) {
(Some(s), _) => s,
(None, Some((m, g))) => {
(None, Some((m, g, reach))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, q);
let c = sample(g, reach, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
@@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`:
/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and
/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not.
fn stop_reach(g: &Gradient) -> Vec<f64> {
g.stops
.iter()
.skip(1)
.scan(f64::NEG_INFINITY, |m, s| {
*m = m.max(s.offset);
Some(*m)
})
.collect()
}
/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`).
fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color {
let t = match g.kind {
GradientKind::Linear => q.x,
GradientKind::Radial => q.x.hypot(q.y),
@@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color {
if t <= first.offset {
return first.color;
}
for w in g.stops.windows(2) {
let [a, b] = [&w[0], &w[1]];
if t <= b.offset {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
let k = reach.partition_point(|r| *r < t);
if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
last.color
}
+19 -7
View File
@@ -13,15 +13,26 @@ fn luminance(c: &Color) -> f32 {
}
fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
let lum = luminance(c);
// Black ink alone is its own tint: its amount is the tint, not how light it displays (100% K
// displays lighter than black).
let light = match *c {
Color::Cmyk { c, m, y, k } if c == 0.0 && m == 0.0 && y == 0.0 => 1.0 - k,
_ => luminance(c),
};
// A process or gray stroke color's tints and shades stay in its model.
let (white, black) = match key {
Color::Cmyk { .. } => (Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)),
Color::Gray { .. } => (Color::gray(0.0), Color::gray(1.0)),
_ => (Color::WHITE, Color::BLACK),
};
match mode {
Colorization::None => *c,
Colorization::Tints => key.lerp(&Color::WHITE, lum),
Colorization::Tints => key.lerp_keep_model(&white, light),
Colorization::TintsAndShades => {
if lum < 0.5 {
Color::BLACK.lerp(key, lum * 2.0)
if light < 0.5 {
key.lerp_keep_model(&black, 1.0 - light * 2.0)
} else {
key.lerp(&Color::WHITE, (lum - 0.5) * 2.0)
key.lerp_keep_model(&white, (light - 0.5) * 2.0)
}
}
Colorization::HueShift { key: art_key } => {
@@ -132,9 +143,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
/// opacity (how far the art mixes towards it). `None` for an unstained instance.
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
let f = inst.appearance.fill().filter(|f| f.visible)?;
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
Some((f.paint.color()?, f.opacity))
}
+14 -5
View File
@@ -475,6 +475,10 @@ pub struct PatternBrush {
pub side_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_swatch: Option<String>,
/// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner`
/// is the art for the corners where it turns the other way. Both are drawn as the corner that
/// joins a side tile on its right to one below it, and each corner tile is turned so that
/// those two arms meet the sides.
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -781,10 +785,9 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String {
return base.to_string();
}
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
(2..=lib.len() + 2)
.map(|i| format!("{base} {i}"))
.find(|n| !lib.iter().any(|b| &b.name == n))
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
// Each is looked up in a set: scanning the library per candidate is quadratic in its size.
let taken: std::collections::HashSet<&str> = lib.iter().map(|b| b.name.as_str()).collect();
(2..=lib.len() + 2).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n.as_str())).unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
}
// ---------- geometry ----------
@@ -849,9 +852,15 @@ fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
};
// Without recursion, each one looked at once: they may use one another without end.
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
// A brush name the library lacks draws a plain stroke ([`node_pieces`]), which runs no brush.
let mut names: Option<std::collections::HashSet<String>> = None;
let mut resolves = |n: &Node| {
let names = names.get_or_insert_with(|| library(doc).into_iter().map(|b| b.name).collect());
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.as_ref().is_some_and(|b| names.contains(b))))
};
let mut answer = false;
while let Some(n) = stack.pop() {
if has_brush(n) {
if has_brush(n) && resolves(n) {
answer = true;
break;
}
+173 -4
View File
@@ -1,6 +1,6 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes};
use super::*;
@@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() {
}
}
/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the
/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so
/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have
/// art; `flips` are the brush's Flip Along and Flip Across.
fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush {
let marker =
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0));
if inner {
(p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame);
}
if outer {
(p.outer_corner, p.outer_corner_frame) = (Some(marker), frame);
}
(p.flip_along, p.flip_across) = (flip_along, flip_across);
}
b
}
/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips.
fn marked_corner_brushes() -> Vec<(String, Brush)> {
let mut v = vec![];
for (inner, outer) in [(true, true), (true, false), (false, true)] {
for flips in [(false, false), (true, false), (false, true), (true, true)] {
v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips)));
}
}
v
}
/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not.
fn marker_bounds(out: &[Node]) -> Vec<Rect> {
out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect()
}
/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path:
/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner
/// outside the turn, with the brush flipped or not and with art for one kind of corner only
/// (#6110).
#[test]
fn pattern_corner_tiles_are_square_to_the_sides() {
let doc = Document::new(1.0, 1.0);
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let mut reversed = BezPath::new();
reversed.move_to((0.0, 0.0));
for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] {
reversed.line_to((x, y));
}
reversed.close_path();
// The square turns the same way at all four corners, and the reversed square the other way,
// so one draws only inner corner tiles and the other only outer ones.
for (name, brush) in marked_corner_brushes() {
for path in [&sq, &reversed] {
let out = stroke_pieces(&doc, &brush, path, &stroke(1.0));
let marks = marker_bounds(&out);
assert_eq!(marks.len(), 4, "{name}: one marker a corner");
for (corner, outside) in SQUARE_CORNERS {
let want = Rect::from_points(corner, outside);
assert!(
marks
.iter()
.any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)),
"{name}: the marker at {corner:?} fills {want:?}: {marks:?}"
);
}
}
}
}
/// At a corner that is not a right angle, a corner tile splits the difference between the two
/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and
/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path
/// arrives on.
#[test]
fn pattern_corner_tiles_sit_on_the_corners_diagonal() {
let doc = Document::new(1.0, 1.0);
for (name, brush) in marked_corner_brushes() {
for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] {
let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin()));
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((50.0, 0.0));
bp.line_to(Point::new(50.0, 0.0) + b * 50.0);
let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0));
let marks = marker_bounds(&out);
assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker");
let away = a - b;
let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt();
assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center());
}
}
}
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
let mut b = pattern_brush(PatternFit::Stretch, false);
@@ -701,6 +796,55 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Black ink alone is a tint by its amount: a process black stroke leaves it as it is, and another
/// process stroke color takes that share of its inks. Tints of a process color stay process.
#[test]
fn tints_of_black_ink_are_its_amount_of_the_stroke_color() {
let tinted = |art: Color, stroke: Color| {
let mut n = rect_art(1.0, 1.0);
n.appearance.set_fill(Paint::solid(art));
colorize(&mut n, Colorization::Tints, &Paint::solid(stroke));
n.appearance.fill_paint().color().unwrap()
};
let ink = Color::cmyk(0.0, 0.0, 0.0, 0.6);
let close = |a: Color, b: Color| {
let (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) = (a, b) else { panic!("{a:?} {b:?}") };
(c - c2).abs() < 1e-5 && (m - m2).abs() < 1e-5 && (y - y2).abs() < 1e-5 && (k - k2).abs() < 1e-5
};
assert!(close(tinted(ink, Color::cmyk(0.0, 0.0, 0.0, 1.0)), ink));
assert!(close(tinted(ink, Color::cmyk(0.0, 1.0, 1.0, 0.0)), Color::cmyk(0.0, 0.6, 0.6, 0.0)));
let [r, g, b] = tinted(ink, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
assert!((r - 1.0).abs() < 1e-5 && (g - 0.4).abs() < 1e-5 && (b - 0.4).abs() < 1e-5, "{r} {g} {b}");
let Color::Gray { k } = tinted(ink, Color::gray(0.5)) else { panic!("tints of a gray stroke color stay gray") };
assert!((k - 0.3).abs() < 1e-5, "{k}");
}
/// Under Tints and Shades black ink stays black, however light 100% K displays, and a band of black
/// ink is its amount: K 0.5 is the stroke color. A process or gray stroke color stays in its model.
#[test]
fn tints_and_shades_of_black_ink_go_by_its_amount() {
let mapped = |k: f32, stroke: Color| {
let mut n = rect_art(1.0, 1.0);
n.appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, k)));
colorize(&mut n, Colorization::TintsAndShades, &Paint::solid(stroke));
n.appearance.fill_paint().color().unwrap()
};
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5);
let inks = |c: Color| {
let Color::Cmyk { c, m, y, k } = c else { panic!("a process color: {c:?}") };
[c, m, y, k]
};
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
assert!(close(inks(mapped(1.0, orange)), [0.0, 0.0, 0.0, 1.0]), "black stays black");
assert!(close(inks(mapped(0.5, orange)), [0.0, 0.6, 1.0, 0.0]), "the stroke color");
assert!(close(inks(mapped(0.25, orange)), [0.0, 0.3, 0.5, 0.0]), "a tint");
assert!(close(inks(mapped(0.75, orange)), [0.0, 0.3, 0.5, 0.5]), "a shade");
let Color::Gray { k } = mapped(0.75, Color::gray(0.4)) else { panic!("a gray stroke color stays gray") };
assert!((k - 0.7).abs() < 1e-5, "{k}");
let [r, g, b] = mapped(1.0, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
assert!(r < 1e-5 && g < 1e-5 && b < 1e-5, "black stays black: {r} {g} {b}");
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
@@ -764,6 +908,26 @@ fn stain_mixes_type_strokes_too() {
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
/// An instance whose fill has an opacity of 0 does not stain its symbol's art, so the art keeps its
/// swatch links.
#[test]
fn a_fill_with_no_opacity_leaves_the_symbol_art_linked() {
let spot = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4), swatch: Some("Spot".into()), tint: 1.0 };
let art = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(spot.clone(), Paint::None, 0.0));
let mut inst = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::IDENTITY });
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
if let Some(f) = inst.appearance.fill_mut() {
f.opacity = 0.0;
}
assert_eq!(stain(&inst), None);
assert_eq!(instance_art(&art, &inst).appearance.fill_paint(), spot);
// Any opacity above 0 still stains.
if let Some(f) = inst.appearance.fill_mut() {
f.opacity = 0.01;
}
assert_ne!(instance_art(&art, &inst).appearance.fill_paint(), spot);
}
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
@@ -1301,6 +1465,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
};
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
store(&mut doc, std::slice::from_ref(&b));
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
@@ -1325,7 +1490,10 @@ fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() {
let mut doc = Document::new(100.0, 100.0);
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
// A brush the library lacks draws a plain stroke: its mask stays.
let mut unresolved = rect_art(10.0, 4.0);
unresolved.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("missing".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed), ("unresolved", unresolved)] {
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art) });
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
@@ -1334,8 +1502,9 @@ fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() {
n.extras_mut().mask = Some(Box::new(OpacityMask::new(instance(symbol), true)));
n
};
let art = Node::group(NodeId(0), [masked("plain"), masked("brushed")].map(std::sync::Arc::new).into());
let art = Node::group(NodeId(0), [masked("plain"), masked("brushed"), masked("unresolved")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
store(&mut doc, std::slice::from_ref(&b));
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
@@ -1348,7 +1517,7 @@ fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() {
_ => None,
})
.collect();
assert_eq!(roots, [Some("plain"), None], "the plain mask stays and the brushed one goes");
assert_eq!(roots, [Some("plain"), None, Some("unresolved")], "the plain and unresolved masks stay and the brushed one goes");
}
}
+2 -1
View File
@@ -236,7 +236,8 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
return out;
}
let mut els = vec![];
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| {
// Guarded: kurbo works out every subdivision of a curve even when the callback drops them.
vectorcraft_geom::flatten(bp.iter(), tol.max(1e-4), |el| {
if els.len() < MAX_FLATTENED {
els.push(el);
}
+11 -7
View File
@@ -103,7 +103,7 @@ fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut pts = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
vectorcraft_geom::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e {
pts.push(q);
}
@@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
};
trims.push(None);
corner_half = corner_half.max(bb.width() * k / 2.0);
let bis = a + b;
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
let xf = Affine::translate(t.verts[i].to_vec2())
* Affine::rotate(bis.y.atan2(bis.x))
* Affine::scale(k)
* Affine::translate(-bb.center().to_vec2());
// Inner and outer corner art is the corner that joins a side tile on its right to one
// below it, so its arms point along +x and +y (as the flips leave them). The tile is
// turned so that the line halving its arms lies on the line halving the sides at the
// corner, back along the incoming side and on along the outgoing one: at a right angle
// the arms meet the side tiles, and at other angles the difference is split between
// the two sides (#6110).
let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 });
let sides = b - a;
let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x);
let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2());
let mut n = art.clone();
n.transform(xf, true);
out.take(Some(n));
+13 -6
View File
@@ -43,6 +43,7 @@ const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out";
const TOO_MUCH_BLOCK_WORK: &str = "the drawing's blocks take too much work to place: the rest are left out";
const TOO_MANY_DASHES: &str = "the drawing has more dashed lines than can be kept: the rest are drawn solid";
const TOO_MUCH_TEXT: &str = "the drawing has more than 64 MB of text and block names: the rest are left out";
@@ -499,6 +500,9 @@ impl<'d, 'a> Build<'d, 'a> {
super::curve::bounds(&bp)?;
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
let scale = map.determinant().abs().sqrt();
let path = PathData::from_bezpath(&bp);
// A compound path's loops each keep a copy of its appearance, beside its own.
let copies = if path.subpaths.len() > 1 { path.subpaths.len() + 1 } else { 1 };
let appearance = if fill {
Appearance::basic(paint, Paint::None, 0.0)
} else {
@@ -514,7 +518,7 @@ impl<'d, 'a> Build<'d, 'a> {
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
{
match self.dash_values.checked_add(dash.pattern.len()).filter(|n| *n <= MAX_DASH_VALUES) {
match dash.pattern.len().checked_mul(copies).and_then(|v| self.dash_values.checked_add(v)).filter(|n| *n <= MAX_DASH_VALUES) {
Some(n) => {
self.dash_values = n;
s.dash = Some(dash);
@@ -528,7 +532,6 @@ impl<'d, 'a> Build<'d, 'a> {
ap
};
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
let path = PathData::from_bezpath(&bp);
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
let mut n = if path.subpaths.len() > 1 {
// Several loops (a hatch with islands) paint as one compound path. Each loop is an
@@ -630,7 +633,7 @@ impl<'d, 'a> Build<'d, 'a> {
/// The art of block `name` drawn by `map`, as one object.
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
if self.visits >= MAX_VISITS {
self.warn(TOO_MANY);
self.warn(TOO_MUCH_BLOCK_WORK);
return None;
}
let items = self.block(name);
@@ -638,7 +641,7 @@ impl<'d, 'a> Build<'d, 'a> {
// makes nothing, so the node budget doesn't bound it).
self.visits = self.visits.saturating_add(1 + items.as_ref().map_or(0, |i| i.len()));
if self.visits > MAX_VISITS {
self.warn(TOO_MANY);
self.warn(TOO_MUCH_BLOCK_WORK);
return None;
}
let items = items?;
@@ -671,12 +674,16 @@ impl<'d, 'a> Build<'d, 'a> {
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
let map = self.symbol_map * Affine::translate(-base.to_vec2());
let art = self.art(name, map, &key.1, depth);
let made = art.map(|art| {
let made = art.and_then(|art| {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
// The symbol keeps its name: text, as its instances' copies of it are.
if !self.charge_text(label.len()) {
return None;
}
let symbol = unique(label, &mut self.next_suffix, &self.symbol_names);
self.symbol_names.insert(symbol.clone());
self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) });
symbol
Some(symbol)
});
self.symbols.insert(key, made.clone());
made
+74 -2
View File
@@ -814,7 +814,7 @@ fn nested_array_inserts_of_nothing_stop_at_the_work_budget() {
blocks.extend(block("*C", None));
let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")];
let r = open(&drawing(&[], &[], &blocks, &top), &points());
assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings);
}
#[test]
@@ -1273,5 +1273,77 @@ fn arrays_of_blocks_of_hidden_art_stop_at_the_work_budget() {
blocks.push((0, "ENDBLK"));
let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "50"), (71, "50"), (44, "1"), (45, "1")];
let r = open(&drawing(&[], &tables, &blocks, &insert), &points());
assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings);
}
#[test]
fn dashed_compound_paths_charge_every_copy_of_their_pattern() {
// 100 pattern hatches of 9 loops in a two-length linetype: each keeps its dashes on the
// compound path and on each loop, 2,000 dash lengths, past the 1,000 tests import with.
let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")];
let corners = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90"];
let mut hatch = vec![(0, "HATCH"), (6, "DASHED"), (10, "0"), (20, "0"), (30, "0"), (2, "ANSI31"), (70, "0"), (71, "0"), (91, "9")];
for i in 0..9 {
let (a, b) = (corners[i], corners[i + 1]);
hatch.extend([
(92, "2"),
(72, "0"),
(73, "1"),
(93, "4"),
(10, a),
(20, "0"),
(10, b),
(20, "0"),
(10, b),
(20, "5"),
(10, a),
(20, "5"),
(97, "0"),
]);
}
hatch.extend([(75, "0"), (76, "1"), (98, "0")]);
let e: Vec<(i32, &str)> = (0..100).flat_map(|_| hatch.iter().copied()).collect();
let r = open(&drawing(&[], &tables, &[], &e), &points());
let (mut compounds, mut kept) = (0, 0);
r.document.walk(|n| {
compounds += usize::from(matches!(n.kind, NodeKind::Compound { .. }));
kept += n.appearance.stroke().and_then(|s| s.dash.as_ref()).map_or(0, |d| d.pattern.len());
});
assert_eq!(compounds, 100, "{:?}", r.warnings);
assert!(kept > 0 && kept <= 1_000, "{kept} dash lengths kept");
assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings);
}
#[test]
fn symbols_of_a_long_block_name_stop_at_the_text_budget() {
// A 1,000-byte block name inserted in 100 colours its art takes: a symbol per colour, each
// keeping its own copy of the name, past the 64 KB tests import with.
let name = "N".repeat(1000);
let blocks = [
(0, "BLOCK"),
(2, name.as_str()),
(10, "0"),
(20, "0"),
(0, "LINE"),
(8, "0"),
(62, "0"),
(10, "0"),
(20, "0"),
(11, "1"),
(21, "0"),
(0, "ENDBLK"),
];
let colours: Vec<String> = (1..=100).map(|c| c.to_string()).collect();
let inserts: Vec<(i32, &str)> =
colours.iter().flat_map(|c| [(0, "INSERT"), (2, name.as_str()), (62, c.as_str()), (10, "0"), (20, "0")]).collect();
let r = open(&drawing(&[], &[], &blocks, &inserts), &points());
let mut bytes: usize = r.document.symbols.iter().map(|s| s.name.len()).sum();
r.document.walk(|n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
bytes += symbol.len();
}
});
assert!(!r.document.symbols.is_empty());
assert!(bytes <= 64 << 10, "{bytes} bytes of symbol names");
assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings);
}
+106 -15
View File
@@ -1,7 +1,7 @@
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes
//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look
//! needs them.
//! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or
//! filled outlines where the look needs them.
use std::collections::HashMap;
use std::io::Cursor;
@@ -66,6 +66,8 @@ struct CircularSub {
centre: Point,
radius: f64,
samples: Vec<Point>,
/// The signed angle (radians) the curve turns through about `centre` in document space.
turn: f64,
}
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
@@ -122,7 +124,12 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
return None;
}
let tolerance = (radius * 5e-4).max(1e-6);
// The tolerance is a fraction of the smaller of the radius and the curve's extent, so the large
// radius of a shallow curve does not loosen it.
let (lo, hi) = samples
.iter()
.fold((samples[0], samples[0]), |(lo, hi), p| (Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y))));
let tolerance = (radius.min(lo.distance(hi)) * 5e-4).max(1e-6);
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
@@ -181,7 +188,76 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
} else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 {
return None;
}
Some(CircularSub { centre, radius, samples })
Some(CircularSub { centre, radius, samples, turn: total_turn })
}
/// The points of the circle about `centre` of `radius` furthest left, right, up and down that lie on
/// its arc from `from` through `turn` radians, for the drawing extents.
fn arc_extrema(centre: Point, radius: f64, from: Point, turn: f64) -> Vec<Point> {
let start = (from.y - centre.y).atan2(from.x - centre.x);
(0..4)
.map(|k| f64::from(k) * std::f64::consts::FRAC_PI_2)
.filter(|a| ((a - start) * turn.signum()).rem_euclid(std::f64::consts::TAU) <= turn.abs())
.map(|a| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin()))
.collect()
}
/// The most cubics one bulge arc is fitted to; the cubics after them start the next arc.
const MAX_ARC_RUN: usize = 64;
/// A subpath of straight segments and circular arcs as polyline vertices, in document space.
struct BulgedSub {
/// The anchors, without a closed subpath's repeated start.
pts: Vec<Point>,
/// For each anchor, the angle (radians, document space) the segment that starts there turns
/// through: 0 for a straight segment.
turns: Vec<f64>,
/// The arcs' extreme points ([`arc_extrema`]), for the drawing extents.
extrema: Vec<Point>,
}
/// Fit a subpath with curves to straight segments and circular arcs: each run of cubics on one
/// circle, turning one way and less than a whole turn, becomes one arc ([`circular_subpath`]'s
/// tolerance). `None` when the subpath has no curves or a curve that is not a circular arc.
fn bulged_subpath(sub: &Sub) -> Option<BulgedSub> {
if !sub.segs.iter().any(|s| matches!(s, Seg::Cubic(..))) {
return None;
}
let mut pts = vec![sub.start];
let mut turns = vec![];
let mut extrema = vec![];
let mut from = sub.start;
let mut i = 0;
while let Some(seg) = sub.segs.get(i) {
if let Seg::Line(p) = *seg {
turns.push(0.0);
pts.push(p);
from = p;
i += 1;
continue;
}
let mut best = None;
let mut j = i;
while j < sub.segs.len() && j - i < MAX_ARC_RUN && matches!(sub.segs[j], Seg::Cubic(..)) {
let run = Sub { start: from, segs: sub.segs[i..=j].to_vec(), closed: false };
let Some(arc) = circular_subpath(&run) else { break };
best = Some((j, arc));
j += 1;
}
let (j, arc) = best?;
turns.push(arc.turn);
extrema.extend(arc_extrema(arc.centre, arc.radius, from, arc.turn));
from = sub.segs[j].end();
pts.push(from);
i = j + 1;
}
if sub.closed && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
pts.pop();
} else {
// The segment from the last anchor: the straight closing edge, or none for an open subpath.
turns.push(0.0);
}
(pts.len() >= 2).then_some(BulgedSub { pts, turns, extrema })
}
/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle
@@ -846,7 +922,9 @@ impl<'a> Scene<'a> {
}
/// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for
/// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones.
/// other straight subpaths, circles, arcs and ellipses where the curves are one, polylines
/// with bulges for subpaths of straight segments and circular arcs, and cubic splines for
/// other curved ones (flattened polylines in R12).
/// `width`: the stroke weight, which DXF before 2000 gives polylines as a width.
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
let v = self.o.version;
@@ -863,7 +941,7 @@ impl<'a> Scene<'a> {
// A LINE has no width, so a stroke before 2000 keeps its polyline width.
match (sub.closed, width, pts.as_slice()) {
(false, None, &[a, b]) => self.line(a, b, style),
_ => self.polyline(&pts, sub.closed, style, width),
_ => self.polyline(&pts, &[], sub.closed, style, width),
}
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
@@ -871,6 +949,12 @@ impl<'a> Scene<'a> {
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() && self.elliptical(&sub, style) {
// R13 and later write an ellipse as a native ELLIPSE.
} else if let Some(b) = bulged_subpath(&sub) {
// Straight segments and circular arcs: one polyline whose bulges draw the arcs.
b.extrema.iter().for_each(|p| _ = self.pt(*p));
let flip = if self.to_dxf.determinant() < 0.0 { -1.0 } else { 1.0 };
let bulges: Vec<f64> = b.turns.iter().map(|t| flip * (t / 4.0).tan()).collect();
self.polyline(&b.pts, &bulges, sub.closed, style, width);
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -883,7 +967,7 @@ impl<'a> Scene<'a> {
}
}
for pts in polygons(&path) {
self.polyline(&pts, sub.closed, style, width);
self.polyline(&pts, &[], sub.closed, style, width);
}
}
}
@@ -900,8 +984,9 @@ impl<'a> Scene<'a> {
}
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
/// vertex entity per point.
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
/// vertex entity per point. `bulges`: the bulge (group 42) of the segment that starts at each
/// point, in drawing space; empty for straight segments only.
fn polyline(&mut self, pts: &[Point], bulges: &[f64], closed: bool, style: &EntityStyle, width: Option<f64>) {
if pts.len() < 2 {
return;
}
@@ -917,8 +1002,11 @@ impl<'a> Scene<'a> {
if let Some(w) = width {
self.e.num(43, w);
}
for p in &pts {
for (i, p) in pts.iter().enumerate() {
self.e.xy(10, p.x, p.y);
if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) {
self.e.num(42, b);
}
}
return;
}
@@ -930,13 +1018,16 @@ impl<'a> Scene<'a> {
self.e.num(40, w);
self.e.num(41, w);
}
for p in &pts {
for (i, p) in pts.iter().enumerate() {
let vh = self.handle();
self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex");
if self.o.version.handles() {
self.e.raw(100, "AcDb2dVertex");
}
self.e.xyz(10, p.x, p.y);
if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) {
self.e.num(42, b);
}
}
let end = self.handle();
self.e.entity("SEQEND", end, h, &layer, style, "");
@@ -960,8 +1051,8 @@ impl<'a> Scene<'a> {
return false;
}
// Count sampled points in the drawing extents. For a full circle include all cardinal
// points exactly; for an arc, the dense samples conservatively cover its visible span.
// Count the curve's extreme points in the drawing extents: all four cardinal points of a
// full circle; an arc's ends and the cardinal points on it.
if sub.closed {
let r = circle.radius;
for p in [
@@ -973,8 +1064,8 @@ impl<'a> Scene<'a> {
self.pt(p);
}
} else {
for p in &circle.samples {
self.pt(*p);
for p in [sub.start, last].into_iter().chain(arc_extrema(circle.centre, circle.radius, sub.start, circle.turn)) {
self.pt(p);
}
}
+204 -4
View File
@@ -275,6 +275,197 @@ fn circles_and_circular_arcs_are_native_dxf_entities() {
}
}
/// A cubic arc about `c` of radius `r` from `deg0` to `deg1` (degrees, y down), from the current
/// point, which is the arc's start.
fn arc_cubic(b: &mut vectorcraft_geom::BezPath, c: Point, r: f64, deg0: f64, deg1: f64) {
let (a0, a1) = (deg0.to_radians(), deg1.to_radians());
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan() * r;
let (p0, p3) = (Point::new(c.x + r * a0.cos(), c.y + r * a0.sin()), Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()));
b.curve_to(Point::new(p0.x - k * a0.sin(), p0.y + k * a0.cos()), Point::new(p3.x + k * a1.sin(), p3.y - k * a1.cos()), p3);
}
/// A slot (two lines, two half circles) about y = 50, a D shape (a half circle closed by its
/// diameter) and a rounded rectangle with 10 pt corners.
fn lines_and_arcs() -> [PathData; 3] {
let mut slot = vectorcraft_geom::BezPath::new();
slot.move_to((60.0, 30.0));
slot.line_to((140.0, 30.0));
arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, -90.0, 0.0);
arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, 0.0, 90.0);
slot.line_to((60.0, 70.0));
arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0);
arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0);
slot.close_path();
let mut d = vectorcraft_geom::BezPath::new();
d.move_to((150.0, 20.0));
arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, -90.0, 0.0);
arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, 0.0, 90.0);
d.close_path();
[PathData::from_bezpath(&slot), PathData::from_bezpath(&d), shapes::rounded_rectangle(Rect::new(20.0, 20.0, 100.0, 80.0), 10.0)]
}
/// A polyline entity's vertices as (x, y, bulge): an LWPOLYLINE's, or those of the VERTEX
/// entities that follow a POLYLINE at `e[i]`.
fn vertices(e: &[Pairs], i: usize) -> Vec<(f64, f64, f64)> {
let mut out = vec![];
let mut read = |o: &Pairs| {
for (code, value) in o {
let v: f64 = value.trim().parse().unwrap_or(0.0);
match code {
10 => out.push((v, 0.0, 0.0)),
20 => out.last_mut().expect("x before y").1 = v,
42 => out.last_mut().expect("a vertex before its bulge").2 = v,
_ => {}
}
}
};
if kind(&e[i]) == "LWPOLYLINE" {
read(&e[i]);
} else {
e[i + 1..].iter().take_while(|o| kind(o) == "VERTEX").for_each(&mut read);
}
out
}
#[test]
fn paths_of_lines_and_circular_arcs_are_polylines_with_bulges() {
let [slot, d_shape, rounded] = lines_and_arcs();
let d = doc_with(|d| {
let black = || Paint::solid(Color::BLACK);
let mut open = vectorcraft_geom::BezPath::new();
open.move_to((10.0, 90.0));
open.line_to((30.0, 90.0));
arc_cubic(&mut open, Point::new(30.0, 80.0), 10.0, 90.0, 0.0);
vec![
path(d, slot, Paint::None, black(), 1.0),
path(d, d_shape, Paint::None, black(), 1.0),
path(d, rounded, Paint::None, black(), 1.0),
path(d, PathData::from_bezpath(&open), Paint::None, black(), 1.0),
]
});
let b = (std::f64::consts::PI / 8.0).tan();
for version in DxfVersion::ALL {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
let polys: Vec<usize> = (0..e.len()).filter(|i| matches!(kind(&e[*i]), "LWPOLYLINE" | "POLYLINE")).collect();
let k = kinds(&out);
assert_eq!(polys.len(), 4, "{version:?}: {k:?}");
assert!(!k.iter().any(|k| matches!(k.as_str(), "SPLINE" | "ARC" | "CIRCLE")), "{version:?}: {k:?}");
let want = if version.lwpolyline() { "LWPOLYLINE" } else { "POLYLINE" };
assert!(polys.iter().all(|i| kind(&e[*i]) == want), "{version:?}: {k:?}");
let flags: Vec<i64> = polys.iter().map(|i| get(&e[*i], 70).unwrap().parse::<i64>().unwrap() & 1).collect();
assert_eq!(flags, [1, 1, 1, 0], "{version:?}: closed, closed, closed, open");
// The slot's half circles turn clockwise with y up: a bulge of -1 after each long side.
assert_eq!(vertices(&e, polys[0]), [(60.0, 70.0, 0.0), (140.0, 70.0, -1.0), (140.0, 30.0, 0.0), (60.0, 30.0, -1.0)], "{version:?}");
// The D shape: the half circle, then the straight closing edge.
assert_eq!(vertices(&e, polys[1]), [(150.0, 80.0, -1.0), (150.0, 20.0, 0.0)], "{version:?}");
// The rounded rectangle: eight anchors, a quarter circle (bulge tan 22.5°) at each corner.
let r = vertices(&e, polys[2]);
assert_eq!(r.len(), 8, "{version:?}: {r:?}");
let bulges: Vec<f64> = r.iter().map(|v| v.2).filter(|b| *b != 0.0).collect();
assert!(close_to(&bulges, &[-b; 4], 1e-6), "{version:?}: {r:?}");
// An open line then a quarter circle turning counterclockwise with y up.
let o = vertices(&e, polys[3]);
assert!(
close_to(&o.iter().flat_map(|v| [v.0, v.1, v.2]).collect::<Vec<_>>(), &[10.0, 10.0, 0.0, 30.0, 10.0, b, 40.0, 20.0, 0.0], 1e-6),
"{version:?}: {o:?}"
);
}
}
#[test]
fn polylines_with_bulges_read_back_as_the_same_outline() {
for (i, shape) in lines_and_arcs().into_iter().enumerate() {
let original = shape.to_bezpath();
let d = doc_with(|d| vec![path(d, shape, Paint::None, Paint::solid(Color::BLACK), 1.0)]);
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["LWPOLYLINE"], "shape {i}");
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
let back = import(&out.bytes, &o).unwrap().document;
let mut paths = vec![];
back.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
paths.push(path.to_bezpath());
}
});
assert_eq!(paths.len(), 1, "shape {i}");
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "shape {i} reads back closed");
let (a, b) = (original.bounding_box(), paths[0].bounding_box());
assert!(close_to(&[a.width(), a.height()], &[b.width(), b.height()], 1e-2), "shape {i}: {a:?} against {b:?}");
// Every point read back lies on the original outline, once both are centered.
let shift = a.center() - b.center();
let outline: Vec<Point> = original.segments().flat_map(|s| (0..=64).map(move |t| s.eval(f64::from(t) / 64.0))).collect();
for seg in paths[0].segments() {
for t in 0..=16 {
let p = seg.eval(f64::from(t) / 16.0) + shift;
let near = outline
.windows(2)
.map(|w| {
let (u, v) = (w[1] - w[0], p - w[0]);
let s = if u.hypot2() > 0.0 { (v.dot(u) / u.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
(v - u * s).hypot()
})
.fold(f64::INFINITY, f64::min);
assert!(near < 1e-2, "shape {i}: {p:?} is {near} pt off the outline");
}
}
}
}
#[test]
fn a_path_with_a_curve_that_is_not_a_circular_arc_stays_a_spline() {
// The slot with one end drawn by a longer-handled curve.
let mut slot = vectorcraft_geom::BezPath::new();
slot.move_to((60.0, 30.0));
slot.line_to((140.0, 30.0));
slot.curve_to((165.0, 30.0), (165.0, 70.0), (140.0, 70.0));
slot.line_to((60.0, 70.0));
arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0);
arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0);
slot.close_path();
let d = doc_with(|d| {
vec![
path(d, PathData::from_bezpath(&slot), Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, shapes::ellipse(Rect::new(150.0, 20.0, 190.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "CIRCLE"]);
}
#[test]
fn drawing_extents_reach_the_extreme_points_of_arcs() {
// An arc about (100, 50) of radius 40 from -60° to 30° passes x = 140 at 0°, between the points
// along it that recognition samples.
let arc = |closed: bool| {
let mut b = vectorcraft_geom::BezPath::new();
let start = Point::new(100.0 + 40.0 * 60f64.to_radians().cos(), 50.0 - 40.0 * 60f64.to_radians().sin());
b.move_to(start);
arc_cubic(&mut b, Point::new(100.0, 50.0), 40.0, -60.0, 30.0);
if closed {
b.close_path();
}
PathData::from_bezpath(&b)
};
for (closed, entity) in [(false, "ARC"), (true, "LWPOLYLINE")] {
let d = doc_with(|d| vec![path(d, arc(closed), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), [entity]);
let x: f64 = header(&pairs(&out.bytes), "$EXTMAX").unwrap().trim().parse().unwrap();
assert!((x - 140.0).abs() < 1e-6, "{entity}: $EXTMAX x {x}");
}
}
#[test]
fn a_shallow_curve_that_is_not_circular_is_not_an_arc() {
// 100 pt across and 1.5 pt deep: the circle through its ends and middle has a radius of about
// 834 pt, and the curve is 0.12 pt off it a quarter of the way along.
let mut b = vectorcraft_geom::BezPath::new();
b.move_to((0.0, 50.0));
b.curve_to((20.0, 48.0), (80.0, 48.0), (100.0, 50.0));
let d = doc_with(|d| vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE"]);
}
/// The path `p` with `xf` applied.
fn transformed(p: &PathData, xf: Affine) -> PathData {
PathData::from_bezpath(&(xf * p.to_bezpath()))
@@ -391,11 +582,12 @@ fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
vec![
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). Its
// quarters fit shallow circular arcs within the tolerance, so it is a polyline with bulges.
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "LWPOLYLINE"]);
}
#[test]
@@ -462,8 +654,12 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
// The three quarters turn counterclockwise with y up (bulge tan 67.5°), then the chord closes it.
let modern = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&modern), ["SPLINE"]);
assert_eq!(kinds(&modern), ["LWPOLYLINE"]);
let v = vertices(&entities(&modern), 0);
let flat: Vec<f64> = v.iter().flat_map(|v| [v.0, v.1, v.2]).collect();
assert!(close_to(&flat, &[80.0, 50.0, 67.5f64.to_radians().tan(), 50.0, 20.0, 0.0], 1e-6), "{v:?}");
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
@@ -497,9 +693,13 @@ fn a_closed_sliver_along_a_circle_is_not_a_circle() {
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
// Two arcs, out along the circle and back: one polyline with a bulge each way.
let modern = export(&d, &opts(&d)).unwrap();
assert_ne!(kinds(&modern), ["CIRCLE"]);
assert_eq!(kinds(&modern), ["SPLINE"]);
assert_eq!(kinds(&modern), ["LWPOLYLINE"]);
let bulges: Vec<f64> = vertices(&entities(&modern), 0).iter().map(|v| v.2).collect();
let b = 10f64.to_radians().tan();
assert!(close_to(&bulges, &[-b, b], 1e-6) || close_to(&bulges, &[b, -b], 1e-6), "{bulges:?}");
}
#[test]
+15
View File
@@ -231,6 +231,21 @@ impl GenericCmyk {
}
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
}
// An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks.
for p in 0..3 {
if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) {
a[p] = [0.0; 3];
for row in &mut a {
row[p] = 0.0;
}
a[p][p] = 1.0;
g[p] = 0.0;
}
}
// Every ink is held at a bound, or the gradient is zero: no step can lower the error.
if g == [0.0; 3] {
break;
}
let mut improved = false;
for _ in 0..8 {
let mut al = a;
+40
View File
@@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() {
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
/// in all) stays within 0.001 on every ink.
#[test]
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
let c = cms();
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -2000i32..=2000 {
let mut rgb = pink;
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
}
}
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!(lo[0], 0.0, "{lo:?}");
}
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values
/// stays within 0.001 on every ink.
#[test]
fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() {
let c = cms();
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -60i32..=60 {
let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
}
}
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+32 -6
View File
@@ -86,6 +86,9 @@ const TINT_STEPS: u32 = 16;
const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
const MAX_INK_TINTS: usize = 4096;
/// How many even steps [`Gradient::ink_mixes`] checks the lightness of two stops' display RGB mix
/// at, to tell whether it moves steadily from one stop's lightness to the other's.
const INK_MIX_STEPS: u32 = 64;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
@@ -148,16 +151,23 @@ pub struct GradientStop {
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
pub tint: f32,
/// Process inks (C, M, Y, K, 0..1) printed together with the linked spot swatch's tint: a
/// DeviceN colour of a file that mixes a spot ink with process plates. `color` is how the mix
/// looks; separations print the spot ink at `tint` and these plates. `None`: the ink alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plates: Option<[f32; 4]>,
}
impl GradientStop {
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
pub fn new(offset: f32, color: Color) -> Self {
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0, plates: None }
}
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked, without
/// process plates.
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
self.color = color;
self.plates = None;
(self.swatch, self.tint) = match link {
Some((n, t)) => (Some(n), t),
None => (None, 1.0),
@@ -245,6 +255,12 @@ impl Gradient {
pub fn sample(&self, t: f32) -> (Color, f32) {
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops
/// are prepared once, so each sample costs a binary search rather than a scan of the stops.
pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ {
let reach = StopReach::new(&self.stops);
move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
/// gradient's as [`Self::sample`] mixes them.
@@ -395,8 +411,10 @@ impl Gradient {
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
/// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere
/// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even
/// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more
/// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
@@ -408,8 +426,16 @@ impl Gradient {
out.push((s.offset, i, 0.0, s.opacity));
let Some(n) = self.stops.get(i + 1) else { continue };
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
let mixed_lightness = |w: f32| lightness(&GradientMethod::Classic.mix(&s.color, &n.color, w));
// The mix position itself when the lightness doesn't tell the mixes apart.
let by_position = (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN
|| !(0..=INK_MIX_STEPS)
.map(|k| mixed_lightness(k as f32 / INK_MIX_STEPS as f32))
.collect::<Vec<_>>()
.windows(2)
.all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0);
for v in perceptual_mixes(s, n) {
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
let weight = if by_position {
v
} else {
let want = lightness(&self.method.mix(&s.color, &n.color, v));
@@ -417,7 +443,7 @@ impl Gradient {
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
if (mixed_lightness(mid) - want) * (l1 - l0) < 0.0 {
lo = mid;
} else {
hi = mid;
+34
View File
@@ -23,6 +23,21 @@ fn increasing_samples_match_single_samples() {
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
}
/// The prepared sampler gives what sampling each position alone does, out-of-order stops included.
#[test]
fn the_sampler_matches_single_samples() {
let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap());
let g = Gradient::new(
GradientKind::Linear,
vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")],
);
let sample = g.sampler();
for k in (0..=40).rev() {
let t = k as f32 / 40.0;
assert_eq!(sample(t), g.sample(t), "{t}");
}
}
/// Looking stops up by their reach finds what scanning them does, for any order of offsets,
/// repeated, out-of-order and NaN ones included, and for positions outside and between them.
#[test]
@@ -570,3 +585,22 @@ fn ink_mixes_follow_the_perceptual_curve() {
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
assert_eq!(g.ink_mixes(), None);
}
/// From red to blue the display RGB mix darkens past blue's lightness and comes back, so a
/// weight found by lightness near the blue stop would be a red-side one and the inks would jump
/// there. The weights are the mix positions instead, and reach the blue stop's inks steadily.
#[test]
fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red), GradientStop::new(1.0, blue)]);
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
assert_eq!(mixes[mixes.len() - 1], (1.0, 1, 0.0, 1.0), "the blue stop");
let between: Vec<_> = mixes.iter().filter(|m| m.1 == 0 && m.0 > 0.0).collect();
assert!(!between.is_empty(), "stops on the curve between red and blue: {mixes:?}");
// With the midpoint at ½, a stop's offset on the curve is its mix position.
for (t, _, w, _) in &between {
assert!((w - t).abs() < 1e-6, "at {t}: weight {w}");
}
assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}");
}
+10 -1
View File
@@ -433,10 +433,11 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
.collect();
let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6);
let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
let n = n.max(1);
if pts.is_empty() {
return vec![];
}
// No more rows than colours: each colour seeds one at most.
let n = n.clamp(1, pts.len());
// Seeds: the heaviest colour, then each time the one farthest from every seed so far.
let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best });
let mut centres = vec![pts[first]];
@@ -447,6 +448,8 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
break;
}
centres.push(pts[i]);
// Picked: a colour whose distances aren't finite (an infinite coordinate) stays farthest.
near[i] = 0.0;
for (j, p) in pts.iter().enumerate() {
near[j] = near[j].min(d2(p, &pts[i]));
}
@@ -670,6 +673,12 @@ mod tests {
assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. }));
}
#[test]
fn clustering_seeds_each_colour_once_even_unbounded() {
let colors = [Color::lab(f32::INFINITY, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
assert!(cluster(&colors, &[], usize::MAX).len() <= colors.len());
}
#[test]
fn clustering_reduces_to_n_rows_of_similar_colours() {
let colors =
+183 -14
View File
@@ -22,11 +22,15 @@ pub struct ProfilePreset {
}
impl WidthProfile {
/// (left, right) width factors at `t`, linear between points.
/// (left, right) width factors at `t`: a smooth curve through the points ([`Self::at_points`]).
pub fn at(&self, t: f64) -> (f64, f64) {
Self::at_points(&self.points, t)
}
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
/// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through
/// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point
/// without a kink, turns flat at a point wider or narrower than both neighbours, and never
/// goes past either point of the span it is in (#5714). Points on one straight line stay on
/// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it.
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
if p.is_empty() {
return (1.0, 1.0);
@@ -34,11 +38,23 @@ impl WidthProfile {
if t <= p[0].0 {
return (p[0].1, p[0].2);
}
for w in p.windows(2) {
if t <= w[1].0 {
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
// (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.)
let i = p.partition_point(|q| q.0 < t);
if let (Some(j), Some(b)) = (i.checked_sub(1), p.get(i))
&& let Some(a) = p.get(j)
{
let h = b.0 - a.0;
let u = (t - a.0) / h.max(1e-9);
let linear = (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u);
if h <= STEP {
return linear;
}
let side = |v: fn(&(f64, f64, f64)) -> f64, straight: f64| {
let y = hermite(v(a), v(b), tangent(p, j, v) * h, tangent(p, i, v) * h, u);
// Widths read from a file may be huge: where the curve overflows, the straight line.
if y.is_finite() { y } else { straight }
};
return (side(|q| q.1, linear.0), side(|q| q.2, linear.1));
}
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
}
@@ -55,14 +71,17 @@ impl WidthProfile {
// Two swells between narrow necks.
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
];
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
/// right factors at any of the points (between them the profile is straight), 1 for a profile
/// without points.
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each
/// side stays between the widths of the two points of each span ([`Self::at_points`]), but the
/// two sides needn't peak together, so a span counts its wider left and its wider right.
pub fn widest(&self) -> f64 {
if self.points.is_empty() {
let p = &self.points;
if p.is_empty() {
return 1.0;
}
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
let total = |l: f64, r: f64| (l.max(0.0) + r.max(0.0)) / 2.0;
let spans = p.windows(2).map(|w| total(w[0].1.max(w[1].1), w[0].2.max(w[1].2)));
p.iter().map(|&(_, l, r)| total(l, r)).chain(spans).fold(0.0, f64::max)
}
/// The built-in profile with this id.
pub fn preset(id: &str) -> Option<Self> {
@@ -90,10 +109,12 @@ impl WidthProfile {
/// discontinuous point (two points at `t`, within `1e-7`).
pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) {
let p = &self.points;
if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) {
let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1;
// The same distance test below, so a point exactly 1e-7 before `t` is never skipped by rounding.
let i = p.partition_point(|q| q.0 < t && (q.0 - t).abs() > 1e-7);
if let Some(first) = p.get(i).filter(|q| (q.0 - t).abs() <= 1e-7) {
let j = i + p[i..].partition_point(|q| q.0 - first.0 <= 1e-9) - 1;
if j > i {
return ((p[i].1, p[i].2), (p[j].1, p[j].2));
return ((first.1, first.2), (p[j].1, p[j].2));
}
}
let v = self.at(t);
@@ -225,6 +246,41 @@ impl AngleProfile {
}
}
/// How close two width points' positions are to make a step ([`WidthProfile::around`]).
const STEP: f64 = 1e-7;
/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in
/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where
/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes
/// of the spans on either side elsewhere (never more than three times either, so no span
/// overshoots its points), and the span's own slope at an end of the profile or beside a step.
fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 {
let Some(q) = p.get(j) else { return 0.0 };
let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP);
let after = p.get(j + 1).filter(|b| b.0 - q.0 > STEP);
let slope = |a: &(f64, f64, f64), b: &(f64, f64, f64)| (v(b) - v(a)) / (b.0 - a.0);
match (before, after) {
(Some(a), Some(b)) => {
let (d0, d1) = (slope(a, q), slope(q, b));
if d0 * d1 <= 0.0 {
return 0.0;
}
let (h0, h1) = (q.0 - a.0, b.0 - q.0);
3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
}
(Some(a), None) => slope(a, q),
(None, Some(b)) => slope(q, b),
(None, None) => 0.0,
}
}
/// The cubic Hermite curve from `y0` to `y1` at `u` (0..1), with end slopes `m0` and `m1` per
/// whole span.
fn hermite(y0: f64, y1: f64, m0: f64, m1: f64, u: f64) -> f64 {
let (u2, u3) = (u * u, u * u * u);
(2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * m0 + (-2.0 * u3 + 3.0 * u2) * y1 + (u3 - u2) * m1
}
/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.
fn interpolated(p: &[(f64, f64)], t: f64) -> Option<f64> {
let (first, last) = (p.first()?, p.last()?);
@@ -334,3 +390,116 @@ pub struct SavedProfile {
pub name: String,
pub profile: WidthProfile,
}
#[cfg(test)]
mod tests {
use super::*;
/// Widths and steps are found by search, at the ends, between points and at a step.
#[test]
fn width_profile_lookups_by_search() {
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] };
assert_eq!(p.at(-1.0), (0.0, 0.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
assert_eq!(p.at(0.75), (0.25, 0.125));
assert_eq!(p.at(2.0), (0.0, 0.0));
// A step: the widths just before and just after it.
assert_eq!(p.around(0.5), ((1.0, 1.0), (0.5, 0.25)));
assert_eq!(p.around(0.25), ((0.5, 0.5), (0.5, 0.5)));
// Many points at one position are one step, found without a pass over them all.
let many = WidthProfile { points: (0..1000).map(|i| (0.5, i as f64, i as f64)).collect() };
assert_eq!(many.around(0.5), ((0.0, 0.0), (999.0, 999.0)));
// 0.4999999 == 0.5 - 1e-7, yet its distance from 0.5 rounds above 1e-7: it is not part of the step.
let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] };
assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25)));
}
/// The slope of one side of `p` just before and just after `t`.
fn slopes(p: &WidthProfile, t: f64) -> (f64, f64) {
let e = 1e-6;
((p.at(t).0 - p.at(t - e).0) / e, (p.at(t + e).0 - p.at(t).0) / e)
}
/// #5714: the width flows through a width point instead of turning a corner there.
#[test]
fn widths_run_smoothly_through_width_points() {
// A swell in the middle: flat at its widest, and a quarter of the way along above the
// straight ramp from 1 to 3 (2).
let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] };
let (before, after) = slopes(&swell, 0.5);
assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}");
let (l, r) = swell.at(0.25);
assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}");
// Rising on both sides of a point: the slope is the same either side of it.
let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] };
let (before, after) = slopes(&rise, 0.3);
assert!(before > 0.0 && (before - after).abs() < 1e-3 * before, "{before} {after}");
// Points on one line stay on it.
let straight = WidthProfile { points: vec![(0.0, 0.0, 1.0), (0.25, 0.5, 1.0), (1.0, 2.0, 1.0)] };
for t in [0.1, 0.25, 0.6, 0.9] {
let (l, r) = straight.at(t);
assert!((l - 2.0 * t).abs() < 1e-12 && (r - 1.0).abs() < 1e-12, "{t}: {l} {r}");
}
}
/// Neither side of a profile goes wider or narrower than the two points of the span it is in.
#[test]
fn widths_never_pass_the_points_either_side() {
for preset in WidthProfile::PRESETS {
let p = WidthProfile { points: preset.points.to_vec() };
for w in p.points.windows(2) {
let (lo, hi) = (w[0].1.min(w[1].1), w[0].1.max(w[1].1));
for k in 0..=100 {
let t = w[0].0 + (w[1].0 - w[0].0) * k as f64 / 100.0;
let l = p.at(t).0;
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{}: {l} at {t} outside {lo}..{hi}", preset.id);
}
}
}
// Uneven spacing and a steep jump next to a gentle rise.
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.05, 1.0, 1.0), (0.9, 1.1, 1.1), (1.0, 5.0, 5.0)] };
for k in 0..=1000 {
let t = k as f64 / 1000.0;
let l = p.at(t).0;
let span = p.points.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).map(|w| (w[0].1.min(w[1].1), w[0].1.max(w[1].1)));
let (lo, hi) = span.unwrap();
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{l} at {t}");
}
}
/// A step still breaks the width sharply, and the curve on either side ends at it.
#[test]
fn a_step_ends_the_curve_on_each_side() {
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (0.5, 1.0, 1.0), (1.0, 2.0, 2.0)] };
assert_eq!(p.around(0.5), ((3.0, 3.0), (1.0, 1.0)));
// Two points between ends: each side of the step is a straight ramp.
assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12);
}
/// `widest()` is at least the stroke's width anywhere along the profile, even where the sides peak apart.
#[test]
fn widest_covers_every_width_along_the_profile() {
let profiles = [
vec![(0.0, 1.0, 0.0), (0.5, 0.2, 1.0), (1.0, 1.0, 0.0)],
vec![(0.0, 0.0, 1.0), (0.3, 1.0, 0.0), (0.6, 0.0, 1.0), (1.0, 1.0, 1.0)],
vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)],
];
for points in profiles {
let p = WidthProfile { points };
let most = (0..=1000).map(|k| p.at(k as f64 / 1000.0)).map(|(l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max);
assert!(p.widest() >= most - 1e-12, "{} < {most}: {:?}", p.widest(), p.points);
}
assert_eq!(WidthProfile::lens().widest(), 1.0);
assert_eq!(WidthProfile::default().widest(), 1.0);
}
/// Widths read from a file may be absurd: the curve stays finite or falls back to the line.
#[test]
fn huge_widths_stay_finite() {
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, f64::MAX, 1.0), (1.0, -f64::MAX, 0.0)] };
for k in 0..=10 {
let (l, r) = p.at(k as f64 / 10.0);
assert!(!l.is_nan() && r.is_finite(), "{l} {r}");
}
}
}
+4 -1
View File
@@ -192,11 +192,14 @@ fn strokes_without_pressure_round_trip_unchanged() {
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
}
/// The Lens profile's widths at its middle, a quarter of the way along and past its end.
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
assert_eq!(p.at(0.5), (1.0, 1.0));
assert_eq!(p.at(0.25), (0.5, 0.5));
// Half way up the lens the width curves above the straight ramp (0.5), flattening into its
// widest point (#5714).
assert_eq!(p.at(0.25), (0.625, 0.625));
assert_eq!(p.at(2.0), (0.0, 0.0));
}
+337 -61
View File
@@ -699,6 +699,34 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
/// the same place (by character) of both.
/// The run of `of` at fraction `u` of its characters.
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
let target = (u * total as f64) as usize;
let mut seen = 0;
for r in &of.runs {
seen += r.text.chars().count();
if seen > target {
return Some(r);
}
}
of.runs.last()
}
/// The fraction of the characters of `runs` at the middle of each run.
fn run_middles(runs: &[crate::text::TextRun]) -> Vec<f64> {
let total: usize = runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
let mut start = 0usize;
runs.iter()
.map(|r| {
let len = r.text.chars().count();
let u = (start as f64 + len as f64 / 2.0) / total as f64;
start += len;
u
})
.collect()
}
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
o.xf = lerp_affine(x.xf, y.xf, t);
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
@@ -706,27 +734,10 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64
{
*f = lerp_path(fx, fy, t);
}
// The run of `of` at fraction `u` of its characters.
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
let target = (u * total as f64) as usize;
let mut seen = 0;
for r in &of.runs {
seen += r.text.chars().count();
if seen > target {
return Some(r);
}
}
of.runs.last()
}
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
let mut start = 0usize;
let tf = tc as f32;
let mut resized = false;
for r in &mut o.runs {
let len = r.text.chars().count();
let u = (start as f64 + len as f64 / 2.0) / total as f64;
start += len;
let middles = run_middles(&o.runs);
for (r, u) in o.runs.iter_mut().zip(middles) {
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
let (sx, sy) = (&rx.style, &ry.style);
r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf);
@@ -752,33 +763,115 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
// Step counts
// =====================================================================================
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
fn color_distance(a: &Node, b: &Node) -> f32 {
let mut d = 0.0f32;
let mut cmp = |x: &Paint, y: &Paint| {
let cols = |p: &Paint| -> Vec<Color> {
match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
}
};
let (cx, cy) = (cols(x), cols(y));
for (i, c) in cx.iter().enumerate() {
if let Some(o) = cy.get(i).or(cy.first()) {
let (p, q) = (c.to_rgb(), o.to_rgb());
for k in 0..3 {
d = d.max((p[k] - q[k]).abs());
}
}
}
/// Largest channel difference between two solid colors, as a fraction of the channel's range: two
/// tints of the same swatch by their tints, RGB by its 0 to 255 values over 256, CMYK and gray by
/// their percentages, and colors in different spaces (or Lab) by the difference of their RGB
/// values over 255. A solid paint against no paint, or anything that isn't solid, differs by 0:
/// [`lerp_paint`] switches such paints halfway rather than mixing them.
fn paint_difference(x: &Paint, y: &Paint) -> f64 {
let (Paint::Solid { color: p, swatch: sp, tint: tp }, Paint::Solid { color: q, swatch: sq, tint: tq }) = (x, y) else {
return 0.0;
};
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
d
if sp.is_some() && sp == sq && tp != tq {
return f64::from((tp - tq).abs());
}
let most = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0.0f64, |m, (u, v)| m.max(f64::from((u - v).abs())));
match (p, q) {
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => most(&[*r, *g, *b], &[*r2, *g2, *b2]) * 255.0 / 256.0,
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => most(&[*c, *m, *y, *k], &[*c2, *m2, *y2, *k2]),
(Color::Gray { k }, Color::Gray { k: k2 }) => most(&[*k], &[*k2]),
_ => most(&p.to_rgb(), &q.to_rgb()),
}
}
/// Number of intermediate steps between two keys `len` apart (along the spine).
/// Largest difference (see [`paint_difference`]) between pairs of fills and strokes, or `None`
/// when any of them is a gradient or a pattern.
fn paints_difference(pairs: &[(&Paint, &Paint)]) -> Option<f64> {
let mut d = 0.0f64;
for (x, y) in pairs {
if [x, y].iter().any(|p| matches!(p, Paint::Gradient(_) | Paint::Pattern { .. })) {
return None;
}
d = d.max(paint_difference(x, y));
}
Some(d)
}
/// Largest channel difference (0..1, see [`paint_difference`]) between the solid colors of two
/// keys: their top fill and stroke, the runs of two text objects paired as [`lerp_text`] pairs
/// them, and the members of two groups paired as [`Lerp::new`] pairs them. `None` when any of
/// these holds a gradient or a pattern, whose steps Smooth Color counts by distance.
fn color_distance(a: &Node, b: &Node) -> Option<f64> {
let mut d =
paints_difference(&[(&a.appearance.fill_paint(), &b.appearance.fill_paint()), (&a.appearance.stroke_paint(), &b.appearance.stroke_paint())])?;
match (&a.kind, &b.kind) {
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if k1 == k2 && !(*k1 && ca.len() != cb.len()) => {
for (x, y) in ca.iter().zip(cb) {
d = d.max(color_distance(x, y)?);
}
}
(NodeKind::Text(x), NodeKind::Text(y)) => {
// The runs `lerp_text` pairs: those of `x` and of `y` at the middle of each run of either.
for u in run_middles(&x.runs).into_iter().chain(run_middles(&y.runs)) {
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
d = d.max(paints_difference(&[(&rx.style.fill, &ry.style.fill), (&rx.style.stroke, &ry.style.stroke)])?);
}
}
_ => {}
}
d.is_finite().then_some(d)
}
/// Smooth Color's steps between two keys by distance: the largest distance between their
/// corresponding visible bounding-box edges, in mean key widths (left and right edges) or heights
/// (top and bottom), rounded, less one, from 1 to 1000. Keys with no width are measured in their
/// mean height across as well, keys with no height in their mean width, and keys with neither in
/// points.
fn spatial_steps(ba: Rect, bb: Rect) -> usize {
let (w, h) = ((ba.width() + bb.width()) / 2.0, (ba.height() + bb.height()) / 2.0);
let size = |s: f64, other: f64| {
if s > 1e-9 {
s
} else if other > 1e-9 {
other
} else {
1.0
}
};
let (w, h) = (size(w, h), size(h, w));
let q = ((ba.x0 - bb.x0).abs().max((ba.x1 - bb.x1).abs()) / w).max((ba.y0 - bb.y0).abs().max((ba.y1 - bb.y1).abs()) / h);
if !q.is_finite() {
return 1;
}
((q + 0.5).floor() - 1.0).clamp(1.0, 1000.0) as usize
}
/// `255 × d` steps for a color difference `d` (see [`color_distance`]), a half step rounding down,
/// from 1 to 254.
fn level_steps(d: f64) -> f64 {
// The tolerance keeps a difference that lands on a half step (128 of 256 RGB values gives
// 127.5), stored as an `f32`, from rounding up.
(255.0 * d - 0.5 - 1e-4).ceil().clamp(1.0, 254.0)
}
/// Smooth Color's steps between two keys whose colors differ by `d` (see [`color_distance`]):
/// [`level_steps`], and at most eight per point of the largest of the keys' widths and heights
/// and the distance between their centers along either axis.
fn color_steps(d: f64, ba: Rect, bb: Rect) -> usize {
let by_color = level_steps(d);
let (ca, cb) = (ba.center(), bb.center());
let reach = [(ca.x - cb.x).abs(), (ca.y - cb.y).abs(), ba.width(), ba.height(), bb.width(), bb.height()].into_iter().fold(0.0f64, f64::max);
let by_density = (8.0 * reach + 0.5).floor().clamp(1.0, 254.0);
if !by_density.is_finite() {
return by_color as usize;
}
by_color.min(by_density) as usize
}
/// Number of intermediate steps between two keys. Specified Distance counts along the spine, `len`
/// long between them. Smooth Color counts from the keys' colors and visible bounds, whatever the
/// spine: keys whose colors differ make [`color_steps`] ([`level_steps`] when either key has no
/// bounds), and others make [`spatial_steps`] (1 when either key has no bounds).
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
@@ -787,17 +880,14 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
}
BlendSpacing::SmoothColor => {
let cd = color_distance(a, b);
if cd > 1.0 / 255.0 {
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
} else {
// Same colours: base the count on the distance between the objects.
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
let dist = match (ba, bb) {
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
_ => len,
};
((dist / 2.0).ceil() as usize).clamp(1, 256)
let bounds = a.visual_bounds().zip(b.visual_bounds());
match (color_distance(a, b), bounds) {
// Colors that differ by less than a thousandth of a step count as the same.
(Some(d), Some((ba, bb))) if d * 255.0 > 1e-3 => color_steps(d, ba, bb),
(Some(d), None) if d * 255.0 > 1e-3 => level_steps(d) as usize,
// Same colors, gradients and patterns: the distance between the objects.
(_, Some((ba, bb))) => spatial_steps(ba, bb),
(_, None) => 1,
}
}
}
@@ -1148,13 +1238,19 @@ fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) ->
.collect()
}
/// Most steps `spacing` makes between two keys, whatever their size and distance
/// ([`blend_step_count`]'s clamps).
fn most_steps(spacing: BlendSpacing) -> usize {
/// Most steps `spacing` makes between keys `a` and `b`, whatever their size and distance
/// ([`blend_step_count`]'s clamps). A copy of a blend is moved and scaled, never recolored
/// ([`Shape::Other`]), so Smooth Color between colors that differ makes no more than their color
/// change asks for, whatever the copy's scale or rotation. By distance, a copy turned by Align to
/// Path can measure its edges differently, so it counts as up to 1000.
fn most_steps(a: &Node, b: &Node, spacing: BlendSpacing) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
BlendSpacing::Distance(_) => 1000,
BlendSpacing::SmoothColor => 256,
BlendSpacing::SmoothColor => match color_distance(a, b) {
Some(d) if d * 255.0 > 1e-3 => level_steps(d) as usize,
_ => 1000,
},
}
}
@@ -1182,8 +1278,8 @@ struct StepCount {
impl StepCount {
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
/// by distance, or by Smooth Color, can make more steps scaled up, so a copy counts the most
/// its spacing can make between each pair ([`most_steps`]), an upper bound for any scale.
fn node(&mut self, n: &Node, copy: bool) -> usize {
let at = (std::ptr::from_ref(n) as usize, copy);
if let Some(&known) = self.known.get(&at) {
@@ -1249,7 +1345,11 @@ impl StepCount {
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
let inside = self.nodes(keys, true);
let each = self.steps(keys);
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
let most = keys.windows(2).map(|w| match w {
[a, b] => most_steps(a, b, spec.spacing),
_ => 0,
});
let demand = each.iter().zip(most).map(|(&c, m)| c.saturating_mul(m)).fold(0, usize::saturating_add);
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
}
}
@@ -1634,4 +1734,180 @@ mod tests {
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
}
/// A key for the Smooth Color tests: a rectangle of `size` (width, height) centered on `c`,
/// with `fill`, and `stroke` at `width`.
fn smooth_key(c: (f64, f64), size: (f64, f64), fill: Paint, stroke: Paint, width: f64) -> Node {
let ((cx, cy), (w, h)) = (c, size);
let rect = Rect::new(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0);
Node::path(NodeId(1), shapes::rectangle(rect), Appearance::basic(fill, stroke, width))
}
/// Smooth Color's steps between two filled `size` rectangles centered on `a` and `b`.
fn smooth(a: (f64, f64), b: (f64, f64), size: (f64, f64), fill_a: Color, fill_b: Color) -> usize {
let key = |c, fill| smooth_key(c, size, Paint::solid(fill), Paint::None, 0.0);
blend_step_count(&key(a, fill_a), &key(b, fill_b), BlendSpacing::SmoothColor, a.0.max(b.0) - a.0.min(b.0))
}
/// Smooth Color counts 255 steps for a full change of the channel that changes most, a half
/// step rounding down: RGB values over 256, CMYK and gray in percent, a spot color by its
/// tint, colors in different spaces by their RGB values over 255, a fill or a stroke,
/// whichever changes more.
#[test]
fn smooth_color_counts_a_step_per_level_of_color_change() {
let (a, b, size) = ((50.0, 150.0), (250.0, 150.0), (20.0, 20.0));
let black = Color::rgb8(0, 0, 0);
for (level, steps) in [(1, 1), (2, 2), (5, 5), (64, 64), (127, 127), (128, 127), (129, 128), (200, 199), (254, 253), (255, 254)] {
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, 0, 0)), steps, "black to red {level}");
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, level, level)), steps, "black to gray {level}");
}
assert_eq!(smooth(a, b, size, Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)), 254);
assert_eq!(smooth(a, b, size, black, Color::rgb8(64, 128, 0)), 127, "the channel that changes most");
assert_eq!(smooth(a, b, size, Color::rgb8(17, 42, 93), Color::rgb8(91, 147, 220)), 127);
for (percent, steps) in [(0.2, 1), (1.0, 3), (5.0, 13), (10.0, 25), (25.0, 64), (50.0, 127), (100.0, 254)] {
let p = percent / 100.0;
assert_eq!(smooth(a, b, size, Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(p, 0.0, 0.0, 0.0)), steps, "cyan {percent}%");
assert_eq!(smooth(a, b, size, Color::gray(0.0), Color::gray(p)), steps, "gray {percent}%");
}
let spot = |tint: f32| Paint::Solid { color: Color::rgb8(255, 0, 0).tinted(tint), swatch: Some("Spot".into()), tint };
let key = |c, fill| smooth_key(c, size, fill, Paint::None, 0.0);
assert_eq!(blend_step_count(&key(a, spot(1.0)), &key(b, spot(0.5)), BlendSpacing::SmoothColor, 200.0), 127, "a tint of 50%");
let stroked = |c, fill: Color, stroke: Color| smooth_key(c, size, Paint::solid(fill), Paint::solid(stroke), 1.0);
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
assert_eq!(blend_step_count(&stroked(a, red, black), &stroked(b, red, blue), BlendSpacing::SmoothColor, 200.0), 254, "the stroke changes");
let outline = |c, stroke: Color| smooth_key(c, size, Paint::None, Paint::solid(stroke), 1.0);
assert_eq!(blend_step_count(&outline(a, red), &outline(b, blue), BlendSpacing::SmoothColor, 200.0), 254, "strokes alone");
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::cmyk(0.0, 0.0, 0.0, 0.0)), 128, "RGB against CMYK white");
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::rgb8(255, 255, 255)), 127, "RGB against RGB white");
}
/// Smooth Color reads the colors of group members paired in order and of text runs, and
/// counts a solid paint against no paint by distance, since such a paint switches halfway
/// instead of mixing.
#[test]
fn smooth_color_reads_the_colors_inside_groups_and_text() {
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
let square = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
let group = |x: f64, top: Color| Node::new(NodeId(1), NodeKind::Group { children: vec![square(x, red), square(x, top)], clip: false });
let steps = |a: &Node, b: &Node| blend_step_count(a, b, BlendSpacing::SmoothColor, 200.0);
assert_eq!(steps(&group(50.0, red), &group(250.0, blue)), 254, "groups whose members change color");
assert_eq!(steps(&group(50.0, red), &group(250.0, red)), 9, "groups of the same colors");
let text = |x: f64, c: Color| {
let style = crate::CharStyle { fill: Paint::solid(c), ..Default::default() };
Node::new(NodeId(1), NodeKind::Text(Box::new(crate::TextObject::point(Point::new(x, 150.0), "Hi", style))))
};
assert_eq!(steps(&text(50.0, red), &text(250.0, blue)), 254, "text whose runs change color");
let mut two_runs = text(250.0, red);
if let NodeKind::Text(t) = &mut two_runs.kind {
let style = crate::CharStyle { fill: Paint::solid(blue), ..t.runs[0].style.clone() };
t.runs.push(crate::text::TextRun::new("Hi", style));
}
assert_eq!(steps(&text(50.0, red), &two_runs), 254, "a run that turns blue against one red run");
let empty = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::None, Paint::None, 0.0);
assert_eq!(steps(&smooth_key((50.0, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0), &empty(250.0)), 9, "a fill against none");
let nothing = |c: Color| Node::path(NodeId(1), PathData::new(vec![]), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
assert_eq!(steps(&nothing(red), &nothing(blue)), 254, "keys without bounds whose colors differ");
assert_eq!(steps(&nothing(red), &nothing(red)), 1, "keys without bounds of the same color");
}
/// Smooth Color makes at most eight steps per point of the largest of the keys' widths and
/// heights and the distance between their centers along either axis.
#[test]
fn smooth_color_makes_at_most_eight_steps_a_point() {
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
for (dx, steps) in [(0.0, 160), (20.0, 160), (20.1, 161), (25.0, 200), (31.5, 252), (40.0, 254)] {
assert_eq!(smooth((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0), red, blue), steps, "{dx} pt apart");
}
assert_eq!(smooth((100.0, 150.0), (100.0, 175.0), (20.0, 20.0), red, blue), 200, "25 pt apart vertically");
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (5.0, 5.0), red, blue), 40, "5 pt squares on each other");
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (25.0, 5.0), red, blue), 200, "the longer side counts");
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (1.0, 1.0), red, blue), 8);
}
/// Smooth Color between keys of the same colors counts the largest distance between their
/// corresponding visible edges in mean key widths or heights, rounded, less one; so do keys
/// with gradients, whatever their colors.
#[test]
fn smooth_color_between_the_same_colors_counts_by_distance() {
let red = Color::rgb8(255, 0, 0);
let same = |a, b, size| smooth(a, b, size, red, red);
for (dx, steps) in [(25.0, 1), (50.0, 2), (100.0, 4), (200.0, 9), (400.0, 19), (800.0, 39)] {
assert_eq!(same((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0)), steps, "20 pt squares {dx} pt apart");
}
for (side, steps) in [(5.0, 39), (10.0, 19), (40.0, 4), (80.0, 2)] {
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (side, side)), steps, "{side} pt squares");
}
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (80.0, 10.0)), 2, "wide");
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (10.0, 80.0)), 19, "tall");
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (20.0, 20.0)), 9, "diagonal");
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (80.0, 10.0)), 19, "wide and diagonal");
assert_eq!(same((50.0, 150.0), (1050.0, 150.0), (1.0, 1.0)), 999);
assert_eq!(same((50.0, 150.0), (1052.0, 150.0), (1.0, 1.0)), 1000, "at most 1000");
assert_eq!(same((50.0, 150.0), (50.0, 150.0), (20.0, 20.0)), 1, "at least one");
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (0.0, 20.0)), 9, "vertical lines, measured in their height");
assert_eq!(same((50.0, 150.0), (50.0, 350.0), (20.0, 0.0)), 9, "horizontal lines, measured in their width");
assert_eq!(same((50.0, 150.0), (60.0, 150.0), (0.0, 0.0)), 9, "points, measured in points");
let key = |c, size, stroke: f64| smooth_key(c, size, Paint::solid(red), Paint::solid(Color::BLACK), stroke);
let wide =
blend_step_count(&key((50.0, 150.0), (10.0, 20.0), 0.0), &key((250.0, 150.0), (80.0, 20.0), 0.0), BlendSpacing::SmoothColor, 200.0);
assert_eq!(wide, 4, "keys of different widths");
let strokes =
blend_step_count(&key((50.0, 150.0), (20.0, 20.0), 1.0), &key((250.0, 150.0), (20.0, 20.0), 10.0), BlendSpacing::SmoothColor, 200.0);
assert_eq!(strokes, 7, "strokes widen the visible edges");
let gradient = |a: Color, b: Color| {
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
Default::default(),
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)],
))))
};
let shaded = |c, fill| smooth_key(c, (20.0, 20.0), fill, Paint::None, 0.0);
let blue = Color::rgb8(0, 0, 255);
for (from, to) in [(gradient(red, blue), gradient(red, blue)), (gradient(red, blue), gradient(blue, red))] {
assert_eq!(blend_step_count(&shaded((50.0, 150.0), from), &shaded((250.0, 150.0), to), BlendSpacing::SmoothColor, 200.0), 9);
}
}
/// A Smooth Color blend counts each pair of keys on its own.
#[test]
fn smooth_color_counts_each_pair_of_keys() {
let key = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
let spec = BlendSpec::default();
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, red), key(250.0, blue)], &spec), vec![4, 254]);
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, blue), key(250.0, red)], &spec), vec![254, 254]);
}
/// A Smooth Color blend between different colors inside another blend's keys counts as the
/// steps its colors ask for, so the outer blend keeps the steps it asks for within
/// [`MAX_BLEND_STEPS`]: 200 steps, each a copy making 254 more. The copies keep their keys'
/// colors, also halfway between a red-to-blue and a blue-to-red blend.
#[test]
fn a_smooth_color_blend_in_a_key_counts_its_color_steps() {
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
let key = |x: f64, c: Color| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0);
let inner = |x: f64, from: Color, to: Color| blend_of(key(x, from), key(x + 100.0, to), BlendSpacing::SmoothColor);
let outer = blend_of(inner(0.0, red, blue), inner(500.0, red, blue), BlendSpacing::Steps(200));
let (keys, spec) = blend_parts(&outer).unwrap();
assert_eq!(blend_steps(keys, spec), vec![200]);
let crossed = blend_of(inner(0.0, red, blue), inner(500.0, blue, red), BlendSpacing::Steps(199));
let (keys, spec) = blend_parts(&crossed).unwrap();
assert_eq!(blend_steps(keys, spec), vec![199]);
let drawn = blend_expand(keys, spec);
let Some(NodeKind::Blend { children, .. }) = drawn.get(100).map(|n| &n.kind) else { panic!("a copy of a blend halfway") };
let fills: Vec<_> = children.iter().map(|k| k.appearance.fill_paint().color()).collect();
assert!(fills == [Some(red), Some(blue)] || fills == [Some(blue), Some(red)], "{fills:?}");
}
/// A Smooth Color blend between the same colors inside another blend's keys counts as up to
/// 1000 steps, which a copy turned to a path can ask for: 200 steps of copies of it come
/// down to 99.
#[test]
fn a_smooth_color_blend_of_the_same_colors_in_a_key_counts_up_to_1000_steps() {
let red = Color::rgb8(255, 0, 0);
let key = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0);
let inner = |x: f64| blend_of(key(x), key(x + 100.0), BlendSpacing::SmoothColor);
let outer = blend_of(inner(0.0), inner(500.0), BlendSpacing::Steps(200));
let (keys, spec) = blend_parts(&outer).unwrap();
assert_eq!(blend_steps(keys, spec), vec![99]);
}
}
+50
View File
@@ -195,6 +195,24 @@ impl ImageBlob {
jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16)))
}
/// Does the image hold CMYK samples of 16 bits (a CMYK TIFF), which [`Self::cmyk`] gives at 8
/// bits? Reads only its header.
pub fn is_cmyk16(&self) -> bool {
tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(16)))
}
/// The width and height a JPEG or TIFF says it has, for any color type. Reads only its header.
pub fn header_size(&self) -> Option<(u32, u32)> {
let bytes = self.bytes.as_slice();
if bytes.starts_with(&[0xFF, 0xD8]) {
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
d.decode_headers().ok()?;
let (w, h) = d.dimensions()?;
return Some((u32::try_from(w).ok()?, u32::try_from(h).ok()?));
}
tiff(bytes).and_then(|(_, mut d)| d.dimensions().ok())
}
/// The ink amounts of a CMYK image; `None` for other images, and ones that can't be decoded
/// or are larger than [`MAX_CMYK_PIXELS`].
pub fn cmyk(&self) -> Option<Inks> {
@@ -633,6 +651,38 @@ mod tests {
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
#[test]
fn a_header_gives_the_size_without_the_samples() {
assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2)));
// A CMYK TIFF of 6000 × 6000 pixels whose samples aren't there.
let mut t = b"II*\0\x08\0\0\0\x07\0".to_vec();
// Bits per sample: four values, after the entries (8 + 2 + 7 × 12 + 4 = 98).
let entries = [
(256u16, 4u16, 1u32, 6000u32),
(257, 4, 1, 6000),
(258, 3, 4, 98),
(262, 3, 1, 5),
(273, 4, 1, 106),
(277, 3, 1, 4),
(279, 4, 1, 144_000_000),
];
for (tag, kind, count, value) in entries {
t.extend_from_slice(&tag.to_le_bytes());
t.extend_from_slice(&kind.to_le_bytes());
t.extend_from_slice(&count.to_le_bytes());
t.extend_from_slice(&value.to_le_bytes());
}
t.extend_from_slice(&0u32.to_le_bytes());
t.extend_from_slice(&[8, 0, 8, 0, 8, 0, 8, 0]);
let big = ImageBlob::new("image/tiff", t);
assert!(big.is_cmyk());
assert_eq!(big.header_size(), Some((6000, 6000)));
assert_eq!(big.cmyk(), None);
let mut png = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(ImageBlob::png(png).header_size(), None, "not a JPEG or TIFF");
}
#[test]
fn a_cmyk_image_samples_its_ink_amounts() {
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
+4
View File
@@ -72,6 +72,7 @@ impl Document {
new_art,
dimensions,
retired_artboard_id: _,
opened_artboard: _,
index: _,
} = self;
*version == other.version
@@ -248,6 +249,7 @@ impl Document {
new_art: Default::default(),
dimensions: false,
retired_artboard_id: Default::default(),
opened_artboard: None,
index: Default::default(),
};
let id = d.alloc_id();
@@ -350,6 +352,7 @@ impl Document {
}
false
}
crate::id_index::note_tree_walk();
let mut p = vec![];
find(&self.layers, id, &mut p).then_some(p)
}
@@ -391,6 +394,7 @@ impl Document {
});
}
if !want.is_empty() {
crate::id_index::note_tree_walk();
let mut rest = HashMap::with_capacity(want.len());
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
out.extend(rest);
+8 -6
View File
@@ -99,13 +99,15 @@ impl Document {
}
/// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The
/// layer is removed wherever it is, also as a sublayer of another layer; an id that names
/// anything other than a layer is left alone.
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
if let Some(se) = self.symbol_edit.take() {
self.layers.retain(|l| l.id != se.layer);
let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)];
for id in layers.into_iter().flatten() {
if self.node(id).is_some_and(Node::is_layer) {
let _ = self.remove(id);
}
}
}
+65 -10
View File
@@ -6,9 +6,15 @@ impl Document {
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
/// instances measured by their art ([`Node::geometric_bounds_with`]).
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
let symbols = self.symbol_boxes();
self.bounds_of_with(ids, visual, &self.symbol_boxes())
}
/// [`bounds_of`](Document::bounds_of) measuring symbol instances from `symbols`, a cache shared
/// across calls: measuring many instances one by one otherwise measures their art again for
/// each.
pub fn bounds_of_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option<Rect> {
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) })
})
}
@@ -79,31 +85,77 @@ impl Document {
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
/// (Delete Empty Artboards).
pub fn artboard_has_art(&self, rect: Rect) -> bool {
self.art_meets(&self.symbol_boxes(), rect, true)
}
/// Is there a rect in `rects` (artboards) with no artwork on it, by [`Document::artboard_has_art`]?
/// The symbols are measured once for all of them. A first pass rules art out by a quick box
/// (paths and compound paths: their control points grown by the farthest their strokes reach),
/// so a rect away from every path is found without measuring along any; only when every rect
/// may hold art is each one measured.
pub fn any_artboard_without_art(&self, rects: impl IntoIterator<Item = Rect>) -> bool {
let rects: Vec<Rect> = rects.into_iter().collect();
let symbols = self.symbol_boxes();
rects.iter().any(|r| !self.art_meets(&symbols, *r, false)) || rects.iter().any(|r| !self.art_meets(&symbols, *r, true))
}
/// [`Document::artboard_has_art`] of `rect` when `exact`. Otherwise whether art may lie on it:
/// paths and compound paths count when their quick box meets it, so a false answer is exact and
/// a true one may not be.
fn art_meets(&self, symbols: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
let rect = rect.inflate(1e-6, 1e-6);
fn meets(b: Option<Rect>, r: Rect) -> bool {
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
/// A box that holds the visual bounds of a path, or of a compound path of paths, found
/// without measuring along its shape: its control points' box grown by the farthest its
/// strokes reach ([`crate::Appearance::outset`], as in [`Node::reach_bounds`]), with a
/// margin for rounding. None for other objects, and when the reach is not finite.
fn quick_box(c: &Node) -> Option<Rect> {
let b = match &c.kind {
NodeKind::Path { path, .. } => path.control_bounds()?,
NodeKind::Compound { children, .. } => children.iter().try_fold(None, |acc, m| {
let NodeKind::Path { path, .. } = &m.kind else { return None };
Some(vectorcraft_geom::union_opt(acc, path.control_bounds()))
})??,
_ => return None,
};
let o = c.appearance.outset();
let o = o * (1.0 + 1e-9) + 1e-6;
o.is_finite().then(|| b.inflate(o, o))
}
/// Does `c`'s visual box meet `rect`? Without `exact`, a quick box that meets it answers yes.
fn art_box_meets(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
match quick_box(c).map(|b| meets(Some(b), rect)) {
Some(false) => false,
Some(true) if !exact => true,
_ => meets(c.visual_bounds_with(s), rect),
}
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
match &c.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
let Some((clip, rest)) = children.split_first() else { return false };
let paint = clip.clip_paint();
if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) {
if (paint.fill.is_some() || paint.stroke.is_some()) && art_box_meets(clip, s, rect, exact) {
return true;
}
if !exact {
// The part of `rect` inside the clip's box lies inside `rect`.
return rest.iter().any(|m| has(m, s, rect, false));
}
// The members show only inside the clip's box.
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
rest.iter().any(|m| has(m, s, inside))
rest.iter().any(|m| has(m, s, inside, true))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
_ => art_box_meets(c, s, rect, exact),
}
}
let symbols = self.symbol_boxes();
self.layers.iter().any(|l| has(l, &symbols, rect))
self.layers.iter().any(|l| has(l, symbols, rect, exact))
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
@@ -296,11 +348,14 @@ impl Document {
t.cached_bounds = None;
t.cached_baselines.clear();
}
let on_path = n.extras().objects_on_path.is_some();
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
if on_path {
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
+7
View File
@@ -314,6 +314,13 @@ pub struct SeriesPaint {
/// Stroke weight in points, kept with [`Self::stroke`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_width: Option<f64>,
/// Opacity (0..1) of the topmost fill of each path directly in the series group (graph designs keep their own),
/// taken from the topmost fill row edited on one of them. Absent: fully opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_opacity: Option<f32>,
/// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_opacity: Option<f32>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
+277
View File
@@ -0,0 +1,277 @@
//! Color Halftone's Max Radius was a distance in points until documents of version 3; since then
//! it is in pixels of the Document Raster Effects resolution, as public documentation gives it
//! (#3260). [`Document::migrate_halftone_radii`] converts the radii of older documents, and
//! [`Document::halftone_radii_to_points`] converts them back for a file saved for an older app.
use std::sync::Arc;
use serde_json::{Value, json};
use crate::limits::FORMAT_VERSION;
use crate::{Appearance, Document, Effect, Node};
/// The first document version whose Color Halftone radii are in pixels.
const PIXEL_RADII_SINCE: u32 = 3;
const HALFTONE: &str = "pixelate.colorHalftone";
const RADIUS: &str = "maxRadius";
/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s
/// `DOC_KEY`), whose art can carry effects.
const BRUSHES: &str = "brushes";
/// The radius (points) an older document's halftone without one drew with.
const OLD_DEFAULT: f64 = 8.0;
/// The range the radius was kept within in points, and is kept within in pixels.
const RANGE: std::ops::RangeInclusive<f64> = 4.0..=127.0;
impl Document {
/// Saved before version 3: each Color Halftone's Max Radius in points becomes the pixels of
/// the document's raster effects resolution that draw the same dots, within the 4 to 127 px
/// the effect takes (a radius over 127 px, from a large radius at a high resolution, draws
/// smaller dots than before). In the art, symbols, patterns, graph designs, opacity masks and
/// graphic styles.
pub fn migrate_halftone_radii(&mut self) {
if self.version >= PIXEL_RADII_SINCE {
return;
}
self.version = FORMAT_VERSION;
let k = self.raster_ppi() / 72.0;
self.each_halftone(&mut |e| rescale(e, k));
}
/// For a file saved for an app before version 3 (every native file version before today's):
/// each Color Halftone's Max Radius in pixels becomes the points that draw the same dots,
/// within the 4 to 127 pt that app takes (a radius under 4 pt, from a fine screen at a high
/// resolution, draws bigger dots there), and the document becomes version 2, so reopening the
/// file here converts them back.
pub fn halftone_radii_to_points(&mut self) {
if self.version < PIXEL_RADII_SINCE {
return;
}
self.version = PIXEL_RADII_SINCE - 1;
let k = 72.0 / self.raster_ppi();
self.each_halftone(&mut |e| rescale(e, k));
}
/// `fix` each effect in the art, symbols, patterns (and the one being edited as it was), graph
/// designs, opacity masks, Shaper edits, graphic styles and brush definitions that use a Color
/// Halftone.
fn each_halftone(&mut self, fix: &mut dyn FnMut(&mut Effect)) {
for l in &mut self.layers {
node(l, fix);
}
for s in &mut self.symbols {
node(&mut s.art, fix);
}
// Pattern editing is saved with the pattern as it was, which Cancel puts back.
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
for a in &mut p.art {
node(a, fix);
}
}
for g in &mut self.graph_designs {
node(&mut g.art, fix);
}
if self.graphic_styles.iter().any(|s| has_halftone(&s.appearance)) {
for s in self.graphic_styles.iter_mut() {
appearance(&mut s.appearance, fix);
}
}
if let Some(b) = self.unknown.get_mut(BRUSHES) {
brushes(b, fix);
}
}
}
/// `fix` each Color Halftone in JSON `v` (brush definitions): every object whose `id` is
/// [`HALFTONE`] is a serialized [`Effect`] (object ids are numbers).
fn brushes(v: &mut Value, fix: &mut dyn FnMut(&mut Effect)) {
match v {
Value::Object(m) if m.get("id").and_then(Value::as_str) == Some(HALFTONE) => {
let params = m.get("params").cloned().unwrap_or(Value::Null);
let mut e = Effect { id: HALFTONE.into(), params, visible: true };
fix(&mut e);
m.insert("params".into(), e.params);
}
Value::Object(m) => m.values_mut().for_each(|v| brushes(v, fix)),
Value::Array(a) => a.iter_mut().for_each(|v| brushes(v, fix)),
_ => {}
}
}
/// `e`'s radius times `k` (pixels a point, or points a pixel), within the effect's range, when it
/// is a Color Halftone. Its default is 8 in either unit.
fn rescale(e: &mut Effect, k: f64) {
if e.id != HALFTONE {
return;
}
let pt = e.params.get(RADIUS).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(OLD_DEFAULT);
let px = pt.clamp(*RANGE.start(), *RANGE.end()) * k;
if !e.params.is_object() {
e.params = Value::Object(Default::default());
}
e.params[RADIUS] = json!(px.clamp(*RANGE.start(), *RANGE.end()));
}
fn has_halftone(a: &Appearance) -> bool {
a.effects.iter().chain(a.items.iter().flat_map(|i| i.effects())).any(|e| e.id == HALFTONE)
}
fn appearance(a: &mut Appearance, fix: &mut dyn FnMut(&mut Effect)) {
for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) {
fix(e);
}
}
/// Fix the effects of `n`'s subtree, its opacity masks and its Shaper edits (copying only what
/// changes).
fn node(n: &mut Arc<Node>, fix: &mut dyn FnMut(&mut Effect)) {
let n = Arc::make_mut(n);
if has_halftone(&n.appearance) {
appearance(&mut n.appearance, fix);
}
if let Some(x) = n.extras_if_any() {
if let Some(m) = x.mask.as_mut() {
node(&mut m.art, fix);
}
// A Shaper Group's face and edge edits paint with appearances of their own.
if let Some(s) = x.shaper.as_mut() {
let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut());
for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) {
appearance(p, fix);
}
}
}
if let Some(ch) = n.children_mut() {
for c in ch {
node(c, fix);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn halftone(radius: Option<f64>) -> Effect {
let params = radius.map_or_else(|| json!({"channel1": 108.0}), |r| json!({"maxRadius": r, "channel1": 108.0}));
Effect { id: HALFTONE.into(), params, visible: true }
}
fn radius(e: &Effect) -> f64 {
e.params[RADIUS].as_f64().unwrap()
}
#[test]
fn older_radii_become_the_pixels_that_draw_the_same_dots() {
for (ppi, pt, px) in [(72.0, 8.0, 8.0), (300.0, 8.0, 33.333333333333336), (300.0, 4.0, 16.666666666666668), (150.0, 127.0, 127.0)] {
let mut e = halftone(Some(pt));
rescale(&mut e, ppi / 72.0);
assert!((radius(&e) - px).abs() < 1e-9, "{pt} pt at {ppi} ppi: {}", radius(&e));
assert_eq!(e.params["channel1"], json!(108.0), "the angles stay");
}
// A radius left out drew at the old default, 8 pt.
let mut e = halftone(None);
rescale(&mut e, 300.0 / 72.0);
assert!((radius(&e) - 33.333333333333336).abs() < 1e-9);
// Other effects keep their parameters.
let mut blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 8.0}), visible: true };
rescale(&mut blur, 300.0 / 72.0);
assert_eq!(blur.params, json!({"radius": 8.0}));
}
#[test]
fn only_documents_before_version_3_are_converted_once() {
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = 300.0;
let mut style = d.graphic_styles.first().cloned().unwrap();
style.appearance.effects.push(halftone(Some(8.0)));
d.graphic_styles.push(style);
let fresh = d.clone();
d.migrate_halftone_radii();
assert_eq!(d, fresh, "a new document's radii are already pixels");
d.version = 1;
d.migrate_halftone_radii();
assert_eq!(d.version, FORMAT_VERSION);
let r = d.graphic_styles.last().map(|s| radius(&s.appearance.effects[0])).unwrap();
assert!((r - 33.333333333333336).abs() < 1e-9, "{r}");
let once = d.clone();
d.migrate_halftone_radii();
assert_eq!(d, once, "converted documents are left alone");
}
#[test]
fn radii_go_back_to_points_for_an_older_app_and_return_unchanged() {
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = 300.0;
let mut style = d.graphic_styles.first().cloned().unwrap();
style.appearance.effects.push(halftone(Some(100.0)));
style.appearance.effects.push(halftone(Some(4.0)));
d.graphic_styles.push(style);
let r = |d: &Document, i: usize| d.graphic_styles.last().map(|s| radius(&s.appearance.effects[i])).unwrap();
d.halftone_radii_to_points();
assert_eq!(d.version, 2, "reopened here, it converts back");
assert!((r(&d, 0) - 24.0).abs() < 1e-9, "100 px at 300 ppi is 24 pt: {}", r(&d, 0));
assert_eq!(r(&d, 1), 4.0, "0.96 pt is under the 4 pt the older app takes");
let points = d.clone();
d.halftone_radii_to_points();
assert_eq!(d, points, "already in points");
d.migrate_halftone_radii();
assert!((r(&d, 0) - 100.0).abs() < 1e-9, "{}", r(&d, 0));
}
/// Shaper face and edge edits paint with appearances of their own, and brush definitions keep
/// their art as JSON: their radii convert both ways too.
#[test]
fn shaper_edits_and_brushes_are_converted_both_ways() {
use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec};
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = 300.0;
d.version = 1;
let mut look = Appearance::default_art();
look.effects = vec![halftone(Some(8.0))];
let face = FaceEdit {
selector: FaceSelector { sources: vec![0], anchor: vectorcraft_geom::Point::ZERO },
erase: false,
erase_edges: false,
paint_source: None,
merge: None,
paint: Some(look),
};
let mut shaper = Node::group(d.alloc_id(), vec![]);
shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] }));
let l = d.layers[0].id;
d.insert(Some(l), 0, shaper).unwrap();
let effect = serde_json::to_value(halftone(Some(8.0))).unwrap();
d.unknown.insert(BRUSHES.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [effect]}}}]));
let radii = |d: &Document| {
let shaper = d.layers[0].children().and_then(|c| c.first()).cloned().unwrap();
let paint = shaper.extras().shaper.as_ref().and_then(|s| s.faces[0].paint.clone()).unwrap();
let brush = d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"][RADIUS].as_f64().unwrap();
(radius(&paint.effects[0]), brush)
};
d.migrate_halftone_radii();
let (face, brush) = radii(&d);
assert!((face - 33.333333333333336).abs() < 1e-9, "the face edit: {face}");
assert!((brush - 33.333333333333336).abs() < 1e-9, "the brush: {brush}");
assert_eq!(d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"]["channel1"], json!(108.0));
d.halftone_radii_to_points();
let (face, brush) = radii(&d);
assert!((face - 8.0).abs() < 1e-9 && (brush - 8.0).abs() < 1e-9, "back in points: {face}, {brush}");
}
#[test]
fn the_pattern_being_edited_is_converted_as_it_was() {
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = 300.0;
let id = d.alloc_id();
let mut look = Appearance::default_art();
look.effects = vec![halftone(Some(8.0))];
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let tile = Arc::new(Node::path(id, square, look));
let original = Some(crate::PatternDef::new("Tile", vec![tile]));
d.pattern_edit = Some(crate::PatternEdit { pattern: "Tile".into(), layer: crate::NodeId(0), original });
d.version = 1;
d.migrate_halftone_radii();
let art = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
assert!((radius(&art.appearance.effects[0]) - 33.333333333333336).abs() < 1e-9, "the tile Cancel puts back");
}
}
+109 -14
View File
@@ -6,9 +6,10 @@ use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{BezPath, FillRule, PathData, PathSeg, Point, Rect};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, SymbolBoxes};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
@@ -508,8 +509,9 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
// Isolated before its artboard was locked: nothing in it hits.
if locks.holds_id(scope) {
// Isolated before its artboard, or its layer or a group around it, was locked or hidden:
// nothing in it hits (#5309).
if !doc.is_editable_with(&locks, scope) {
return None;
}
if isolated.is_container() {
@@ -723,6 +725,43 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
/// Check the body without assembling a `BezPath`. A miss near a joint or cap may still hit a
/// miter or square cap, so the caller falls back to the full stroke check there.
fn near_path_body(path: &PathData, p: Point, radius: f64, decoration_reach: f64) -> Option<(bool, bool)> {
if !radius.is_finite() || radius < 0.0 || !decoration_reach.is_finite() || decoration_reach < 0.0 {
return None;
}
let radius_sq = radius * radius;
let decoration_sq = decoration_reach * decoration_reach;
let mut near_decoration = false;
for sp in &path.subpaths {
for i in 0..sp.segment_count() {
let c = sp.segment(i);
if decoration_reach > 0.0 && !near_decoration && ([c.p0, c.p3].iter().any(|q| (*q - p).hypot2() <= decoration_sq)) {
near_decoration = true;
}
let controls = [c.p0, c.p1, c.p2, c.p3];
if controls.iter().all(|q| q.x < p.x - radius)
|| controls.iter().all(|q| q.x > p.x + radius)
|| controls.iter().all(|q| q.y < p.y - radius)
|| controls.iter().all(|q| q.y > p.y + radius)
{
continue;
}
let seg = if sp.segment_is_line(i) { PathSeg::Line(vectorcraft_geom::kurbo::Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) };
if seg.nearest(p, 1e-9).distance_sq <= radius_sq {
if vectorcraft_geom::bez::has_length(&seg) {
return Some((true, near_decoration));
}
// A zero-length segment can still hit as an outline, though a painted stroke
// needs a segment with direction. Let the existing check decide its hit kind.
near_decoration = true;
}
}
}
Some((false, near_decoration))
}
#[cfg(test)]
thread_local! {
/// How many objects [`hit_leaf`] tested on this thread.
@@ -734,6 +773,43 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
match &n.kind {
NodeKind::Path { path, rule, .. } => {
if opt.outline
&& let Some((hit, needs_full_check)) = near_path_body(path, p, opt.tol, 0.0)
{
if hit {
return Some(HitKind::Outline);
}
if !needs_full_check {
return None;
}
}
let mut strokes = n.appearance.painted_strokes();
if !opt.outline
&& n.appearance.fill().is_none_or(|f| f.paint.is_none())
&& let Some(s) = strokes.next()
&& strokes.next().is_none()
&& s.align == StrokeAlign::Center
&& s.width.is_finite()
&& s.width > 0.0
&& s.dash.as_ref().is_none_or(|d| !d.is_dashed())
&& s.profile.is_none()
&& s.start_arrow.is_none()
&& s.end_arrow.is_none()
&& s.brush.is_none()
&& let Some((hit, near_decoration)) = near_path_body(
path,
p,
s.width / 2.0 + opt.tol,
if s.join == crate::LineJoin::Miter || s.cap == crate::LineCap::Square { s.reach() + opt.tol } else { 0.0 },
)
{
if hit {
return Some(HitKind::Stroke);
}
if !near_decoration {
return None;
}
}
let bp = path.to_bezpath();
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
@@ -880,9 +956,22 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
marquee_with(doc, r, scope, leaves, false)
}
/// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group
/// traversal follow [`marquee`]; compounds contribute the complete path they paint.
pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option<NodeId>) -> Vec<NodeId> {
marquee_with(doc, r, scope, true, true)
}
fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, whole_compounds: bool) -> Vec<NodeId> {
let mut out = Vec::new();
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
// marquee touches none of it, as clicks don't (#5309).
Some(s) if !doc.is_editable(s.id) => vec![],
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
@@ -924,13 +1013,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
/// compound paths, layers and groups are touched by their box.
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
marquee_in(n, r).is_some_and(|r| {
children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds))
})
}
NodeKind::Compound { children, .. } if whole_compounds => {
children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r))
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)),
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -939,18 +1034,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
let Some(r) = marquee_in(n, r) else { return };
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
collect_leaves(c, s, r, scope, out);
collect_leaves(c, s, r, scope, whole_compounds, out);
}
}
}
_ => {
if touches(n, s, r, scope) {
if touches(n, s, r, scope, whole_compounds) {
out.push(n.id);
}
}
@@ -963,8 +1058,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
continue;
}
if leaves {
collect_leaves(t, &symbols, r, scope, &mut out);
} else if touches(t, &symbols, r, scope) {
collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out);
} else if touches(t, &symbols, r, scope, whole_compounds) {
out.push(t.id);
}
}
+33
View File
@@ -38,6 +38,39 @@ fn unfilled_interior_misses() {
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
}
#[test]
fn long_plain_stroke_hits_its_middle_and_curved_end() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&(0..=4097).map(|i| Point::new(i as f64 * 10.0, 0.0)).collect::<Vec<_>>(), false);
sp.anchors[4096].h_out = Point::new(40960.0, 100.0);
sp.anchors[4097].h_in = Point::new(40970.0, 100.0);
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(40970.0, 100.0)));
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(0.0, 100.0)));
let path = vectorcraft_geom::PathData::single(sp);
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
d.insert(Some(l), 0, Node::path(id, path, look)).unwrap();
let outline = HitOptions { outline: true, ..Default::default() };
for point in [Point::new(20485.0, 0.0), Point::new(40965.0, 75.0)] {
assert_eq!(hit_test(&d, point, HitOptions::default()).map(|h| h.kind), Some(HitKind::Stroke));
assert_eq!(hit_test(&d, point, outline).map(|h| h.kind), Some(HitKind::Outline));
}
assert!(hit_test(&d, Point::new(20485.0, 50.0), HitOptions::default()).is_none());
}
#[test]
fn coincident_anchors_remain_hittable_as_an_outline() {
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[Point::new(50.0, 50.0); 2], false));
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
d.insert(d.layers.first().map(|l| l.id), 0, Node::path(id, path, look)).unwrap();
let at = Point::new(50.0, 50.0);
assert_eq!(hit_test(&d, at, HitOptions::default()).map(|h| h.kind), Some(HitKind::Outline));
assert_eq!(hit_test(&d, at, HitOptions { outline: true, ..Default::default() }).map(|h| h.kind), Some(HitKind::Outline));
}
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
/// outline it doesn't touch.
#[test]
+19
View File
@@ -90,6 +90,24 @@ impl Paths {
/// object and look the next one up, again and again).
const MISSES_PER_REBUILD: u32 = 4;
thread_local! {
/// [`tree_walks`] of this thread.
static TREE_WALKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many times lookups by id on this thread have walked a document's tree, reading up to all of
/// its nodes: one count per search of the tree and per rebuild of the cache. An edit that looks its
/// objects up one at a time counts about one walk per object; one that looks them all up at once
/// counts a few.
pub fn tree_walks() -> u64 {
TREE_WALKS.get()
}
/// Adds one to [`tree_walks`].
pub(crate) fn note_tree_walk() {
TREE_WALKS.set(TREE_WALKS.get().saturating_add(1));
}
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
#[derive(Default)]
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
@@ -125,6 +143,7 @@ impl IdIndex {
}
}
}
note_tree_walk();
let mut out = Paths::default();
walk(layers, u32::MAX, &mut out);
let out = Arc::new(out);
+13 -2
View File
@@ -16,8 +16,10 @@ pub mod corners;
pub mod depth;
pub mod dimension;
pub mod graph;
mod halftone_radii;
pub mod hit;
mod id_index;
pub use id_index::tree_walks;
pub mod inks;
mod inline;
pub mod intertwine;
@@ -40,6 +42,7 @@ pub mod live;
pub mod live_paint;
pub mod marks;
mod mem;
pub use mem::{appearance_heap_bytes, paint_heap_bytes};
pub mod metadata;
pub mod node;
pub mod objects_on_path;
@@ -67,6 +70,7 @@ mod symbols;
pub use symbols::SymbolBoxes;
pub mod text;
pub mod trace;
pub mod twist;
pub mod variables;
pub mod xml_id;
@@ -110,6 +114,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
@@ -226,8 +231,9 @@ pub struct Document {
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
/// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
/// symbol already holds the art of its editing layer.
#[serde(default, skip_serializing)]
/// symbol already holds the art of its editing layer. Never read either: a value in file data
/// is dropped, so the layer it names is kept and saving does not write the value again.
#[serde(default, skip_serializing, deserialize_with = "skip::read_as_none")]
pub symbol_edit: Option<SymbolEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
@@ -320,6 +326,11 @@ pub struct Document {
/// Never saved.
#[serde(skip)]
pub retired_artboard_id: RetiredArtboardId,
/// The id of the artboard an imported file names as its active one (a `.ai` file's
/// `CropAreaActive`): the document opens with that artboard active. A `.ai` save or export
/// names it as the file's active artboard. `None`: the first. Native files do not keep it.
#[serde(skip)]
pub opened_artboard: Option<u32>,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
+5 -1
View File
@@ -13,7 +13,11 @@ pub enum DocError {
Invalid(String),
}
pub const FORMAT_VERSION: u32 = 1;
/// The document version new documents get. Version 2: a positive Twist angle turns clockwise
/// ([`crate::twist`]). Version 3: Color Halftone's Max Radius is in pixels of the raster effects
/// resolution (older documents are converted on opening,
/// [`crate::Document::migrate_halftone_radii`]).
pub const FORMAT_VERSION: u32 = 3;
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
+174 -2
View File
@@ -46,10 +46,26 @@ impl LinkInfo {
Self { path: path.into(), ..Self::default() }
}
/// The file name: the path's last component, whichever system's separators it uses.
/// The file name: the path's last component, whichever system's separators it uses
/// ([`path_separators`]).
pub fn name(&self) -> &str {
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
self.path.rsplit(path_separators(&self.path)).next().unwrap_or(&self.path)
}
/// The folder the file is in: the path without its name and the separators before it.
pub fn folder(&self) -> &str {
let folder = self.path.get(..self.path.len().saturating_sub(self.name().len())).unwrap_or_default();
folder.trim_end_matches(path_separators(&self.path))
}
}
/// The characters that separate the folders of `path`. A path with a colon and no `/` or `\` is a
/// classic Mac OS path (`Disk:Folder:file.tif`, as older documents store links), whose folders are
/// separated by colons. Any other path is separated by `/` and `\`: its file name may hold a colon
/// (`/Users/a/x:y.png`) and never holds a `/` or `\`.
pub fn path_separators(path: &str) -> &'static [char] {
const SLASHES: &[char] = &['/', '\\'];
if path.contains(':') && !path.contains(SLASHES) { &[':'] } else { SLASHES }
}
impl From<&str> for LinkInfo {
@@ -164,6 +180,31 @@ impl ImageBlob {
pub fn is_proxy(&self) -> bool {
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
}
/// Does a colorize paint ([`crate::NodeExtras::colorize`]) show on these bytes? Not while
/// they are a preview whose pixels aren't grayscale, such as [`missing_placeholder`]: until
/// the file is relinked the image shows the preview as it is, and keeps the paint.
pub fn takes_colorize(&self) -> bool {
!self.is_proxy() || self.gray_source().is_some()
}
}
/// The side of [`missing_placeholder`]'s image, in pixels.
pub const MISSING_SIDE: u32 = 64;
/// What a linked image shows when an importer has no pixels for it (its file is missing or isn't
/// in the data): a light tile, crossed out, [`MISSING_SIDE`] pixels a side, as a PNG. `None` when
/// it can't be encoded.
pub fn missing_placeholder() -> Option<Vec<u8>> {
let last = MISSING_SIDE - 1;
let img = image::RgbaImage::from_fn(MISSING_SIDE, MISSING_SIDE, |x, y| {
let edge = x == 0 || y == 0 || x == last || y == last;
let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1;
image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] })
});
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
@@ -374,6 +415,68 @@ impl Document {
}
}
/// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the
/// way to one are copied.
pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) {
fn link_of(n: &Node) -> Option<&LinkInfo> {
match &n.kind {
NodeKind::Image(image) => image.link.as_ref(),
NodeKind::PlacedDocument(placed) => Some(&placed.link),
_ => None,
}
}
/// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link.
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) {
// Each child is updated through a second handle, so an updated child is a new node and a new
// pointer marks a change.
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
update(&mut art, pick, f);
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
});
let mut children = vec![];
for (i, c) in n.children().into_iter().flatten().enumerate() {
let mut new = Arc::clone(c);
update(&mut new, pick, f);
if !Arc::ptr_eq(&new, c) {
children.push((i, new));
}
}
let own = link_of(n).is_some_and(pick);
if !own && mask.is_none() && children.is_empty() {
return;
}
let n = Arc::make_mut(n);
let link = match &mut n.kind {
NodeKind::Image(image) => image.link.as_mut(),
NodeKind::PlacedDocument(placed) => Some(&mut placed.link),
_ => None,
};
if let Some(link) = link.filter(|_| own) {
f(link);
}
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
m.art = art;
}
if let Some(slots) = n.children_mut() {
for (i, c) in children {
if let Some(slot) = slots.get_mut(i) {
*slot = c;
}
}
}
}
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
}
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
/// art included) for which `pick` holds; only the subtrees holding one are copied.
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
@@ -445,6 +548,46 @@ mod tests {
.unwrap()
}
/// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them;
/// the other nodes stay shared with the document it was cloned from.
#[test]
fn update_links_where_copies_only_the_nodes_it_changes() {
let mut d = Document::new(10.0, 10.0);
image(&mut d, "a", Some(LinkInfo::new("/art/a.png")));
image(&mut d, "b", Some(LinkInfo::new("/art/b.png")));
image(&mut d, "c", None);
let mut copy = d.clone();
let mut calls = 0;
copy.update_links_where(
|l| l.path == "/art/a.png",
|l| {
calls += 1;
l.relative = Some("art/a.png".into());
},
);
assert_eq!(calls, 1);
let node = |d: &Document, key: &str| {
d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone()
};
let relative = |n: &Node| match &n.kind {
NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()),
_ => None,
};
assert_eq!((relative(&node(&copy, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None));
assert!(Arc::ptr_eq(&node(&copy, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(&copy, "c"), &node(&d, "c")));
assert!(!Arc::ptr_eq(&copy.layers[0], &d.layers[0]));
// Nothing picked: nothing is copied.
let mut same = d.clone();
same.update_links_where(|_| false, |_| {});
assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0]));
}
#[test]
fn the_placeholder_is_a_png() {
let png = missing_placeholder().unwrap();
assert_eq!(image::load_from_memory(&png).unwrap().width(), MISSING_SIDE);
}
#[test]
fn a_path_string_from_older_files_reads_as_a_link() {
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
@@ -460,6 +603,35 @@ mod tests {
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}
/// A classic Mac OS path is named by its last colon-separated component, and its folder is
/// what comes before; other paths keep their `/` and `\` separators, and a name never holds
/// one.
#[test]
fn a_classic_mac_path_is_named_by_its_last_component() {
let parts = |path: &str| {
let l = LinkInfo::new(path);
(l.name().to_string(), l.folder().to_string())
};
assert_eq!(parts("Macintosh HD:Images:photo.tif"), ("photo.tif".into(), "Macintosh HD:Images".into()));
assert_eq!(parts("Disk:photo.tif"), ("photo.tif".into(), "Disk".into()));
assert_eq!(parts("Disk:Art/2024:a\\b.tif"), ("b.tif".into(), "Disk:Art/2024:a".into()));
assert_eq!(parts("Disk:Images:../other.png"), ("other.png".into(), "Disk:Images:..".into()), "a name reaches no other folder");
assert_eq!(parts("C:\\art\\photo.png"), ("photo.png".into(), "C:\\art".into()));
assert_eq!(parts("c:/art/photo.png"), ("photo.png".into(), "c:/art".into()));
assert_eq!(parts("/Users/a/x:y.png"), ("x:y.png".into(), "/Users/a".into()), "a POSIX name may hold a colon");
assert_eq!(parts("./art/x:y.png"), ("x:y.png".into(), "./art".into()));
assert_eq!(parts("..\\art\\x:y.png"), ("x:y.png".into(), "..\\art".into()));
assert_eq!(parts("\\\\?\\C:\\art\\photo.png"), ("photo.png".into(), "\\\\?\\C:\\art".into()));
assert_eq!(parts("file:///art/photo.png"), ("photo.png".into(), "file:///art".into()));
assert_eq!(parts("file:/art/photo.png"), ("photo.png".into(), "file:/art".into()));
assert_eq!(parts("assets/icon:dark.png"), ("icon:dark.png".into(), "assets".into()), "a relative path with slashes");
assert_eq!(parts(":Images:photo.tif"), ("photo.tif".into(), ":Images".into()), "a classic path relative to its folder");
assert_eq!(parts("/Users/a//photo.png").1, "/Users/a", "repeated separators");
assert_eq!(parts("photo.png"), ("photo.png".into(), String::new()));
assert_eq!(parts("C:photo.png"), ("photo.png".into(), "C".into()), "a drive-relative path has the same shape");
assert_eq!(LinkInfo::new("Macintosh HD:Images:photo.tif").path, "Macintosh HD:Images:photo.tif", "the stored path is kept");
}
#[test]
fn a_reader_hashes_as_its_bytes_do() {
// Several 64 KB reads, and none.
+61 -2
View File
@@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
#[cfg(test)]
use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame,
envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles,
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
set_type_ink, smooth_handles,
};
pub(crate) use expand::max_outset;
pub use expand::{
@@ -233,6 +234,64 @@ mod tests {
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
/// `bounds_margin` grows the box an envelope maps from, only while Distort Appearance is on. It
/// holds shares of the box, and an envelope with an uneven frame maps from the same box in that
/// frame. A native file writes it only when it isn't zero.
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
let on = EnvelopeOptions { bounds_margin: [0.125, 0.25, 0.0625, 0.5], ..EnvelopeOptions::NEW };
let geometric = Rect::new(10.0, 20.0, 110.0, 70.0);
let grown = Rect::new(3.75, 13.75, 160.0, 82.5);
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions::default()), geometric);
assert_eq!(src(&content, Affine::IDENTITY, on), grown);
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { distort_appearance: false, ..on }), geometric);
for bad in [f32::NAN, f32::INFINITY, -0.05] {
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { bounds_margin: [bad, 0.25, 0.0625, 0.5], ..on }), geometric);
}
// A finite share of a box too wide to grow leaves the box as it is.
let wide = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1e308, 10.0)), crate::Appearance::default_art());
let wide = [Arc::new(wide)];
let huge = EnvelopeOptions { bounds_margin: [0.0, 0.0, f32::MAX, 0.0], ..on };
assert_eq!(src(&wide, Affine::IDENTITY, huge), src(&wide, Affine::IDENTITY, EnvelopeOptions::NEW));
// Bounds follow the box: a warp that doesn't bend covers it.
let b = envelope_bounds(&content, &kind, Affine::IDENTITY, on).unwrap();
assert!((b.x0 - 3.75).abs() < 1e-6 && (b.y1 - 82.5).abs() < 1e-6, "{b:?}");
// Art taken through an uneven frame maps from the same box in that frame.
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(2.0, 0.5);
let mut placed = (*content[0]).clone();
placed.transform(frame, true);
let framed = src(&[Arc::new(placed)], frame, on);
assert!((framed.x0 - 3.75).abs() + (framed.y0 - 13.75).abs() + (framed.x1 - 160.0).abs() + (framed.y1 - 82.5).abs() < 1e-6, "{framed:?}");
// A frame that turns and scales gives the same margins as the art had before it took them.
let mut stroked = (*content[0]).clone();
if let Some(s) = stroked.appearance.stroke_mut() {
s.width = 10.0;
}
let margin = |n: &Node, frame: Affine| {
let (with, plain) = envelope_content_boxes(&[Arc::new(n.clone())], frame).unwrap();
envelope_stroke_margin(with, plain).unwrap()
};
let before = margin(&stroked, Affine::IDENTITY);
assert_eq!(before.map(|m| (m * 1000.0).round()), [100.0, 100.0, 50.0, 50.0]);
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale(2.0);
let mut turned = stroked.clone();
turned.transform(frame, true);
assert_eq!(margin(&turned, frame).map(|m| (m * 1000.0).round()), before.map(|m| (m * 1000.0).round()));
// Margins an `f32` can't hold are none.
assert_eq!(envelope_stroke_margin(Rect::new(-5e41, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0)), None);
assert!(serde_json::to_value(EnvelopeOptions::default()).unwrap().get("boundsMargin").is_none());
let saved = serde_json::to_value(on).unwrap();
assert_eq!(saved["boundsMargin"], serde_json::json!([0.125, 0.25, 0.0625, 0.5]));
assert_eq!(serde_json::from_value::<EnvelopeOptions>(saved).unwrap(), on);
}
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
/// ellipse's short radius), checked at 20 places along every border edge.
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
+442 -17
View File
@@ -1,6 +1,7 @@
use super::expand::{Hooks, Outliner, Warper, map_node};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -76,6 +77,12 @@ pub struct EnvelopeOptions {
pub distort_linear_gradients: bool,
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
pub distort_pattern_fills: bool,
/// With Distort Appearance, how far the box the content maps from reaches past its geometric
/// bounds at the top, bottom, left and right, as shares of their height and width. An envelope
/// opened from a `.ai` whose saved size includes its strokes has these margins. New envelopes
/// have none. Leaving Edit Contents measures them again when the strokes reach differently.
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub bounds_margin: [f32; 4],
}
impl Default for EnvelopeOptions {
@@ -86,6 +93,7 @@ impl Default for EnvelopeOptions {
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
}
}
}
@@ -98,6 +106,7 @@ impl EnvelopeOptions {
distort_appearance: true,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
};
/// Do gradients bend with the art?
pub fn gradients(&self) -> bool {
@@ -107,6 +116,319 @@ impl EnvelopeOptions {
pub fn patterns(&self) -> bool {
self.distort_appearance && self.distort_pattern_fills
}
/// The box content with geometric bounds `r` maps from. Margins that aren't finite, are
/// negative or give a box that isn't finite count as none.
fn source_box(&self, r: Rect) -> Rect {
let [top, bottom, left, right] = self.bounds_margin.map(f64::from);
if !self.distort_appearance || !self.bounds_margin.iter().all(|m| m.is_finite() && *m >= 0.0) {
return r;
}
let grown = crate::marks::outset(r, [top * r.height(), bottom * r.height(), left * r.width(), right * r.width()]);
if grown.is_finite() { grown } else { r }
}
}
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
/// [`Node::transform`] scales them.
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
(vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
});
Some((stroked?, plain?))
}
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
/// [`set_type_ink`], and when it draws nothing.
pub fn envelope_content_bounds(content: &[Arc<Node>]) -> Option<Rect> {
content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
}
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn
/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is
/// laid out above this crate, so the text crate registers one with [`set_type_ink`].
pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option<Rect>;
/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform
/// after it.
type TypeMap = (Affine, Option<(Homography, Affine)>);
static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new();
/// Register how envelopes measure type, and a number that changes whenever what it reads besides
/// the type changes, such as the installed fonts (the first registration stays).
pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) {
// Already set: the same measure, registered by another session, an import or a renderer.
let _ = TYPE_INK.set((f, inputs));
}
/// How many entries each table of [`TypeInks`] holds before it is emptied.
const TYPE_INK_KEPT: usize = 1024;
/// `map`, bit for bit, for a table key.
fn map_bits(map: &TypeMap) -> [u64; 21] {
let mut bits = [0; 21];
let (m, projection) = map;
for (b, v) in bits.iter_mut().zip(m.as_coeffs()) {
*b = v.to_bits();
}
if let Some((h, after)) = projection {
for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) {
*b = v.to_bits();
}
}
bits
}
/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an
/// affine map without its move) and the inputs number registered with [`set_type_ink`].
type NodeKey = (usize, [u64; 21], u64);
/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and
/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number.
type ContentKey = ([u64; 2], [u64; 21], u64);
/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs
/// number: by the object (an unchanged object is found without being read),
/// and by its content (a moved or copied object is found without laying it out again). Neither
/// table keeps a copy of the type.
#[derive(Default)]
struct TypeInks {
/// By the object's address, with a weak pointer that keeps the address from being reused.
nodes: std::collections::HashMap<NodeKey, (std::sync::Weak<Node>, Option<Rect>)>,
/// By the type's content.
content: std::collections::HashMap<ContentKey, Option<Rect>>,
}
/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type
/// that could not be hashed whole).
struct Missing {
by_node: NodeKey,
content: Option<ContentKey>,
}
impl TypeInks {
/// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under
/// once it is measured.
fn find(&mut self, n: &Arc<Node>, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result<Option<Rect>, Box<Missing>> {
// While the table holds a weak pointer to `n`'s allocation, no other object can be at that
// address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut`
// moves it).
let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs);
if let Some((_, ink)) = self.nodes.get(&by_node) {
return Ok(*ink);
}
// Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout.
let [a, b, c, d, _, _] = t.xf.as_coeffs();
let mut text = t.clone();
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
text.cached_bounds = None;
text.cached_baselines.clear();
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
match content.and_then(|key| self.content.get(&key)) {
Some(ink) => {
let ink = *ink;
self.keep_node(n, by_node, ink);
Ok(ink)
}
None => Err(Box::new(Missing { by_node, content })),
}
}
/// Keep the box `ink` measured for type node `n` under the keys `missing`.
fn keep(&mut self, n: &Arc<Node>, missing: Missing, ink: Option<Rect>) {
if let Some(key) = missing.content {
if self.content.len() >= TYPE_INK_KEPT {
self.content.clear();
}
self.content.insert(key, ink);
}
self.keep_node(n, missing.by_node, ink);
}
fn keep_node(&mut self, n: &Arc<Node>, by_node: NodeKey, ink: Option<Rect>) {
if self.nodes.len() >= TYPE_INK_KEPT {
self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0);
if self.nodes.len() >= TYPE_INK_KEPT {
self.nodes.clear();
}
}
self.nodes.insert(by_node, (Arc::downgrade(n), ink));
}
}
/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what
/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds
/// of its inline graphics. `None` when it can't be written whole.
fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> {
use std::hash::Hasher;
/// Feeds what is written to two hashers.
struct Feed([std::collections::hash_map::DefaultHasher; 2]);
impl std::io::Write for Feed {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
for h in &mut self.0 {
h.write(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut feed = Feed(Default::default());
// The second hasher starts from another state.
feed.0[1].write_u8(1);
serde_json::to_writer(&mut feed, text).ok()?;
let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits)));
for h in &mut feed.0 {
h.write_u8(u8::from(text.first_para_continues));
for b in inline_bounds.clone() {
match b {
None => h.write_u8(0),
Some(bits) => {
h.write_u8(1);
bits.into_iter().for_each(|v| h.write_u64(v));
}
}
}
}
let [a, b] = feed.0;
Some([a.finish(), b.finish()])
}
std::thread_local! {
/// The type [`type_ink`] measured on this thread.
static TYPE_INKS: std::cell::RefCell<TypeInks> = std::cell::RefCell::new(TypeInks::default());
}
/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m`
/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its
/// second transform; measured again when `t`, the maps (without a move of `m` when there is no
/// projection) or the registered inputs number change. `None` while nothing is registered.
fn type_ink(n: &Arc<Node>, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option<Option<Rect>> {
let (f, inputs) = TYPE_INK.get()?;
let inputs = inputs();
// Without a projection the type is measured without the move, which is added to the box.
let [a, b, c, d, e, g] = m.as_coeffs();
let (map, offset) = match projection {
None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)),
Some(p) => ((m, Some(p)), Vec2::ZERO),
};
let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) {
Ok(ink) => ink,
Err(missing) => {
// Measured outside the borrow: laying type out may measure other type.
let ink = f(t, map.0, map.1);
TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink));
ink
}
};
Some(ink.map(|r| r + offset))
}
/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which
/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree.
fn find_type(n: &Node, typed: &mut std::collections::HashSet<usize>) -> bool {
let holds = match &n.kind {
NodeKind::Text(_) => true,
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds),
_ => false,
};
if holds {
typed.insert(n as *const Node as usize);
}
holds
}
/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds,
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
fn content_box(n: &Arc<Node>, stroked: bool, to: Affine) -> Option<Rect> {
let mut typed = std::collections::HashSet::new();
find_type(n, &mut typed);
content_box_in(n, stroked, to, &typed)
}
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
fn content_box_in(n: &Arc<Node>, stroked: bool, to: Affine, typed: &std::collections::HashSet<usize>) -> Option<Rect> {
// `n` in the axes of `to`.
let moved = || {
if to == Affine::IDENTITY {
return std::borrow::Cow::Borrowed(&**n);
}
let mut m = (**n).clone();
m.transform(to, true);
std::borrow::Cow::Owned(m)
};
let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() };
if !typed.contains(&(Arc::as_ptr(n) as usize)) {
return own(&moved());
}
match &n.kind {
NodeKind::Text(t) => {
// Type in perspective: its outlines as its projection draws them.
let ink = match n.projection() {
None => type_ink(n, t, to * t.xf, None),
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
}
.flatten();
let m = moved();
let Some(ink) = ink else { return own(&m) };
if !stroked {
return Some(ink);
}
let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) };
Some(Rect::new(
ink.x0 - (g.x0 - v.x0).max(0.0),
ink.y0 - (g.y0 - v.y0).max(0.0),
ink.x1 + (v.x1 - g.x1).max(0.0),
ink.y1 + (v.y1 - g.y1).max(0.0),
))
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
let clip = children.first()?;
content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
}
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let members = n.editing_children().iter();
if stroked {
// The container's own strokes, scaled by `to` as `Node::transform` scales them,
// without copying its members.
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
own_strokes.appearance = n.appearance.clone();
own_strokes.transform(to, true);
own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
} else {
members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
}
}
_ => own(&moved()),
}
}
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
/// content with no width or no height, or for margins an `f32` can't hold.
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
if plain.width() < 1e-6 || plain.height() < 1e-6 {
return None;
}
let margin = [
(plain.y0 - stroked.y0) / plain.height(),
(stroked.y1 - plain.y1) / plain.height(),
(plain.x0 - stroked.x0) / plain.width(),
(stroked.x1 - plain.x1) / plain.width(),
]
.map(|m| m as f32);
margin.iter().all(|m| m.is_finite()).then_some(margin)
}
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
@@ -218,27 +540,21 @@ pub struct EnvelopeMap<'a> {
}
impl<'a> EnvelopeMap<'a> {
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
/// has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
/// The map of an envelope of `kind` around `content` with `frame` and `options`, from the
/// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]).
/// `None` when the content has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let src = if frame == Affine::IDENTITY {
nodes_bounds(content)?
} else {
content.iter().try_fold(None, |acc, c| {
let mut m = (**c).clone();
m.transform(to_frame, false);
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
})??
};
let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
let src = options.source_box(src);
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
/// The map of envelope node `n` (`None` for other nodes).
pub fn of(n: &'a Node) -> Option<Self> {
match &n.kind {
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
NodeKind::Envelope { content, kind, frame, options, .. } => Self::new(content, kind, **frame, *options),
_ => None,
}
}
@@ -302,7 +618,7 @@ pub(super) fn expand_envelope(
frame: Affine,
hooks: Hooks,
) -> Vec<Node> {
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
let Some(map) = EnvelopeMap::new(content, kind, frame, options) else { return content.iter().map(|c| (**c).clone()).collect() };
let src = map.src();
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
let f = |p: Point| map.map(p);
@@ -319,7 +635,7 @@ pub(super) fn expand_envelope(
}
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Rect> {
match kind {
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
EnvelopeKind::Mesh { points, .. } => {
@@ -327,6 +643,115 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
}
EnvelopeKind::TopObject { path } => path.bounds(),
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CharStyle, TextObject};
/// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there.
fn look_up(kept: &mut TypeInks, n: &Arc<Node>, linear: Affine, measured: &mut usize) -> Option<Rect> {
let NodeKind::Text(t) = &n.kind else { panic!("type") };
match kept.find(n, t, &(linear, None), 0) {
Ok(ink) => ink,
Err(missing) => {
*measured += 1;
let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0));
kept.keep(n, *missing, ink);
ink
}
}
}
fn type_node(i: usize, text: &str) -> Arc<Node> {
let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default());
Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t))))
}
/// Many type objects are each measured once however often they are looked up; a moved or copied
/// object is found by its content, and a changed one or another transform is measured again.
#[test]
fn measured_type_is_kept_for_many_objects() {
let mut kept = TypeInks::default();
let mut measured = 0;
let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect();
for _ in 0..5 {
for n in &nodes {
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
}
}
assert_eq!(measured, 200);
let mut moved = (*nodes[7]).clone();
moved.transform(Affine::translate((300.0, 40.0)), true);
let moved = Arc::new(moved);
assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured));
assert_eq!(measured, 200, "a moved object is found by its content");
let changed = type_node(7, "Changed");
look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured);
assert_eq!(measured, 201, "a changed object is measured again");
look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured);
assert_eq!(measured, 202, "another transform is measured again");
}
/// Type that differs only in what layout reads but files don't save (a first paragraph that
/// continues a thread, the resolved bounds of an inline graphic) is measured again.
#[test]
fn type_that_differs_in_what_is_not_saved_is_measured_again() {
let mut kept = TypeInks::default();
let mut measured = 0;
let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default());
t.runs.push(crate::TextRun::new("", CharStyle::default()));
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 1, "a copy is found by its content");
t.first_para_continues = true;
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 2, "a first paragraph that continues a thread");
if let Some(inline) = &mut t.runs[1].inline {
inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0));
}
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 3, "an inline graphic's bounds");
}
/// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled
/// with the frame, as the group moved into the frame does; deeply nested groups are measured in
/// one walk each.
#[test]
fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() {
let text = type_node(1, "Group");
let mut group = Node::group(NodeId(2), vec![text.clone()]);
group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into();
let group = Arc::new(group);
let to = Affine::scale(2.0);
let mut moved = (*group).clone();
moved.transform(to, true);
let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
assert!(want.is_some());
assert_eq!(content_box(&group, true, to), want);
let mut nested = group;
for i in 0..300 {
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
}
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
}
/// A table that fills up is emptied instead of growing, and what is looked up after that is
/// measured again.
#[test]
fn the_tables_stay_bounded() {
let mut kept = TypeInks::default();
let mut measured = 0;
let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect();
for n in &nodes {
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
}
assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len());
assert_eq!(measured, TYPE_INK_KEPT + 10);
}
}
+33 -12
View File
@@ -98,7 +98,7 @@ impl Document {
vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::<usize>()
});
n += shared(&self.graphic_styles, &newer.graphic_styles, |g| {
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::<usize>()
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap_bytes(&g.appearance)).sum::<usize>()
});
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
n += shared(&self.char_styles, &newer.char_styles, styles);
@@ -245,7 +245,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
let mut b = ALLOC + ARC + size_of::<Node>();
b += n.name.as_ref().map_or(0, String::capacity);
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
b += ALLOC + ARC + appearance_heap(&n.appearance);
b += ALLOC + ARC + appearance_heap_bytes(&n.appearance);
}
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
@@ -293,32 +293,53 @@ fn text_bytes(t: &TextObject) -> usize {
+ vec_bytes(&t.cached_baselines)
}
/// The heap an appearance owns (not the appearance itself).
fn appearance_heap(a: &Appearance) -> usize {
/// Approximate retained memory of an appearance and its owned buffers, without serialization.
pub fn appearance_heap_bytes(a: &Appearance) -> usize {
size_of::<Appearance>()
+ vec_bytes(&a.items)
+ a.items
.iter()
.map(|i| match i {
AppearanceItem::Fill(f) => paint_heap(&f.paint),
AppearanceItem::Stroke(s) => paint_heap(&s.paint),
.map(|item| {
let paint = paint_heap_bytes(item.paint());
let effects = effects_heap(item.effects());
let stroke = match item {
AppearanceItem::Stroke(s) => {
s.dash.as_ref().map_or(0, |d| vec_bytes(&d.pattern))
+ s.profile.as_ref().map_or(0, |p| vec_bytes(&p.points))
+ s.pressure.as_ref().map_or(0, |p| vec_bytes(&p.points))
+ s.brush.as_ref().map_or(0, String::capacity)
}
_ => 0,
};
paint + effects + stroke
})
.sum::<usize>()
+ vec_bytes(&a.effects)
+ a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
+ effects_heap(&a.effects)
}
fn paint_heap(p: &Paint) -> usize {
fn effects_heap(effects: &Vec<Effect>) -> usize {
vec_bytes(effects) + effects.iter().map(|e| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
}
/// Approximate heap retained by one paint, without serializing it.
pub fn paint_heap_bytes(p: &Paint) -> usize {
match p {
Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity),
Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops),
Paint::Gradient(g) => {
ALLOC
+ size_of_val(&**g)
+ vec_bytes(&g.gradient.stops)
+ g.gradient.stops.iter().map(|s| s.swatch.as_ref().map_or(0, String::capacity)).sum::<usize>()
+ g.swatch.as_ref().map_or(0, String::capacity)
+ g.freeform.as_ref().map_or(0, |f| vec_bytes(&f.points) + vec_bytes(&f.lines) + f.lines.iter().map(vec_bytes).sum::<usize>())
}
Paint::Pattern { pattern, .. } => pattern.capacity(),
Paint::None => 0,
}
}
fn swatches_bytes(s: &[Swatch]) -> usize {
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::<usize>()
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap_bytes(&w.paint)).sum::<usize>()
}
/// A vector's buffer (not what its elements own).
+4
View File
@@ -27,6 +27,10 @@ pub struct ObjectAttributes {
/// the element's event attributes; nothing in VectorCraft runs them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<(String, String)>,
/// The CSS classes the object had in an imported SVG, written back to `class` on export
/// (beside the generated `cls-N` classes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub classes: Vec<String>,
/// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG
/// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives
/// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's,
+4
View File
@@ -66,6 +66,9 @@ pub struct NodeExtras {
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
pub envelope_edit_margin: Option<[f32; 4]>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -91,6 +94,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
envelope_edit_margin: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+40 -9
View File
@@ -212,7 +212,10 @@ impl Node {
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
/// grown by the farthest a stroke in the art can reach.
/// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice
/// guides, that box also holds the art box placed by each cell's map
/// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a
/// symbol with 9-slice guides at any depth is measured as its art placed by its transform.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
@@ -297,7 +300,7 @@ impl Node {
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Envelope { content, kind, frame, options, .. } => crate::live::envelope_bounds(content, kind, **frame, *options),
NodeKind::Mesh(m) => m.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
@@ -329,6 +332,14 @@ impl Node {
self.visual(Some(symbols))
}
/// The visual box of a group or layer without a clipping path, from the visual boxes of its
/// [`Node::editing_children`]: for a walk that measures the members as well.
pub fn visual_of_members(&self, members: impl Iterator<Item = Option<Rect>>) -> Option<Rect> {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
members.fold(None, vectorcraft_geom::union_opt).map(|b| b.inflate(o, o))
}
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
/// given.
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
@@ -343,11 +354,7 @@ impl Node {
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
}
NodeKind::Layer { .. } | NodeKind::Group { .. } => self.visual_of_members(self.editing_children().iter().map(|c| c.visual(symbols))),
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
let o = crate::live::max_outset(children, symbols);
@@ -367,9 +374,14 @@ impl Node {
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf) {
match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) {
Some(v) => v.union(placed),
None => placed.inflate(art.reach, art.reach),
// Art placed through 9-slice guides can draw outside `placed`.
None => s
.document()
.symbol_slice_map_of(symbol, art.geometric, *xf)
.map_or(placed, |m| m.placement_bounds(art.geometric).union(placed))
.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
@@ -598,6 +610,25 @@ impl Node {
}
}
/// The paint behind the Fill (or Stroke) proxy, the one [`Node::proxy_paint`] gives, without its
/// map and box: the object is not measured, so a long path costs no more than a short one.
pub fn proxy_paint_only(&self, stroke: bool, item: Option<usize>) -> Option<&vectorcraft_color::Paint> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& !stroke
&& let Some(c) = self.extras().colorize.as_deref()
{
return Some(c);
}
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
let st = &t.runs.first()?.style;
return Some(if stroke { &st.stroke } else { &st.fill });
}
if stroke { self.appearance.stroke_at(item).map(|s| &s.paint) } else { self.appearance.fill_at(item).map(|f| &f.paint) }
}
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
+26 -1
View File
@@ -21,6 +21,7 @@ fn extras_are_written_flat_in_order() {
e.live_paint_gaps = Some(Box::default());
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -65,6 +66,7 @@ fn extras_are_written_flat_in_order() {
"live_paint_gaps",
"compound_shape",
"shape_mode",
"envelope_edit_margin",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
@@ -390,10 +392,19 @@ fn a_projection_follows_the_transform_or_is_removed() {
let mut n = instance(far);
n.transform(Affine::scale(1e150), false);
assert_eq!(projection(&n), None);
// A transform that is not invertible leaves the projection as it is.
// A transform that is not invertible leaves the projection as it is, at any scale.
let mut n = instance(far);
n.transform(Affine::scale(0.0), false);
assert_eq!(projection(&n), Some(far.to_array()));
let mut n = instance(far);
n.transform(Affine::new([1e-8, 2e-8, 2e-8, 4e-8, 0.0, 0.0]), false);
assert_eq!(projection(&n), Some(far.to_array()));
// A scale by 1e-8 is invertible: a move by 10 becomes a move by 1e-7.
let ten = Homography::from_affine(Affine::translate((10.0, 0.0)));
let mut n = instance(ten);
n.transform(Affine::scale(1e-8), false);
let q = n.projection().unwrap().apply(Point::new(3e-8, 7e-8)).unwrap();
assert!(q.distance(Point::new(1.3e-7, 7e-8)) < 1e-20, "{q:?}");
}
#[test]
@@ -434,6 +445,20 @@ fn visual_bounds_include_stroke() {
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
}
/// A group's box from its members' boxes grows by the group's own strokes, leaves out members
/// without a box, and is `None` when none has one.
#[test]
fn visual_of_members_unions_the_members_and_grows_by_the_group_stroke() {
let mut g = Node::group(NodeId(1), vec![]);
g.appearance = Appearance::default_art().into();
let (a, b) = (Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(20.0, 5.0, 30.0, 8.0));
let o = g.appearance.outset();
assert!(o > 0.0);
assert_eq!(g.visual_of_members([Some(a), None, Some(b)].into_iter()), Some(Rect::new(0.0, 0.0, 30.0, 10.0).inflate(o, o)));
assert_eq!(g.visual_of_members([None, None].into_iter()), None);
assert_eq!(g.visual_of_members([].into_iter()), None);
}
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
#[test]
+5
View File
@@ -53,6 +53,7 @@ impl Serialize for Node {
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
e.envelope_edit_margin.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -94,6 +95,7 @@ impl Serialize for Node {
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
field!("envelope_edit_margin", &e.envelope_edit_margin);
st.end()
}
}
@@ -232,6 +234,8 @@ pub(super) struct NodeWire {
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
#[serde(default)]
envelope_edit_margin: Option<[f32; 4]>,
}
impl From<NodeWire> for Node {
@@ -258,6 +262,7 @@ impl From<NodeWire> for Node {
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
envelope_edit_margin: w.envelope_edit_margin,
};
Node {
id: w.id,
+12 -2
View File
@@ -125,9 +125,19 @@ impl Document {
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
self.oriented_bounds_with(ids, visual, &self.symbol_boxes())
}
/// [`oriented_bounds`](Document::oriented_bounds) measuring symbol instances from `symbols`,
/// a cache shared across calls: measuring many instances one by one otherwise measures their
/// art again for each.
pub fn oriented_bounds_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of_with(ids, visual, symbols).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let symbols = self.symbol_boxes();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(symbols, to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ impl Node {
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
&& let Some(h) = p.homography()
&& a.determinant().abs() >= 1e-15
&& Homography::affine_is_invertible(a)
{
p.projection = h.conjugated(a).map(|h| h.to_array());
}
+3
View File
@@ -559,6 +559,9 @@ impl Document {
reads += usize::from(read_full && depth > 0);
let full = if read_full { full_bytes(&p, &blob) } else { None };
if full.and_then(|b| read(&p, &b)).is_none() {
// A full read cached for another caller (one allowed to read the file) must not
// stand in for the one refused or not tried here: the preview shows instead.
cache().entries.remove(&(p.key.clone(), false));
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
if let Some(bytes) = preview {
read_preview(&p, &bytes);
+53 -9
View File
@@ -2,7 +2,7 @@
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
//! up to the miter limit, projecting caps and arrowheads.
use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
@@ -16,12 +16,7 @@ const ARCLEN_ACCURACY: f64 = 1e-3;
/// The segments of a subpath that have length (zero-length ones, all of whose points coincide,
/// have no direction).
fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec<PathSeg> {
let moves = |s: &PathSeg| match s {
PathSeg::Line(l) => l.p1 != l.p0,
PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0,
PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0,
};
segments(els).into_iter().filter(moves).collect()
segments(els).into_iter().filter(has_length).collect()
}
/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end.
@@ -166,6 +161,47 @@ impl StrokeLayer {
out.union(bounds.inflate(side_max, side_max))
}
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
/// subpath of its path (`closed`: a closed one), both with length ([`has_length`]), or `None`
/// when the join is not a miter, the path does not turn there or the miter limit bevels it.
pub fn miter_tip(&self, closed: bool, a: &PathSeg, b: &PathSeg) -> Option<Point> {
let side = self.side_reach(closed);
if self.join != LineJoin::Miter || side <= 0.0 {
return None;
}
miter(a.end(), tangent(a, 1.0), tangent(b, 0.0), side, self.miter_limit).map(|[_, tip, _]| tip)
}
/// [`Self::bounds_on`] with the miter spikes' tips taken from `tips`, the box of the tips of
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
let els = bp.elements();
let mut out = tips.map_or(bounds, |t| bounds.union(t));
let mut side_max: f64 = 0.0;
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
if side <= 0.0 {
continue;
}
if self.cap == LineCap::Square && !closed {
let segs = drawn(&els[r]);
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
}
}
}
for (at, _, end) in head_ends(self, bp) {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
}
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
/// outside strokes of closed paths count on their own side only (and within `tol` of the
/// path); a width profile counts at its width where `p` is nearest.
@@ -271,13 +307,21 @@ impl Appearance {
pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect {
let mut path = Some(bp);
let mut shape = None;
self.stroked_bounds_by(bounds, |s| {
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
s.bounds_on(bp, bounds)
})
}
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
/// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`.
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
let o = s.side_reach(false);
return acc.union(bounds.inflate(o, o));
}
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
acc.union(s.bounds_on(bp, bounds))
acc.union(shaped(s))
})
}
+14
View File
@@ -164,6 +164,9 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
// „…“ at either level: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/bg.xml,
// `delimiters`). Shaped as `bg`, fonts draw their Bulgarian forms (`locl` for `BGR`, #5945).
("Bulgarian", "bg", Quotes { double: ['„', '“'], single: ['„', '“'] }),
// „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN
// 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR
// (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`).
@@ -185,6 +188,8 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("Kannada", "kn", Quotes::ENGLISH),
("Malayalam", "ml", Quotes::ENGLISH),
("Odia", "or", Quotes::ENGLISH),
("Assamese", "as", Quotes::ENGLISH),
("Sanskrit", "sa", Quotes::ENGLISH),
("Urdu", "ur", Quotes::ENGLISH),
];
@@ -374,6 +379,8 @@ mod tests {
fn char_languages_are_stored_by_tag() {
assert_eq!(char_language("Turkish"), Ok(Some("tr")));
assert_eq!(char_language("MR"), Ok(Some("mr")));
assert_eq!(char_language("Assamese"), Ok(Some("as")));
assert_eq!(char_language("Sanskrit"), Ok(Some("sa")));
assert_eq!(char_language("english: usa"), Ok(None));
assert_eq!(char_language("en-US"), Ok(None));
assert!(char_language("Klingon").is_err() && char_language("").is_err());
@@ -385,6 +392,10 @@ mod tests {
assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl")));
assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn")));
assert_eq!(char_language("Croatian"), Ok(Some("hr")));
// Bulgarian (#5945), by name or tag.
assert_eq!(char_language("Bulgarian"), Ok(Some("bg")));
assert_eq!(char_language("BG"), Ok(Some("bg")));
assert_eq!(char_language_name(Some("bg")), Some("Bulgarian"));
assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic"));
// Every entry has a tag of its own, and a name of its own.
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
@@ -409,6 +420,9 @@ mod tests {
}
let mut prev = None;
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// Bulgarian: „…“ at either level, as CLDR has it.
let mut prev = None;
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");
// Converting in pieces keeps the context.
let mut prev = None;
let a = Quotes::ENGLISH.apply("\"", &mut prev);
+5
View File
@@ -13,3 +13,8 @@ pub fn is_empty_vec<T>(v: &[T]) -> bool {
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
v.is_empty()
}
/// Read a field's value and drop it, giving `None`: for state that is never taken from a file.
pub fn read_as_none<'de, D: serde::Deserializer<'de>, T>(d: D) -> Result<Option<T>, D::Error> {
<serde::de::IgnoredAny as serde::Deserialize>::deserialize(d)?;
Ok(None)
}
+10 -2
View File
@@ -98,6 +98,10 @@ impl GraphicStyle {
/// The extension (and `format` header) of graphic style library files.
pub const STYLES_EXT: &str = "vcstyles";
/// The version of the `.vcstyles` files [`write`] writes. Version 2: a positive Twist angle turns
/// clockwise ([`crate::twist`]).
const STYLES_VERSION: u32 = crate::twist::TWIST_CLOCKWISE_SINCE;
/// The file: a header around the library.
#[derive(Serialize, Deserialize)]
struct NativeFile {
@@ -109,17 +113,21 @@ struct NativeFile {
/// `lib` as a `.vcstyles` file.
pub fn write(lib: &StyleLibrary) -> String {
let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() };
let file = NativeFile { format: STYLES_EXT.into(), version: STYLES_VERSION, library: lib.clone() };
serde_json::to_string_pretty(&file).unwrap_or_default()
}
/// Read a `.vcstyles` file; an unnamed library is called `name`.
/// Read a `.vcstyles` file; an unnamed library is called `name`. A version 1 file was written by
/// an app that turned Twist counterclockwise: its Twist angles are negated.
pub fn read(text: &str, name: &str) -> Result<StyleLibrary, String> {
let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?;
if f.format != STYLES_EXT {
return Err(format!("not a graphic style library (format `{}`)", f.format));
}
let mut lib = f.library;
if f.version < STYLES_VERSION {
crate::twist::negate_library(&mut lib);
}
if lib.name.trim().is_empty() {
lib.name = name.into();
}
+103
View File
@@ -7,6 +7,7 @@
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
//! change.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use vectorcraft_color::swatch::REGISTRATION;
@@ -85,6 +86,12 @@ impl Document {
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
/// Names for several new swatches: [`FreeSwatchNames::take`] gives what
/// [`Document::free_swatch_name`] gives once the swatches named before it are added.
pub fn free_swatch_names(&self) -> FreeSwatchNames {
let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone()));
FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() }
}
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
/// "New Pattern Swatch 1"…
@@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String {
}
}
/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names
/// handed out since, for naming many new swatches without searching the document for each name.
#[derive(Clone, Debug)]
pub struct FreeSwatchNames {
taken: HashSet<String>,
/// For each base and first number already numbered, the number its next numbered name is
/// looked for from: names are only ever added, so the numbers below it stay taken.
next: HashMap<(String, usize), usize>,
}
impl FreeSwatchNames {
/// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`]
/// gives it; the name is taken from then on.
pub fn take(&mut self, base: &str) -> String {
let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() };
self.taken.insert(name.clone());
name
}
/// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken
/// from then on.
pub fn take_new(&mut self, paint: &Paint) -> String {
let base = match paint {
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))),
Paint::Solid { color, .. } => return self.take(&color_name(*color)),
Paint::Gradient(_) => "New Gradient Swatch",
_ => "New Pattern Swatch",
};
let name = self.numbered(base, 1);
self.taken.insert(name.clone());
name
}
/// Count `name` as taken (a swatch or color group added another way).
pub fn insert(&mut self, name: &str) {
self.taken.insert(name.to_string());
}
/// The first of "base `from`", "base `from + 1`"… not taken.
fn numbered(&mut self, base: &str, from: usize) -> String {
let key = (base.to_string(), from);
let start = self.next.get(&key).copied().unwrap_or(from);
let (i, name) =
(start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string()));
self.next.insert(key, i + 1);
name
}
}
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
@@ -419,6 +472,56 @@ mod tests {
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
}
#[test]
fn free_swatch_names_match_naming_each_swatch_as_it_is_added() {
let (mut d, _, _) = doc();
let mut names = d.free_swatch_names();
for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] {
let want = d.free_swatch_name(base);
assert_eq!(names.take(base), want, "{base}");
d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false });
}
let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect();
assert_eq!(
added,
["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"]
);
}
#[test]
fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() {
let (mut d, _, _) = doc();
let red = Color::rgb(1.0, 0.0, 0.0);
d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false });
let mut names = d.free_swatch_names();
// A name taken another way (a group made alongside) counts for both.
names.insert("R=255 G=0 B=0 3");
d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] });
let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 };
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient];
let mut added = vec![];
for paint in paints {
let want = d.new_swatch_name(&paint);
assert_eq!(names.take_new(&paint), want);
d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false });
added.push(want);
}
assert_eq!(
added,
[
"R=255 G=0 B=0",
"R=255 G=0 B=0 2",
"New Gradient Swatch 1",
"Brand 40%",
"R=255 G=0 B=0 4",
"New Gradient Swatch 3",
"Brand 40% 2",
"New Gradient Swatch 4"
]
);
}
#[test]
fn mapping_copies_only_changed_nodes() {
let (mut d, a, b) = doc();
+20
View File
@@ -343,6 +343,20 @@ impl NineSliceMap {
self.cell_xf(self.x.cell(q.x), self.y.cell(q.y))
}
/// A box that holds everything [`Self::place`] draws of art whose geometric box in symbol space
/// is `art`, before strokes and effects: `art` placed by each of the nine cells' maps. An object
/// that crosses a guide and draws symbol art is placed by the map of the cell its center is in,
/// so it can draw outside the box the corner cells place `art` in.
pub fn placement_bounds(&self, art: Rect) -> Rect {
let mut b = self.cell_xf(0, 0).transform_rect_bbox(art);
for j in 0..3 {
for i in 0..3 {
b = b.union(self.cell_xf(i, j).transform_rect_bbox(art));
}
}
b
}
/// The guides in symbol space: the two x values and the two y values.
fn cuts(&self) -> ([f64; 2], [f64; 2]) {
let inv = self.natural.inverse();
@@ -704,6 +718,12 @@ impl Document {
NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf)
}
/// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already
/// measured.
pub(crate) fn symbol_slice_map_of(&self, name: &str, art: Rect, xf: Affine) -> Option<NineSliceMap> {
NineSliceMap::new(self.symbol_nine_slice(name)?, art, self.symbol_natural_xf(name), xf)
}
/// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's
/// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when
/// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through
+171 -3
View File
@@ -23,6 +23,9 @@ const MAX_DEPTH: usize = 32;
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
const MAX_REACHES: usize = 4096;
/// A symbol's name and the bits of the linear part of a transform that places its art.
type Placing = (String, [u64; 4]);
/// A symbol's art measured in symbol space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArtBox {
@@ -37,6 +40,9 @@ pub(crate) struct ArtBox {
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
/// of a symbol an instance in it shows: `None` when no object there has one.
pub(crate) brush: Option<f64>,
/// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art
/// holds one at any depth.
pub(crate) sliced: bool,
}
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
@@ -73,6 +79,14 @@ pub struct SymbolBoxes<'a> {
unplaced: RefCell<HashMap<String, f64>>,
/// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]).
runs: Cell<usize>,
/// The boxes [`Self::sliced_visual`] has measured, by symbol name and the bits of the linear
/// part of the transform that placed the art.
sliced: RefCell<HashMap<Placing, Option<Rect>>>,
/// How many objects of symbol art [`Self::sliced_visual`] has placed, the art of opacity masks
/// included.
sliced_placed: Cell<u64>,
/// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`].
sliced_most: u64,
}
impl Document {
@@ -90,6 +104,9 @@ impl Document {
farthest: RefCell::default(),
unplaced: RefCell::default(),
runs: Cell::new(0),
sliced: RefCell::default(),
sliced_placed: Cell::new(0),
sliced_most: MAX_SYMBOL_NODES,
}
}
}
@@ -102,7 +119,11 @@ impl<'a> SymbolBoxes<'a> {
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// art box alone. An instance that scales or turns stroked art then measures a little larger
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it.
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The
/// visual box of an instance placed through 9-slice guides holds the art box placed by each
/// cell's map ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an
/// instance of a symbol with 9-slice guides at any depth is measured as its art placed by its
/// transform ([`Self::sliced_visual`]).
pub fn quick(self) -> Self {
self.quick.set(true);
self
@@ -319,13 +340,64 @@ impl<'a> SymbolBoxes<'a> {
if self.quick.get() {
return None;
}
self.place_and_measure(name, xf, most)
}
/// [`Self::placed_visual`] in boxes made by [`Self::quick`] and while symbol art is measured
/// too, for a symbol whose art holds an instance of a symbol with 9-slice guides at any depth:
/// where that instance draws its art depends on the transform of each instance above it. The
/// art is placed once for each symbol and linear part of `xf`, and the box is moved by the rest
/// of `xf`. `None` for other symbols, when [`Self::placed_visual`] is `None` outside quick
/// boxes, and when placing the art would take the objects of the art these boxes have placed
/// for it past [`MAX_SYMBOL_NODES`], the art placed to measure the instances in placed art
/// included. The renderer draws no more objects of symbol art than that in one frame.
pub(crate) fn sliced_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
if !self.art(name)?.sliced {
return None;
}
let [a, b, c, d, e, f] = xf.as_coeffs();
let (linear, moved) = (Affine::new([a, b, c, d, 0.0, 0.0]), Affine::translate((e, f)));
let key = (name.to_string(), [a, b, c, d].map(f64::to_bits));
let cached = self.sliced.borrow().get(&key).copied();
let v = match cached {
Some(v) => v,
None => {
// The most objects placing makes, the art of opacity masks included, is set aside
// while the art is placed and measured, and what placing did not make is given back.
let art = &self.doc.symbols.iter().find(|s| s.name == name)?.art;
let most = match self.doc.symbol_slice_map(name, linear) {
Some(m) => m.most_placed(art, self.doc),
None => art.count_with_masks() as u64,
};
let used = self.sliced_placed.get();
if used.saturating_add(most) > self.sliced_most {
return None;
}
self.sliced_placed.set(used + most);
let (v, placed) = self.place_and_count(name, linear, MAX_SYMBOL_NODES).unwrap_or((None, 0));
self.sliced_placed.set(self.sliced_placed.get().saturating_sub(most.saturating_sub(placed)));
self.sliced.borrow_mut().insert(key, v);
v
}
};
v.map(|v| moved.transform_rect_bbox(v))
}
/// The box [`Self::placed_visual_within`] returns outside quick boxes.
fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> {
self.place_and_count(name, xf, most).and_then(|(v, _)| v)
}
/// [`Self::place_and_measure`], with the number of objects of the placed art, the art of
/// opacity masks included. `None` when the art is not placed.
fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option<Rect>, u64)> {
self.measure_art(name, |art| {
let art = match self.doc.symbol_slice_map(name, xf) {
Some(m) if m.most_placed(art, self.doc) > most => return None,
Some(m) => m.place(art, self.doc),
None => self.doc.place_symbol_art(name, art, xf)?,
};
self.in_art(|| art.visual(Some(self)))
Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64))
})
.flatten()
}
@@ -383,6 +455,7 @@ fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
let mut effects = false;
let mut brush: Option<f64> = None;
let mut sliced = false;
art.walk(&mut |n| {
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
effects |= own.any(|e| e.visible);
@@ -395,9 +468,10 @@ fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
if let Some(w) = symbols.brush_width(symbol) {
brush = Some(brush.map_or(w, |b| b.max(w)));
}
sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced);
}
});
Some(ArtBox { geometric, visual, reach, effects, brush })
Some(ArtBox { geometric, visual, reach, effects, brush, sliced })
}
#[cfg(test)]
@@ -447,6 +521,100 @@ mod tests {
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
}
#[test]
fn quick_boxes_hold_art_that_9_slice_guides_move_past_the_instance() {
// A 100 × 100 frame with guides a quarter in, holding an instance of a bar from x 10 to 70.
// Scaled 2 times across, the bar crosses the left guide and is placed by the middle
// column's map, x → 3x − 50: it draws from x −20 to 160, past the frame's left edge at 0.
let mut d = Document::new(200.0, 200.0);
let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0);
symbol(&mut d, "Bar", vec![bar]);
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
let inner = instance(&mut d, "Bar", Affine::IDENTITY);
symbol(&mut d, "Panel", vec![frame, inner]);
d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default()));
let panel = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
let exact = panel.visual_bounds_with(&d.symbol_boxes()).unwrap();
assert_eq!(exact, Rect::new(-20.0, 0.0, 200.0, 100.0));
let quick = panel.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}");
}
#[test]
fn quick_boxes_hold_art_that_nested_9_slice_guides_move_past_the_instance() {
// The panel of the test above, in a symbol without guides: once at its natural size and
// once scaled 2 times across. Where the bar draws depends on the transform the holder's
// instance adds to the panel's.
let mut d = Document::new(200.0, 200.0);
let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0);
symbol(&mut d, "Bar", vec![bar]);
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
let inner = instance(&mut d, "Bar", Affine::IDENTITY);
symbol(&mut d, "Panel", vec![frame, inner]);
d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default()));
let natural = instance(&mut d, "Panel", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![natural]);
let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
symbol(&mut d, "Wide holder", vec![wide]);
// The holder scaled 2 times across scales the panel 2 times across: the bar draws from x
// −20 to 160, as in the test above.
let holder = instance(&mut d, "Holder", Affine::scale_non_uniform(2.0, 1.0));
let quick = holder.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
let drawn = Rect::new(-20.0, 40.0, 160.0, 60.0);
assert_eq!(quick.union(drawn), quick, "{quick:?} holds {drawn:?}");
let wide_holder = instance(&mut d, "Wide holder", Affine::scale(2.0));
for n in [&holder, &wide_holder] {
let exact = n.visual_bounds_with(&d.symbol_boxes()).unwrap();
assert!(exact.x0 < 0.0, "{exact:?} reaches past the frame's left edge");
let quick = n.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}");
let reach = n.reach_bounds_with(&d.symbol_boxes()).unwrap();
assert_eq!(reach.union(exact), reach, "{reach:?} holds {exact:?}");
}
}
#[test]
fn quick_boxes_place_art_that_holds_9_slice_instances_a_bounded_number_of_times() {
// A frame with guides, and 16 levels of symbols that each hold an instance of the level
// below a little taller and one turned a little: the transforms that place the frame differ
// for each of the 2^16 ways down, so measuring every one would place art 2^17 times.
let mut d = Document::new(200.0, 200.0);
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
symbol(&mut d, "Level 0", vec![frame]);
d.set_symbol_nine_slice("Level 0", Some(crate::SymbolNineSlice::default()));
let (taller, turned) = (Affine::scale_non_uniform(1.0, 1.01), Affine::rotate(0.1));
for i in 1..=16 {
let below = format!("Level {}", i - 1);
let pair = vec![instance(&mut d, &below, taller), instance(&mut d, &below, turned)];
symbol(&mut d, &format!("Level {i}"), pair);
}
let top = instance(&mut d, "Level 16", taller);
// With room for 5,000 objects: each placement places at least one.
let s = SymbolBoxes { sliced_most: 5000, ..d.symbol_boxes().quick() };
assert!(top.visual_bounds_with(&s).is_some());
assert!(s.sliced_placed.get() <= 5000, "{} objects", s.sliced_placed.get());
assert!(s.art_measured() <= 5000 + d.symbols.len(), "{} measures", s.art_measured());
// Instances whose transforms differ only by a move place the art once.
let level = instance(&mut d, "Level 1", taller);
let moved = instance(&mut d, "Level 1", Affine::translate((5.0, 7.0)) * taller);
let s = d.symbol_boxes().quick();
let b = level.visual_bounds_with(&s).unwrap();
let measured = s.art_measured();
assert_eq!(moved.visual_bounds_with(&s), Some(Affine::translate((5.0, 7.0)).transform_rect_bbox(b)));
assert_eq!(s.art_measured(), measured);
// The art of an opacity mask counts toward the limit: a group holding the framed
// instance, masked by a group of 10 rectangles, is 13 objects.
let mut framed = instance(&mut d, "Level 0", Affine::IDENTITY);
let squares = (0..10).map(|_| Arc::new(rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0))).collect();
let mask = Node::group(d.alloc_id(), squares);
framed.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(mask, true)));
symbol(&mut d, "Masked", vec![framed]);
let masked = instance(&mut d, "Masked", taller);
let s = d.symbol_boxes().quick();
assert!(masked.visual_bounds_with(&s).is_some());
assert_eq!(s.sliced_placed.get(), 13);
}
#[test]
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
let mut d = Document::new(200.0, 200.0);
+61
View File
@@ -451,3 +451,64 @@ fn pica_form_rejects_exponent_notation() {
assert_eq!(Unit::Points.parse(s), None, "{s}");
}
}
/// Symbol editing of symbol "S" on `layer`.
fn symbol_edit_on(layer: NodeId) -> SymbolEdit {
SymbolEdit {
symbol: "S".into(),
layer,
instance: None,
xf: Affine::IDENTITY,
group: false,
changed: false,
back_isolation: None,
back_layer: None,
}
}
/// The opacity-mask and symbol editing layers are left out of the saved document wherever they
/// are, also when `layer.move` made them sublayers of another layer.
#[test]
fn edit_mode_layers_are_dropped_as_sublayers_too() {
let mut d = Document::new(612.0, 792.0);
let host = d.layers[0].id;
let mask = d.add_layer(Some("Mask"));
let symbol = d.add_layer(Some("Symbol"));
d.move_node(mask, Some(host), 0).unwrap();
d.move_node(symbol, Some(host), usize::MAX).unwrap();
let object = d.alloc_id();
d.mask_edit = Some(MaskEdit { object, layer: mask });
d.symbol_edit = Some(symbol_edit_on(symbol));
let saved = d.without_edit_modes();
assert!(saved.node(mask).is_none(), "the mask editing layer is left out");
assert!(saved.node(symbol).is_none(), "the symbol editing layer is left out");
assert!(saved.node(host).is_some_and(|l| l.children().is_some_and(|c| c.is_empty())));
assert!(saved.mask_edit.is_none() && saved.symbol_edit.is_none());
}
/// Symbol editing is never read from a file, so a layer that file data names as the editing layer
/// loads and stays in the document, and saving does not write the value again.
#[test]
fn symbol_edit_is_not_read_from_a_file() {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
let mut v = serde_json::to_value(&d).unwrap();
v["symbol_edit"] = serde_json::to_value(symbol_edit_on(layer)).unwrap();
d = serde_json::from_value(v).unwrap();
assert_eq!(d.symbol_edit, None);
assert!(d.extra.is_empty(), "not kept as an unknown key");
d.drop_edit_modes();
assert!(d.node(layer).is_some(), "the layer is kept");
assert!(serde_json::to_value(&d).unwrap().get("symbol_edit").is_none(), "not saved again");
}
/// An opacity-mask editing id in file data that names art inside a layer, not a layer, leaves the
/// art in place.
#[test]
fn mask_edit_naming_art_leaves_it() {
let (mut d, a, b) = doc_with_rects();
d.mask_edit = Some(MaskEdit { object: b, layer: a });
d.drop_edit_modes();
assert!(d.node(a).is_some() && d.node(b).is_some());
assert_eq!(d.mask_edit, None);
}
+1 -1
View File
@@ -16,7 +16,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics};
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
+33 -2
View File
@@ -93,6 +93,10 @@ pub struct CharStyle {
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Character panel menu › Kashidas: whether these characters may receive a kashida when
/// their paragraph is justified with Insert Kashidas. On by default, including old documents.
#[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")]
pub kashidas: bool,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
@@ -139,10 +143,35 @@ pub struct CharStyle {
pub no_break: bool,
/// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these
/// characters are shaped in, so that the font uses that language's forms (OpenType `locl`).
/// `None`: the default language, English: USA, shaped without a language. New type takes the
/// document's language (Document Setup). Saved only when set.
/// `None`: the default language, English: USA, shaped without a language, which is what text
/// saved without one reads as. A tag the list doesn't have, from a file, is kept and has no
/// hyphenation. New type takes the document's language (Document Setup), else the default
/// language of Preferences › Hyphenation. Saved only when set. Each run hyphenates in its own
/// language (`vectorcraft_text::hyphen`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates
/// for Current Selection). The text keeps the character; layout draws the chosen glyph while
/// the run starts with that character and is set in the face the choice names, and the
/// default glyph otherwise. Saved only when set.
#[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")]
pub glyph_alternate: Option<GlyphChoice>,
}
/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it
/// belongs to, named by its PostScript name and version string, so that a glyph id is never used
/// in another font or another version of the font.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphChoice {
/// The character the glyph is drawn for.
pub ch: char,
/// The glyph id in the face.
pub gid: u32,
/// The face's PostScript name (`SourceSans3-Regular`).
pub font: String,
/// The face's version string (`Version 3.052`); empty when it has none.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub version: String,
}
/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square
@@ -405,6 +434,7 @@ impl Default for CharStyle {
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
char_direction: None,
kashidas: true,
proportional_metrics: false,
tate_chu_yoko: false,
tcy_up_down: 0.0,
@@ -415,6 +445,7 @@ impl Default for CharStyle {
font_height_reference: FontHeightReference::EmBox,
no_break: false,
language: None,
glyph_alternate: None,
}
}
}
+4 -2
View File
@@ -200,14 +200,16 @@ impl TextObject {
}
/// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte
/// where their paragraph starts: when the plain text from the start of the paragraph up to
/// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise.
/// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise,
/// and for a first paragraph that continues from the frame before
/// ([`TextObject::first_para_continues`]).
pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> {
let text = self.plain_text();
let before = text.get(..at)?;
let start = before.rfind('\n').map_or(0, |i| i + 1);
let list = ParaList::typed(before[start..].strip_suffix(' ')?)?;
let index = before.bytes().filter(|&b| b == b'\n').count();
self.para_at(index).list.is_none().then_some((start, list))
(!(index == 0 && self.first_para_continues) && self.para_at(index).list.is_none()).then_some((start, list))
}
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
+2 -1
View File
@@ -228,7 +228,8 @@ pub struct ParaStyle {
pub leading_model: LeadingModel,
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
/// it and imported text keep [`Burasagari::None`].
/// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding
/// punctuation on, which opens with [`Burasagari::Forced`].
pub burasagari: Burasagari,
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
+13
View File
@@ -239,6 +239,19 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
assert_eq!(t.typed_list_at(19), Some((16, letters)));
}
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
/// The paragraphs after it still start lists.
#[test]
fn typed_list_starts_skip_a_continued_paragraph() {
let dot = ParaList::bulleted(Bullet::Dot);
let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default());
assert_eq!(t.typed_list_at(2), Some((0, dot)));
t.first_para_continues = true;
assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list");
assert_eq!(t.typed_list_at(10), Some((8, dot)));
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+231
View File
@@ -0,0 +1,231 @@
//! The Twist effect's angle in saved documents. A positive angle turns clockwise on the page in
//! VectorCraft 0.21.0 and later; earlier releases turned it counterclockwise. Documents of version
//! 2 ([`TWIST_CLOCKWISE_SINCE`]) turn clockwise. Version 1 documents written before 0.21.0 have
//! their Twist angles negated when they open, so they draw as they were saved
//! ([`Document::migrate_twist_angles`], [`written_clockwise`]), and a save for an earlier app
//! negates them back ([`Document::store_legacy_twist_angles`]). Graphic style library files
//! (`.vcstyles`) of version 1 have their Twist angles negated when they are read
//! ([`crate::style_libs::read`]); [`crate::style_libs::write`] writes version 2.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::style_libs::StyleLibrary;
use crate::{Appearance, Document, Effect, Node};
/// The Twist effect's id.
pub const TWIST: &str = "distort.twist";
/// The first document version whose Twist angles turn clockwise for a positive value.
pub const TWIST_CLOCKWISE_SINCE: u32 = 2;
/// The first release (major, minor) whose Twist turned clockwise for a positive angle.
const CLOCKWISE_RELEASE: (u64, u64) = (0, 21);
/// Did the app named by a native file's `generator` (`"VectorCraft 0.21.0"`) turn Twist effects
/// clockwise for a positive angle? It did from 0.21.0. Any other value, or none, is an earlier
/// app (DrawCraft included).
pub fn written_clockwise(generator: Option<&str>) -> bool {
let Some(version) = generator.and_then(|g| g.strip_prefix("VectorCraft ")) else { return false };
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
match (parts.next().flatten(), parts.next().flatten()) {
(Some(major), Some(minor)) => (major, minor) >= CLOCKWISE_RELEASE,
_ => false,
}
}
/// The angle a Twist effect without a readable one draws with (`vectorcraft-effects` reads a
/// missing or unreadable `angle` as this).
const DEFAULT_ANGLE: f64 = 10.0;
/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s
/// `DOC_KEY`), whose art can carry effects.
const BRUSHES_KEY: &str = "brushes";
/// The angle a Twist effect with `params` draws with, read as `vectorcraft-effects` reads it: a
/// number, the number a string starts with, or a boolean as 1 or 0; anything else (or a value
/// that is not finite) is [`DEFAULT_ANGLE`].
fn angle(params: &Value) -> f64 {
let v = match params.get("angle") {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => {
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
t.parse().ok()
}
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
};
v.filter(|v| v.is_finite()).unwrap_or(DEFAULT_ANGLE)
}
/// Write the negated angle a Twist effect with `params` draws with into `params`, as a number.
fn negate_params(params: &mut Value) {
// `0.0 - a` rather than `-a`: an angle of 0 stays 0, not -0.
let turned = Value::from(0.0 - angle(params));
match params {
Value::Object(m) => {
m.insert("angle".into(), turned);
}
p => *p = json!({ "angle": turned }),
}
}
fn has_twist(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.id == TWIST)
}
fn appearance_has_twist(a: &Appearance) -> bool {
has_twist(&a.effects) || a.items.iter().any(|i| has_twist(i.effects()))
}
fn negate_appearance(a: &mut Appearance) {
for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) {
if e.id == TWIST {
negate_params(&mut e.params);
}
}
}
/// The appearances a Shaper Group's face and edge edits paint with.
fn shaper_appearances(n: &Node) -> impl Iterator<Item = &Appearance> {
let s = n.extras().shaper.as_deref();
let faces = s.into_iter().flat_map(|s| s.faces.iter().filter_map(|f| f.paint.as_ref()));
let edges = s.into_iter().flat_map(|s| s.edges.iter().filter_map(|e| e.paint.as_ref()));
faces.chain(edges)
}
/// Does `n`'s subtree (opacity-mask art and Shaper edits included) hold a Twist effect?
fn node_has_twist(n: &Node) -> bool {
appearance_has_twist(&n.appearance)
|| n.extras().mask.as_ref().is_some_and(|m| node_has_twist(&m.art))
|| shaper_appearances(n).any(appearance_has_twist)
|| n.children().is_some_and(|c| c.iter().any(|c| node_has_twist(c)))
}
/// Negate the Twist angles of `a`'s subtree, copying only the nodes on the way to them.
fn negate_node(a: &mut Arc<Node>) {
if !node_has_twist(a) {
return;
}
let n = Arc::make_mut(a);
if appearance_has_twist(&n.appearance) {
negate_appearance(&mut n.appearance);
}
if let Some(x) = n.extras_if_any() {
if let Some(m) = x.mask.as_mut() {
negate_node(&mut m.art);
}
if let Some(s) = x.shaper.as_mut() {
let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut());
for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) {
negate_appearance(p);
}
}
}
for c in n.children_mut().into_iter().flatten() {
negate_node(c);
}
}
fn is_twist_json(m: &Map<String, Value>) -> bool {
m.get("id").and_then(Value::as_str) == Some(TWIST)
}
/// Does JSON `v` (brush definitions) hold a Twist effect?
fn json_has_twist(v: &Value) -> bool {
match v {
Value::Object(m) => is_twist_json(m) || m.values().any(json_has_twist),
Value::Array(a) => a.iter().any(json_has_twist),
_ => false,
}
}
/// Negate the Twist angles in JSON `v`: every object whose `id` is [`TWIST`] is a serialized
/// [`Effect`] (object ids are numbers).
fn negate_json(v: &mut Value) {
match v {
Value::Object(m) if is_twist_json(m) => negate_params(m.entry("params").or_insert(Value::Null)),
Value::Object(m) => m.values_mut().for_each(negate_json),
Value::Array(a) => a.iter_mut().for_each(negate_json),
_ => {}
}
}
/// Negate every Twist angle of a graphic style library: its styles' and its patterns' art.
pub(crate) fn negate_library(lib: &mut StyleLibrary) {
for g in &mut lib.styles {
negate_appearance(&mut g.appearance);
}
for p in &mut lib.patterns {
p.art.iter_mut().for_each(negate_node);
}
}
impl Document {
/// A document before version [`TWIST_CLOCKWISE_SINCE`] that holds a Twist effect: when
/// `written_clockwise` (asked only then; see [`written_clockwise`]) says the app that saved it
/// turned Twist counterclockwise, negate its Twist angles, so it draws as it was saved. A later
/// version is left as it is, and the document's version is not changed. It recurses through
/// the art, once per level of nesting.
pub fn migrate_twist_angles(&mut self, written_clockwise: impl FnOnce() -> bool) {
if self.version >= TWIST_CLOCKWISE_SINCE || !self.has_twist() || written_clockwise() {
return;
}
self.negate_twist_angles();
}
/// The reverse of [`Document::migrate_twist_angles`], for saving for an app that turned Twist
/// counterclockwise: negate the Twist angles and mark the document version 1. Call it on a
/// document whose angles turn clockwise, as every opened or new document's do, whatever
/// version another conversion for that save has marked it already.
pub fn store_legacy_twist_angles(&mut self) {
self.version = TWIST_CLOCKWISE_SINCE - 1;
if self.has_twist() {
self.negate_twist_angles();
}
}
/// Does any Twist effect sit where [`Document::negate_twist_angles`] negates them?
fn has_twist(&self) -> bool {
let mut roots = self
.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
roots.any(|r| node_has_twist(r))
|| self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance))
|| self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist)
}
/// Negate every Twist angle: in the art of the layers, symbols, graph designs, pattern
/// swatches and the pattern definition that pattern editing's Cancel puts back (opacity-mask
/// art and Shaper edits included), in the graphic styles and in the brush definitions.
fn negate_twist_angles(&mut self) {
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
negate_node(root);
}
if self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) {
for g in self.graphic_styles.iter_mut() {
negate_appearance(&mut g.appearance);
}
}
if self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist)
&& let Some(b) = self.unknown.get_mut(BRUSHES_KEY)
{
negate_json(b);
}
}
}
#[cfg(test)]
mod tests;
+186
View File
@@ -0,0 +1,186 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, shapes};
use super::*;
use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec};
use crate::{GraphicStyle, OpacityMask, PatternDef, Symbol};
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// An appearance with a Twist of `params` on the object and on its fill, and a Roughen.
fn twisted_appearance(params: &Value) -> Appearance {
let mut a = Appearance::default_art();
a.effects.push(fx(TWIST, params.clone()));
a.effects.push(fx("distort.roughen", json!({"size": 5})));
a.items[0].effects_mut().push(fx(TWIST, params.clone()));
a
}
fn twisted(d: &mut Document, params: &Value) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), twisted_appearance(params))
}
/// A version 1 document with Twist effects of `params` everywhere one is stored: on a path, in a
/// group, in an opacity mask's art, in a Shaper edit, in a symbol, a pattern swatch and a graphic
/// style, and `brush` in a brush definition. Its first layer holds the art; its last has no
/// Twist.
fn document(params: &Value, brush: &Value) -> Document {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let path = twisted(&mut d, params);
d.insert(Some(l), 0, path).unwrap();
let inner = twisted(&mut d, params);
let group = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
d.insert(Some(l), 1, group).unwrap();
let mask_art = twisted(&mut d, params);
let mut masked = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art());
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, true)));
d.insert(Some(l), 2, masked).unwrap();
let mut shaper = Node::group(d.alloc_id(), vec![]);
let face = FaceEdit {
selector: FaceSelector { sources: vec![0], anchor: Point::ZERO },
erase: false,
erase_edges: false,
paint_source: None,
merge: None,
paint: Some(twisted_appearance(params)),
};
shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] }));
d.insert(Some(l), 3, shaper).unwrap();
let art = twisted(&mut d, params);
d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art), dynamic: false });
let art = twisted(&mut d, params);
d.patterns.push(PatternDef::new("P", vec![Arc::new(art)]));
d.graphic_styles.push(GraphicStyle::new("G", twisted_appearance(params)));
d.unknown.insert(BRUSHES_KEY.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [brush]}}}]));
d.add_layer(None);
d.version = 1;
d
}
/// The angle each Twist effect of the saved document draws with, in the order the JSON lists them.
fn angles(d: &Document) -> Vec<f64> {
fn walk(v: &Value, out: &mut Vec<f64>) {
match v {
Value::Object(m) => {
if is_twist_json(m) {
out.push(angle(m.get("params").unwrap_or(&Value::Null)));
}
m.values().for_each(|c| walk(c, out));
}
Value::Array(a) => a.iter().for_each(|c| walk(c, out)),
_ => {}
}
}
let mut out = vec![];
walk(&serde_json::to_value(d).unwrap(), &mut out);
out
}
#[test]
fn a_version_1_document_from_before_0_21_has_its_twist_angles_negated() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST}));
let before = angles(&d);
// Two per path, shaper edit and style; one in the brush.
assert_eq!(before.len(), 15, "{before:?}");
assert_eq!(before.iter().filter(|&&a| a == DEFAULT_ANGLE).count(), 1, "{before:?}");
d.migrate_twist_angles(|| false);
assert_eq!(d.version, 1, "the caller sets the version");
assert_eq!(angles(&d), before.iter().map(|a| -a).collect::<Vec<_>>());
let roughen = serde_json::to_string(&d).unwrap().matches(r#""id":"distort.roughen","params":{"size":5}"#).count();
assert_eq!(roughen, 7, "other effects are left alone");
}
#[test]
fn a_document_that_turned_clockwise_is_left_as_it_is() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST, "params": {"angle": 30}}));
let before = d.clone();
d.migrate_twist_angles(|| true);
assert_eq!(d, before, "version 1, written by 0.21.0 or later");
d.version = 2;
let before = d.clone();
d.migrate_twist_angles(|| panic!("a version 2 document's writer is not asked about"));
assert_eq!(d, before, "version 2");
}
#[test]
fn the_writer_is_asked_about_only_when_a_twist_is_stored() {
let mut d = Document::new(10.0, 10.0);
d.version = 1;
let before = d.clone();
d.migrate_twist_angles(|| panic!("a document without a Twist needs no answer"));
assert_eq!(d, before);
}
#[test]
fn angles_are_read_as_the_effect_reads_them() {
let read = |params: Value| {
let mut e = fx(TWIST, params);
negate_params(&mut e.params);
e.params
};
assert_eq!(read(json!({"angle": 30, "x": 1})), json!({"angle": -30.0, "x": 1}));
assert_eq!(read(json!({"angle": "45 deg"})), json!({"angle": -45.0}));
assert_eq!(read(json!({"angle": true})), json!({"angle": -1.0}));
assert_eq!(read(json!({})), json!({"angle": -10.0}));
assert_eq!(read(Value::Null), json!({"angle": -10.0}));
let zero = read(json!({"angle": 0}));
assert!(zero["angle"].as_f64().is_some_and(|a| a == 0.0 && a.is_sign_positive()), "{zero}");
}
#[test]
fn only_the_art_holding_a_twist_is_copied() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST}));
let plain = d.layers.last().cloned().unwrap();
let twisted = d.layers[0].clone();
d.migrate_twist_angles(|| false);
assert!(Arc::ptr_eq(d.layers.last().unwrap(), &plain), "a layer without a Twist is not copied");
assert!(!Arc::ptr_eq(&d.layers[0], &twisted));
}
#[test]
fn storing_for_an_earlier_app_and_reading_back_round_trips() {
let original = document(&json!({"angle": 30.0}), &json!({"id": TWIST, "params": {"angle": -12.5}}));
let mut d = original.clone();
d.migrate_twist_angles(|| false);
d.version = 2;
let current = d.clone();
assert_eq!(angles(&current), angles(&original).iter().map(|a| -a).collect::<Vec<_>>());
d.store_legacy_twist_angles();
assert_eq!(d, original);
d.migrate_twist_angles(|| false);
d.version = 2;
assert_eq!(d, current);
// Another conversion for the same save may have marked the document version 1 already.
d.version = 1;
d.store_legacy_twist_angles();
assert_eq!(d, original);
}
#[test]
fn a_version_1_style_library_has_its_twist_angles_negated() {
let lib = StyleLibrary { name: "L".into(), styles: vec![GraphicStyle::new("G", twisted_appearance(&json!({"angle": 30.0})))], patterns: vec![] };
let text = crate::style_libs::write(&lib);
assert!(text.contains(r#""version": 2"#), "{text}");
assert_eq!(crate::style_libs::read(&text, "x").unwrap(), lib, "a version 2 file reads as written");
let old = crate::style_libs::read(&text.replacen(r#""version": 2"#, r#""version": 1"#, 1), "x").unwrap();
let fx = &old.styles[0].appearance.effects;
assert_eq!(fx[0].params, json!({"angle": -30.0}));
assert_eq!(fx[1], lib.styles[0].appearance.effects[1]);
assert_eq!(old.styles[0].appearance.items[0].effects()[0].params, json!({"angle": -30.0}));
}
#[test]
fn releases_from_0_21_turned_twist_clockwise() {
for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.4", "VectorCraft 0.22.0-dev", "VectorCraft 1.0.0"] {
assert!(written_clockwise(Some(g)), "{g}");
}
for g in ["VectorCraft 0.20.0", "VectorCraft 0.9.12", "DrawCraft 0.1.0", "VectorCraft", "VectorCraft x.y", "vectorcraft 0.21.0"] {
assert!(!written_clockwise(Some(g)), "{g}");
}
assert!(!written_clockwise(None));
}

Some files were not shown because too many files have changed in this diff Show More