mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
Merge upstream/main into fix/compound-path-pen-join
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev
|
||||
@@ -1,25 +1,19 @@
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
branches: [release]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "17 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -13,14 +13,56 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
@@ -5,23 +5,17 @@
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
branches: [release]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -348,20 +349,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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
|
||||
Generated
+8
-9
@@ -876,7 +876,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
@@ -1844,8 +1844,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -1854,7 +1853,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1872,7 +1871,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -1890,13 +1889,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-postscript"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -2296,7 +2294,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -4767,6 +4765,7 @@ dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
|
||||
+13
-5
@@ -11,7 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
@@ -91,6 +91,8 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
|
||||
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
|
||||
hayro-ccitt = "0.3"
|
||||
# Substring search for the EPS reader's PostScript `search`.
|
||||
memchr = "2"
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
@@ -122,18 +124,24 @@ todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies
|
||||
# carry no debug info. Full debug info would make the dev target dir several times larger.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
|
||||
@@ -75,12 +75,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Shared PDF backend
|
||||
|
||||
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
|
||||
are fetched from https://github.com/storytold/craft-libs at revision
|
||||
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
|
||||
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
|
||||
2eca9b3a53a98ac52efed2a27353c6a6927ead10. Their upstream project is
|
||||
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
|
||||
crate's upstream licence files. The patch ledger is at
|
||||
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
|
||||
https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/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.
|
||||
|
||||
+3
-1
@@ -75,7 +75,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 |
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Live vector effects | 85% | 4–8 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
@@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
|
||||
- 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
|
||||
- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
|
||||
- 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged.
|
||||
@@ -197,6 +198,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. |
|
||||
| 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. |
|
||||
| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
|
||||
| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
|
||||
|
||||
@@ -285,7 +285,7 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
let key = format!("photo {i}");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
|
||||
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
|
||||
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
|
||||
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default(), pixels: None };
|
||||
let id = d.alloc_id();
|
||||
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Integration tests: one test binary for the crate, one module per area.
|
||||
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod cli;
|
||||
mod fileio;
|
||||
mod mcp_e2e;
|
||||
@@ -1,6 +1,4 @@
|
||||
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
@@ -143,6 +143,13 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')),
|
||||
_ => path,
|
||||
};
|
||||
// A paste sent by automation reads only what its roots allow.
|
||||
match path.to_str() {
|
||||
Some(p) => vectorcraft_engine::file_access::check_read(p).ok()?,
|
||||
// A lossy name would check another file than the one opened.
|
||||
None if vectorcraft_engine::file_access::current().is_none() => {}
|
||||
None => 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)?;
|
||||
|
||||
@@ -243,12 +243,4 @@ mod tests {
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's label is translated in every language with complete menus.
|
||||
#[test]
|
||||
fn the_label_is_translated() {
|
||||
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
|
||||
//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
|
||||
//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
|
||||
//!
|
||||
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
|
||||
//! each event it is about to dispatch" in this app:
|
||||
//! <https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:)>)
|
||||
//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes
|
||||
//! it on unchanged:
|
||||
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
|
||||
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
|
||||
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
|
||||
//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
|
||||
//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
|
||||
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
|
||||
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
|
||||
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
|
||||
//! (`NSPointingDeviceTypeCursor`) is a mouse.
|
||||
//!
|
||||
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
|
||||
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
|
||||
//!
|
||||
//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
|
||||
//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
|
||||
//! reads them.
|
||||
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// `NSEventType` values (AppKit headers).
|
||||
mod event_type {
|
||||
pub const TABLET_POINT: usize = 23;
|
||||
pub const TABLET_PROXIMITY: usize = 24;
|
||||
/// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each.
|
||||
pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27];
|
||||
}
|
||||
|
||||
/// `NSEventSubtype` values of mouse events.
|
||||
mod subtype {
|
||||
pub const TABLET_POINT: i16 = 1;
|
||||
pub const TABLET_PROXIMITY: i16 = 2;
|
||||
}
|
||||
|
||||
/// `NSPointingDeviceType` values.
|
||||
mod device {
|
||||
pub const CURSOR: usize = 2;
|
||||
pub const ERASER: usize = 3;
|
||||
}
|
||||
|
||||
/// The tablet fields of one `NSEvent`, as plain values.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct RawEvent {
|
||||
/// `type`.
|
||||
kind: usize,
|
||||
/// `subtype` (mouse events only).
|
||||
subtype: i16,
|
||||
/// `pressure` (tablet point events and mouse events of that subtype only).
|
||||
pressure: f32,
|
||||
/// `pointingDeviceType` (proximity events only).
|
||||
device: usize,
|
||||
/// `isEnteringProximity` (proximity events only).
|
||||
entering: bool,
|
||||
}
|
||||
|
||||
impl RawEvent {
|
||||
fn mouse(&self) -> bool {
|
||||
event_type::MOUSE.contains(&self.kind)
|
||||
}
|
||||
|
||||
fn proximity(&self) -> bool {
|
||||
self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY)
|
||||
}
|
||||
|
||||
fn point(&self) -> bool {
|
||||
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
|
||||
fn event_mask() -> u64 {
|
||||
event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t)
|
||||
}
|
||||
|
||||
/// What an event means for the feed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Update {
|
||||
/// Nothing: an event the monitor doesn't map.
|
||||
Keep,
|
||||
/// The pressure and end of a pen, or `None` for a mouse.
|
||||
Set(Option<PenSample>),
|
||||
/// A pen's sample that comes with a pointer move.
|
||||
Move(PenSample),
|
||||
}
|
||||
|
||||
impl Update {
|
||||
fn apply(self, feed: &StylusFeed) {
|
||||
match self {
|
||||
Update::Keep => {}
|
||||
Update::Set(sample) => feed.set_sample(sample),
|
||||
Update::Move(sample) => feed.set_move(sample),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which end of the pen is near the tablet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Pen {
|
||||
eraser: bool,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
fn handle(&mut self, e: &RawEvent) -> Update {
|
||||
if e.proximity() {
|
||||
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
|
||||
self.eraser = e.entering && e.device == device::ERASER;
|
||||
let pen = e.entering && e.device != device::CURSOR;
|
||||
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
|
||||
}
|
||||
let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
|
||||
match (e.point(), e.mouse()) {
|
||||
(true, true) => Update::Move(sample),
|
||||
(true, false) => Update::Set(Some(sample)),
|
||||
(false, true) => Update::Set(None),
|
||||
(false, false) => Update::Keep,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit
|
||||
/// refuses the monitor or this isn't the main thread.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn start() -> Option<StylusFeed> {
|
||||
// The monitor writes on the main thread before winit hands the event on: a press never waits.
|
||||
let feed = StylusFeed::on_ui_thread();
|
||||
match monitor::install(feed.clone()) {
|
||||
Ok(()) => Some(feed),
|
||||
Err(e) => {
|
||||
log::warn!("tablet pressure: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod monitor {
|
||||
use std::cell::Cell;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSEvent, NSEventMask};
|
||||
use vectorcraft_ui_egui::stylus::StylusFeed;
|
||||
|
||||
use super::{Pen, RawEvent, event_mask};
|
||||
|
||||
/// Install the monitor for the life of the app. Main thread only.
|
||||
pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> {
|
||||
MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?;
|
||||
let pen = Cell::new(Pen::default());
|
||||
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
|
||||
// SAFETY: AppKit calls the handler with the event it is about to dispatch, which it
|
||||
// keeps alive while the handler runs.
|
||||
#[allow(unsafe_code)]
|
||||
let e = unsafe { event.as_ref() };
|
||||
let mut p = pen.get();
|
||||
p.handle(&read(e)).apply(&feed);
|
||||
pen.set(p);
|
||||
event.as_ptr()
|
||||
});
|
||||
// SAFETY: the handler returns the event it was given, a valid pointer ("the block's return
|
||||
// must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor
|
||||
// is installed.
|
||||
#[allow(unsafe_code)]
|
||||
let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) };
|
||||
// The monitor stays until the app quits: its token is never handed to `removeMonitor:`.
|
||||
std::mem::forget(token.ok_or("AppKit refused the event monitor")?);
|
||||
NSEvent::setMouseCoalescingEnabled(false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tablet fields of `e`. `subtype` raises an exception for events without one, and the
|
||||
/// pressure and proximity fields are read only for the events that define them.
|
||||
fn read(e: &NSEvent) -> RawEvent {
|
||||
let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() };
|
||||
if raw.mouse() {
|
||||
raw.subtype = e.subtype().0;
|
||||
}
|
||||
if raw.point() {
|
||||
raw.pressure = e.pressure();
|
||||
}
|
||||
if raw.proximity() {
|
||||
raw.device = e.pointingDeviceType().0;
|
||||
raw.entering = e.isEnteringProximity();
|
||||
}
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DRAG: usize = 6;
|
||||
const KEY_DOWN: usize = 10;
|
||||
|
||||
fn pen(kind: usize, pressure: f32) -> RawEvent {
|
||||
RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() }
|
||||
}
|
||||
|
||||
fn proximity(device: usize, entering: bool) -> RawEvent {
|
||||
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
|
||||
}
|
||||
|
||||
fn tip(pressure: f32) -> PenSample {
|
||||
PenSample { pressure, eraser: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_mouse_events_are_moves_with_its_pressure() {
|
||||
let mut p = Pen::default();
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)));
|
||||
assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press");
|
||||
assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_mouse_events_are_a_mouse_and_other_events_are_kept() {
|
||||
let mut p = Pen::default();
|
||||
for subtype in [0, 3, 99, -1] {
|
||||
let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() };
|
||||
assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}");
|
||||
}
|
||||
for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] {
|
||||
// Other events' subtypes don't make them a pen's.
|
||||
let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() };
|
||||
assert_eq!(p.handle(&e), Update::Keep, "type {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_says_which_end_is_near() {
|
||||
let mut p = Pen::default();
|
||||
let eraser = |pressure| PenSample { pressure, eraser: true };
|
||||
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
|
||||
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
|
||||
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
|
||||
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
|
||||
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
|
||||
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
|
||||
assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feed_gets_each_moves_pressure_within_0_to_1() {
|
||||
let feed = StylusFeed::default();
|
||||
let mut p = Pen::default();
|
||||
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
|
||||
p.handle(&e).apply(&feed);
|
||||
}
|
||||
assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
|
||||
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
|
||||
assert_eq!(feed.held(), None, "a mouse moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mask_covers_the_mapped_events() {
|
||||
let m = event_mask();
|
||||
for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] {
|
||||
assert!(m & (1 << t) != 0, "{t}");
|
||||
}
|
||||
assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read");
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod mac_menu;
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_screen_color;
|
||||
mod mac_tablet;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -157,7 +158,11 @@ impl App {
|
||||
// again on its document when it answers.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.app.file_dialog_open() {
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
let files = open_documents::take();
|
||||
if !files.is_empty() {
|
||||
bring_window_forward(ctx);
|
||||
}
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if self.pending_maximize {
|
||||
@@ -191,6 +196,14 @@ fn end_before_teardown(frames: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
|
||||
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn bring_window_forward(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported to the user and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
@@ -568,6 +581,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
no_cursor_theme: wayland && cursor_theme::theme_missing(),
|
||||
#[cfg(target_os = "linux")]
|
||||
stylus: if x11 { tablet::start() } else { None },
|
||||
// macOS reads it from AppKit's events (#852).
|
||||
#[cfg(target_os = "macos")]
|
||||
stylus: mac_tablet::start(),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
|
||||
@@ -1012,11 +1028,13 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
|
||||
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
// User Defined swatch, graphic style and symbol libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string()));
|
||||
app.session.symbol_libraries.set_user_dir(symbols);
|
||||
// With the User Defined libraries listed, the persistent library panels reopen.
|
||||
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
@@ -1126,6 +1144,17 @@ mod tests {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx()));
|
||||
// Nothing paints in the test: drop the font atlas upload egui expects to be applied.
|
||||
out.textures_delta.clear();
|
||||
let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands;
|
||||
assert!(cmds.contains(&egui::ViewportCommand::Minimized(false)));
|
||||
assert!(cmds.contains(&egui::ViewportCommand::Focus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
|
||||
//! the last pressure of each pen is kept.
|
||||
//!
|
||||
//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
|
||||
//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
|
||||
//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into
|
||||
//! the feed marked as the eraser end, and the canvas switches to the Eraser tool
|
||||
//! ([`vectorcraft_ui_egui::stylus`]).
|
||||
//!
|
||||
//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
|
||||
//! that receives none of the window's input.
|
||||
//!
|
||||
@@ -22,7 +28,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::stylus::StylusFeed;
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
|
||||
/// than 16).
|
||||
@@ -62,12 +68,14 @@ struct Device {
|
||||
id: u16,
|
||||
/// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
|
||||
pressure: Option<Axis>,
|
||||
/// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
|
||||
eraser: bool,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// From what the X server lists for the device: its XInput type (`ListInputDevices`), and each
|
||||
/// valuator's number, label, minimum and maximum (`XIQueryDevice`).
|
||||
fn new(id: u16, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
|
||||
/// From what the X server lists for the device: its name and each valuator's number, label,
|
||||
/// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
|
||||
fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
|
||||
let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
|
||||
let pressure = valuators
|
||||
.into_iter()
|
||||
@@ -75,7 +83,8 @@ impl Device {
|
||||
.find(|(_, label, ..)| label == "Abs Pressure")
|
||||
.map(|(number, _, min, max)| Axis { number, min, max })
|
||||
.filter(|_| !finger);
|
||||
Self { id, pressure }
|
||||
let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
|
||||
Self { id, pressure, eraser }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +93,8 @@ impl Device {
|
||||
enum Update {
|
||||
/// Nothing: a device the last listing didn't have (a master, or one added since).
|
||||
Keep,
|
||||
/// The pressure of a pen, or `None` for another pointer.
|
||||
Set(Option<f32>),
|
||||
/// The pressure and end of a pen, or `None` for another pointer.
|
||||
Set(Option<PenSample>),
|
||||
}
|
||||
|
||||
/// The pointer devices and the last pressure of each pen.
|
||||
@@ -106,20 +115,21 @@ impl Pens {
|
||||
fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {
|
||||
let Some(device) = self.devices.get(&id) else { return Update::Keep };
|
||||
let Some(axis) = device.pressure else { return Update::Set(None) };
|
||||
let eraser = device.eraser;
|
||||
let last = self.last.entry(id).or_insert(0.0);
|
||||
for (number, v) in valuators {
|
||||
if number == axis.number {
|
||||
*last = axis.pressure(v);
|
||||
}
|
||||
}
|
||||
Update::Set(Some(*last))
|
||||
Update::Set(Some(PenSample { pressure: *last, eraser }))
|
||||
}
|
||||
|
||||
/// A raw event as the X server sends it: its valuator mask and the values of the valuators
|
||||
/// set in it. A pen's pressure or another pointer's `None` goes into `feed`.
|
||||
/// set in it. A pen's sample or another pointer's `None` goes into `feed`.
|
||||
fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) {
|
||||
if let Update::Set(pressure) = self.handle(id, valuators(mask, values)) {
|
||||
feed.set(pressure);
|
||||
if let Update::Set(sample) = self.handle(id, valuators(mask, values)) {
|
||||
feed.set_sample(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +224,7 @@ mod x11 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slave pointer devices with their pressure valuators (none on any error).
|
||||
/// The slave pointer devices with their names and pressure valuators (none on any error).
|
||||
fn query_devices(conn: &RustConnection) -> Vec<Device> {
|
||||
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
|
||||
let kinds = device_kinds(conn);
|
||||
@@ -230,7 +240,8 @@ mod x11 {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
out.push(Device::new(info.deviceid, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::<Vec<_>>()));
|
||||
let name = String::from_utf8_lossy(&info.name);
|
||||
out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::<Vec<_>>()));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -266,11 +277,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn pen(id: u16) -> Device {
|
||||
Device::new(id, Some("STYLUS"), pen_valuators())
|
||||
Device::new(id, "Wacom Intuos Pro M Pen stylus", Some("STYLUS"), pen_valuators())
|
||||
}
|
||||
|
||||
fn mouse(id: u16) -> Device {
|
||||
Device::new(id, Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)])
|
||||
Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -286,20 +297,70 @@ mod tests {
|
||||
fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
|
||||
assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
|
||||
assert_eq!(mouse(12).pressure, None);
|
||||
let pad = Device::new(13, Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
|
||||
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
|
||||
assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
|
||||
let far = Device::new(14, Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
|
||||
let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
|
||||
assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
|
||||
// The types libinput and XWayland give pens, and a device whose type wasn't listed.
|
||||
for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
|
||||
assert!(Device::new(15, kind, pen_valuators()).pressure.is_some(), "{kind:?}");
|
||||
assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
|
||||
}
|
||||
// xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
|
||||
for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
|
||||
assert_eq!(Device::new(16, Some(kind), pen_valuators()).pressure, None, "{kind}");
|
||||
assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The eraser end of a pen is a device of its own, whose name or XInput type contains
|
||||
/// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it.
|
||||
#[test]
|
||||
fn an_eraser_device_is_named_so() {
|
||||
let erasers = [
|
||||
("Wacom Intuos Pro M Pen eraser", Some("ERASER")),
|
||||
("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")),
|
||||
("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")),
|
||||
("Wacom Intuos Pro M Pen eraser", None),
|
||||
("Tablet Pen (0x2)", Some("ERASER")),
|
||||
];
|
||||
for (name, kind) in erasers {
|
||||
let d = Device::new(10, name, kind, pen_valuators());
|
||||
assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
|
||||
}
|
||||
let tips = [
|
||||
("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
|
||||
("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")),
|
||||
("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")),
|
||||
("Logitech USB Optical Mouse", Some("MOUSE")),
|
||||
("", None),
|
||||
];
|
||||
for (name, kind) in tips {
|
||||
assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eraser_device_sets_the_eraser_end() {
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
|
||||
let feed = StylusFeed::default();
|
||||
pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
|
||||
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
|
||||
assert_eq!(feed.eraser(), Some(false));
|
||||
// Turned over, the pen hovers with its eraser end: the eraser device's first event, with
|
||||
// no pressure yet.
|
||||
pens.raw_event(&feed, 10, &[0b1], &[100.0]);
|
||||
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
|
||||
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
|
||||
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
|
||||
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
|
||||
// Back to the tip, with the tip's own last pressure.
|
||||
pens.raw_event(&feed, 9, &[0b1], &[200.0]);
|
||||
assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5)));
|
||||
// A mouse: no pen end.
|
||||
pens.raw_event(&feed, 12, &[0b1], &[3.0]);
|
||||
assert_eq!(feed.eraser(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
|
||||
let mut pens = Pens::default();
|
||||
@@ -331,18 +392,18 @@ mod tests {
|
||||
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
|
||||
for (min, max) in ranges {
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([Device::new(1, None, [(2, "Abs Pressure".to_string(), min, max)])]);
|
||||
pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
|
||||
let Update::Set(Some(p)) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
|
||||
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
|
||||
assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
|
||||
}
|
||||
}
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices((0..2000u16).map(|i| Device::new(i, None, [])));
|
||||
pens.set_devices((0..2000u16).map(|i| Device::new(i, "", None, [])));
|
||||
assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded");
|
||||
// Values for valuators the pen doesn't have are ignored.
|
||||
pens.set_devices([pen(3)]);
|
||||
let Update::Set(Some(p)) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::<Vec<_>>()) else {
|
||||
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::<Vec<_>>()) else {
|
||||
panic!("the pen gave no pressure")
|
||||
};
|
||||
assert!(p < 0.001, "{p}");
|
||||
|
||||
-3
@@ -1,6 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
|
||||
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Integration tests: one test binary for the crate, one module per area.
|
||||
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod automation_args;
|
||||
mod help;
|
||||
@@ -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="butt" stroke-linejoin="miter"><circle cx="4.5" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.6" fill="currentColor" stroke="none"/><path d="M9 6H21" stroke-width="1.5" /><path d="M9 12H21" stroke-width="1.5" /><path d="M9 18H21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 511 B |
@@ -92,10 +92,10 @@ text-frame fixture.
|
||||
|
||||
## Validation
|
||||
|
||||
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||||
* `engine/tests/it/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||||
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
|
||||
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
|
||||
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||||
`engine/tests/it/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||||
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
@@ -106,7 +106,7 @@ text-frame fixture.
|
||||
feature probes plus one separately identified derived regression input from
|
||||
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
|
||||
notices were inspected, never that project's importer or authoring scripts.
|
||||
* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
|
||||
* `tests/it/real_files.rs` parses every file and freezes the modeled features of all 33 current
|
||||
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
|
||||
text tests assert Unicode run boundaries, fallback fonts and feature retention.
|
||||
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
|
||||
@@ -123,7 +123,7 @@ text-frame fixture.
|
||||
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||||
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||||
and the embedded stream), and checks that whatever opens also renders and exports.
|
||||
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||
`tests/it/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||||
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||||
stream (`stream`):
|
||||
|
||||
@@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
|
||||
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
|
||||
let first = anchors.first()?;
|
||||
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
|
||||
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
|
||||
// The largest radius of each record, once (not a scan of all of them for each anchor).
|
||||
let mut largest: std::collections::HashMap<usize, f64> = std::collections::HashMap::new();
|
||||
for (rec, r) in radii {
|
||||
let e = largest.entry(*rec).or_insert(0.0);
|
||||
*e = e.max(*r);
|
||||
}
|
||||
let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0);
|
||||
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
|
||||
if polyline {
|
||||
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
|
||||
|
||||
@@ -517,6 +517,9 @@ struct ReadAt {
|
||||
|
||||
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
|
||||
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
|
||||
/// The most characters of a layer's name kept.
|
||||
const MAX_NAME_CHARS: usize = 1024;
|
||||
|
||||
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
|
||||
let mut archive = match Archive::open(bytes, limits) {
|
||||
Ok(a) => a,
|
||||
@@ -692,7 +695,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
|
||||
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
|
||||
@@ -139,6 +139,14 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
|
||||
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
|
||||
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
color_within(r, id, 0)
|
||||
}
|
||||
|
||||
/// The most spot colours that stand for one another followed.
|
||||
const MAX_SPOT_CHAIN: u32 = 8;
|
||||
|
||||
/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here.
|
||||
fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
@@ -170,7 +178,13 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
}
|
||||
b"Pant" => {
|
||||
r.warn("spot colours (imported as their RGB equivalent)");
|
||||
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
|
||||
if depth >= MAX_SPOT_CHAIN {
|
||||
return None;
|
||||
}
|
||||
return s
|
||||
.obj(id, b"srgb")
|
||||
.and_then(|c| color_within(r, c, depth + 1))
|
||||
.or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1)));
|
||||
}
|
||||
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
|
||||
_ => {
|
||||
|
||||
@@ -303,6 +303,10 @@ impl Parser<'_> {
|
||||
0x2c => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let size = usize::from(self.c.u16()?);
|
||||
// Records of no bytes take nothing from the data, whatever their number.
|
||||
if n > 0 && size == 0 {
|
||||
return Err(Error::Malformed("array of records without bytes"));
|
||||
}
|
||||
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
|
||||
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
|
||||
|
||||
mod preview {
|
||||
use crate::container::header;
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
|
||||
//! a file links or embeds the same content. Original synthetic documents in the reader's
|
||||
//! accepted schema; no Affinity software or files were used.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod support {
|
||||
pub mod text_fixture;
|
||||
}
|
||||
|
||||
use support::text_fixture::*;
|
||||
use crate::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Error, Limits,
|
||||
model::{Kind, Node},
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Integration tests: one test binary for the crate, one module per area.
|
||||
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
#[path = "../support/corpus.rs"]
|
||||
mod corpus;
|
||||
#[path = "../support/features.rs"]
|
||||
mod features;
|
||||
#[cfg(feature = "synth")]
|
||||
#[path = "../support/text_fixture.rs"]
|
||||
mod text_fixture;
|
||||
|
||||
#[cfg(feature = "synth")]
|
||||
mod import_limits;
|
||||
#[cfg(feature = "synth")]
|
||||
mod outline_limits;
|
||||
mod raster;
|
||||
mod real_files;
|
||||
#[cfg(feature = "synth")]
|
||||
mod text;
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
|
||||
//! accepted schema, without using Affinity software, third-party parser code or external assets.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use vectorcraft_affinity::{
|
||||
Document, Limits,
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Pixel retention on current Affinity 3 documents published under MIT by Patchy.
|
||||
//! Assertions use this reader's archive/stream and the original embedded image bytes;
|
||||
//! no third-party Affinity parser, source code or application is used as an oracle.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use vectorcraft_affinity::{
|
||||
Archive, Limits,
|
||||
@@ -9,8 +8,7 @@ use vectorcraft_affinity::{
|
||||
stream,
|
||||
};
|
||||
|
||||
#[path = "support/corpus.rs"]
|
||||
mod corpus;
|
||||
use crate::corpus;
|
||||
|
||||
fn images(nodes: &[Node], out: &mut Vec<Image>) {
|
||||
for node in nodes {
|
||||
@@ -2,16 +2,13 @@
|
||||
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
|
||||
//! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned
|
||||
//! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_affinity::{Archive, Limits, stream};
|
||||
|
||||
#[path = "support/corpus.rs"]
|
||||
mod corpus;
|
||||
#[path = "support/features.rs"]
|
||||
mod features;
|
||||
use crate::corpus;
|
||||
use crate::features;
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
@@ -90,7 +87,7 @@ fn public_documents_map_to_the_model() {
|
||||
#[test]
|
||||
fn affinity3_feature_retention_matches_pinned_signatures() {
|
||||
let Some(dir) = corpus::pinned() else { return };
|
||||
let expected = include_str!("support/affinity3-signatures.txt");
|
||||
let expected = include_str!("../support/affinity3-signatures.txt");
|
||||
let mut checked = std::collections::BTreeSet::new();
|
||||
for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
|
||||
let (name, signature) = line.split_once('\t').expect("signature row must have a tab");
|
||||
@@ -108,7 +105,7 @@ fn affinity3_feature_retention_matches_pinned_signatures() {
|
||||
doc.saved_by
|
||||
);
|
||||
}
|
||||
let manifest = include_str!("../../../xtask/affinity-corpus.sha256");
|
||||
let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
|
||||
let af_names: std::collections::BTreeSet<_> =
|
||||
manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect();
|
||||
assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline");
|
||||
@@ -1,14 +1,8 @@
|
||||
//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance.
|
||||
//! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3
|
||||
//! mixed-run fixture. Broad complex typography still needs independent visual validation.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod support {
|
||||
pub mod corpus;
|
||||
pub mod text_fixture;
|
||||
}
|
||||
use support::text_fixture::*;
|
||||
use crate::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Limits,
|
||||
model::{Align, Kind, Paragraph, Text},
|
||||
@@ -179,7 +173,7 @@ fn stored_fallback_font_is_used_only_when_it_names_a_font() {
|
||||
|
||||
#[test]
|
||||
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
|
||||
let Some(corpus) = support::corpus::pinned() else { return };
|
||||
let Some(corpus) = crate::corpus::pinned() else { return };
|
||||
// Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser
|
||||
// source was read; assertions describe this crate's parsing of its published MIT documents.
|
||||
let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap();
|
||||
@@ -77,10 +77,32 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
|
||||
walk(Arc::make_mut(c), mode, key);
|
||||
}
|
||||
}
|
||||
// A mask's art is drawn too: a brush on its strokes would run this brush again. Its colours
|
||||
// are the mask's values, so they stay.
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
strip_brushes(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
}
|
||||
walk(n, mode, key);
|
||||
}
|
||||
|
||||
/// Strip the brushes from every stroke under `n`, its masks' art included.
|
||||
fn strip_brushes(n: &mut Node) {
|
||||
for it in &mut n.appearance.items {
|
||||
if let AppearanceItem::Stroke(s) = it {
|
||||
s.brush = None;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
strip_brushes(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
strip_brushes(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
|
||||
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
|
||||
@@ -689,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
|
||||
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
|
||||
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
let bp = node_bezpath(n)?;
|
||||
// The library is parsed for a stroke that names a brush, not for every path.
|
||||
if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) {
|
||||
return None;
|
||||
}
|
||||
let lib = library(doc);
|
||||
let mut out = vec![];
|
||||
let mut any = false;
|
||||
|
||||
@@ -156,6 +156,9 @@ impl Track {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most flattened elements of a path kept.
|
||||
const MAX_FLATTENED: usize = 4_000_000;
|
||||
|
||||
/// Flatten `bp` into tracks (one per subpath) with `tol` accuracy.
|
||||
pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
|
||||
let mut out = vec![];
|
||||
@@ -165,8 +168,20 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
|
||||
out.push(t);
|
||||
}
|
||||
};
|
||||
// Far outside any drawing a curve flattens to millions of points; past a few million, the
|
||||
// rest are not kept.
|
||||
let b = bp.control_box();
|
||||
let extent = b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs());
|
||||
// NaN coordinates must be rejected too, so keep the negation of the `<=` check.
|
||||
if extent.is_nan() || extent > 1e9 {
|
||||
return out;
|
||||
}
|
||||
let mut els = vec![];
|
||||
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| els.push(el));
|
||||
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| {
|
||||
if els.len() < MAX_FLATTENED {
|
||||
els.push(el);
|
||||
}
|
||||
});
|
||||
for el in els {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
|
||||
@@ -23,12 +23,17 @@ const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
const THINNEST: f64 = 0.25;
|
||||
/// The most objects an import makes.
|
||||
const MAX_NODES: usize = 2_000_000;
|
||||
/// The most block visits (one per insert copy) an import makes, whatever they produce.
|
||||
const MAX_VISITS: usize = 10_000_000;
|
||||
/// The most path elements an import makes (blocks drawn as art are copied for every placement).
|
||||
const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 };
|
||||
/// How far from the origin art may lie (points): the canvas.
|
||||
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
|
||||
|
||||
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
|
||||
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";
|
||||
|
||||
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
|
||||
/// and opacity where the art says "by block", and its layer for art on layer 0.
|
||||
@@ -63,6 +68,9 @@ pub(crate) struct Build<'d, 'a> {
|
||||
/// Lineweights are multiplied by this.
|
||||
lw_scale: f64,
|
||||
nodes: usize,
|
||||
visits: usize,
|
||||
/// Path elements made so far.
|
||||
elements: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -118,6 +126,8 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
symbol_map: Affine::IDENTITY,
|
||||
lw_scale: 1.0,
|
||||
nodes: 0,
|
||||
visits: 0,
|
||||
elements: 0,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
@@ -367,7 +377,8 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
let k = self.d.header.ltscale * p.ltscale * scale;
|
||||
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
|
||||
let mut out: Vec<f64> = vec![];
|
||||
for v in pattern {
|
||||
// (A linetype has a few elements; every path keeps its own copy of the dashes.)
|
||||
for v in pattern.iter().take(64) {
|
||||
let dash = *v >= 0.0;
|
||||
let len = v.abs() * k;
|
||||
match out.len().is_multiple_of(2) {
|
||||
@@ -447,6 +458,12 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
}
|
||||
|
||||
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
|
||||
let n = bp.elements().len();
|
||||
if self.elements.saturating_add(n) > MAX_ELEMENTS {
|
||||
self.warn(TOO_MANY_POINTS);
|
||||
return None;
|
||||
}
|
||||
self.elements += n;
|
||||
let mut bp = bp.clone();
|
||||
bp.apply_affine(map);
|
||||
super::curve::bounds(&bp)?;
|
||||
@@ -557,6 +574,13 @@ 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> {
|
||||
self.visits += 1;
|
||||
if self.visits > MAX_VISITS {
|
||||
if self.visits == MAX_VISITS + 1 {
|
||||
self.warn(TOO_MANY);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let items = self.block(name)?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
|
||||
@@ -17,6 +17,8 @@ const DESCENT: f64 = 0.3;
|
||||
const LINE_SPACING: f64 = 5.0 / 3.0;
|
||||
/// The most copies one array insert (MINSERT) makes.
|
||||
const MAX_CELLS: i64 = 10_000;
|
||||
/// The most copies all the array inserts of a file make together.
|
||||
const MAX_TOTAL_CELLS: i64 = 2_000_000;
|
||||
/// Art farther than this from the origin (drawing units) is damaged data, left out.
|
||||
const MAX_COORD: f64 = 1e12;
|
||||
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
|
||||
@@ -184,12 +186,15 @@ pub(crate) struct Converter {
|
||||
/// Entity types left out, with how many.
|
||||
pub skipped: BTreeMap<String, usize>,
|
||||
pub warnings: Vec<String>,
|
||||
/// Array insert copies made so far, against [`MAX_TOTAL_CELLS`].
|
||||
cells: i64,
|
||||
}
|
||||
|
||||
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
|
||||
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
|
||||
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
|
||||
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
|
||||
const MANY_ARRAYS: &str = "array inserts are limited to 2,000,000 copies in all";
|
||||
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
|
||||
|
||||
impl Converter {
|
||||
@@ -294,6 +299,15 @@ impl Converter {
|
||||
} else {
|
||||
(cols, rows)
|
||||
};
|
||||
// Past the file-wide budget, each insert keeps only its first copy.
|
||||
let left = (MAX_TOTAL_CELLS - self.cells).max(1);
|
||||
let (cols, rows) = if cols * rows > left {
|
||||
self.warn(MANY_ARRAYS);
|
||||
(cols.min(left), (left / cols.min(left)).max(1).min(rows))
|
||||
} else {
|
||||
(cols, rows)
|
||||
};
|
||||
self.cells = self.cells.saturating_add(cols * rows);
|
||||
let cells = (0..rows)
|
||||
.flat_map(|r| (0..cols).map(move |c| (r, c)))
|
||||
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
|
||||
|
||||
@@ -423,6 +423,7 @@ fn font_family(g: &[Pair<'_>]) -> String {
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
|
||||
let mut paper: Vec<(i64, LayoutDef)> = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
|
||||
// The layout's own groups follow its subclass marker.
|
||||
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
|
||||
@@ -432,7 +433,7 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
|
||||
continue;
|
||||
}
|
||||
let Some(block) = block else { continue };
|
||||
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
|
||||
if seen.insert(name.to_ascii_lowercase()) {
|
||||
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +799,48 @@ fn array_inserts_attributes_and_dimensions() {
|
||||
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_array_inserts_of_nothing_stop_at_the_work_budget() {
|
||||
let block = |name: &'static str, inner: Option<&'static str>| {
|
||||
let mut b = vec![(0, "BLOCK"), (2, name), (70, "1"), (10, "0"), (20, "0")];
|
||||
if let Some(i) = inner {
|
||||
b.extend([(0, "INSERT"), (2, i), (10, "0"), (20, "0"), (70, "300"), (44, "1")]);
|
||||
}
|
||||
b.push((0, "ENDBLK"));
|
||||
b
|
||||
};
|
||||
let mut blocks = block("*A", Some("*B"));
|
||||
blocks.extend(block("*B", Some("*C")));
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_array_of_a_large_anonymous_block_stops_at_the_path_element_budget() {
|
||||
// A 2,000-vertex polyline in *U1, placed in a 100 × 10 array: 2,000,000 path elements when
|
||||
// every cell is copied, twice the budget tests import with.
|
||||
let mut owned: Vec<(i32, String)> = vec![(0, "BLOCK".into()), (2, "*U1".into()), (70, "1".into()), (10, "0".into()), (20, "0".into())];
|
||||
owned.extend([(0, "LWPOLYLINE".into()), (90, "2000".into()), (70, "0".into())]);
|
||||
for i in 0..2000 {
|
||||
owned.extend([(10, i.to_string()), (20, (i % 2).to_string())]);
|
||||
}
|
||||
owned.push((0, "ENDBLK".into()));
|
||||
let blocks: Vec<(i32, &str)> = owned.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||||
let inserts = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "100"), (71, "10"), (44, "2000"), (45, "2")];
|
||||
let r = open(&drawing(&[], &[], &blocks, &inserts), &points());
|
||||
let mut elements = 0;
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
elements += path.to_bezpath().elements().len();
|
||||
}
|
||||
});
|
||||
assert!(elements > 0);
|
||||
assert!(elements <= 1_000_000, "{elements} path elements");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("path points")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_inside_themselves_are_left_out_without_hanging() {
|
||||
let blocks = [
|
||||
@@ -1061,3 +1103,49 @@ fn entities_extruded_along_minus_z_are_mirrored() {
|
||||
let b = bounds(&leaves(&doc)[0]);
|
||||
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
|
||||
}
|
||||
|
||||
/// A drawing with one paper block record (handle `A`) and a LAYOUT object per name.
|
||||
fn with_layouts(names: &[String]) -> Vec<u8> {
|
||||
let mut s = String::from(
|
||||
" 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nBLOCK_RECORD\n 0\nBLOCK_RECORD\n 5\nA\n 2\n*Paper_Space\n 0\nENDTAB\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n",
|
||||
);
|
||||
for n in names {
|
||||
s.push_str(&format!(" 0\nLAYOUT\n100\nAcDbLayout\n 1\n{n}\n330\nA\n"));
|
||||
}
|
||||
s.push_str(" 0\nENDSEC\n 0\nEOF\n");
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paper_layouts_are_read_in_linear_time() {
|
||||
let names: Vec<String> = (0..100_000).map(|i| format!("L{i}")).collect();
|
||||
let bytes = with_layouts(&names);
|
||||
let t = std::time::Instant::now();
|
||||
let info = info(&bytes).unwrap();
|
||||
assert_eq!(info.layouts.len(), names.len() + 1);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "layout de-duplication is quadratic: {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_layout_names_are_unique_ignoring_case() {
|
||||
let names = ["Layout1", "LAYOUT1", "Other"].map(String::from);
|
||||
let info = info(&with_layouts(&names)).unwrap();
|
||||
assert_eq!(info.layouts, ["Model", "Layout1", "Other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_array_inserts_of_a_missing_block_stay_bounded() {
|
||||
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
|
||||
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
|
||||
let bytes = entities(&e);
|
||||
let text = reader::decode(&bytes);
|
||||
let (pairs, _) = reader::pairs(&text).unwrap();
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let model = d.layout("Model").or(d.layouts.first()).unwrap();
|
||||
let mut conv = entity::Converter::default();
|
||||
let items = conv.items(&d.layout_records(model));
|
||||
assert_eq!(items.len(), 3000);
|
||||
let total: usize = items.iter().map(|i| if let entity::Geom::Insert(ins) = &i.geom { ins.cells.len() } else { 0 }).sum();
|
||||
assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}");
|
||||
assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings);
|
||||
}
|
||||
|
||||
+90
-31
@@ -168,12 +168,32 @@ fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most pixels of an image written (it is held decoded, as RGBA and encoded at once).
|
||||
const MAX_IMAGE_PIXELS: u64 = 64 << 20;
|
||||
|
||||
/// The most entities (handles) a drawing is written with.
|
||||
const MAX_ENTITIES: u32 = 10_000_000;
|
||||
|
||||
/// The largest x or y a path may have to be flattened, and the most points its polygons keep.
|
||||
const MAX_COORD: f64 = 1e9;
|
||||
const MAX_POINTS: usize = 4_000_000;
|
||||
|
||||
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
|
||||
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
|
||||
let b = bp.control_box();
|
||||
if !matches!(
|
||||
b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()).partial_cmp(&MAX_COORD),
|
||||
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||
) {
|
||||
return vec![];
|
||||
}
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let push = |p: Point, cur: &mut Vec<Point>| {
|
||||
if cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
let mut points = 0usize;
|
||||
let mut push = |p: Point, cur: &mut Vec<Point>| {
|
||||
if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
points += 1;
|
||||
cur.push(p);
|
||||
}
|
||||
};
|
||||
@@ -208,21 +228,37 @@ fn symbol_name(name: &str, long: bool) -> String {
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters.
|
||||
fn unique(base: &str, taken: &[&str], max: usize) -> String {
|
||||
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
|
||||
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
|
||||
if free(&base) {
|
||||
return base;
|
||||
/// The names a DXF table holds (compared in any case), and the next suffix to try for each base,
|
||||
/// so identical names don't each search the suffixes before.
|
||||
#[derive(Default)]
|
||||
struct Names {
|
||||
taken: std::collections::HashSet<String>,
|
||||
next: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
/// Names that are there already.
|
||||
fn with(names: &[&str]) -> Self {
|
||||
Self { taken: names.iter().map(|n| n.to_lowercase()).collect(), next: HashMap::new() }
|
||||
}
|
||||
(2..)
|
||||
.map(|i| {
|
||||
let suffix = format!("_{i}");
|
||||
|
||||
/// `base` made unique by `_2`, `_3`…, within `max` characters; the name is taken from now on.
|
||||
fn unique(&mut self, base: &str, max: usize) -> String {
|
||||
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
|
||||
if self.taken.insert(base.to_lowercase()) {
|
||||
return base;
|
||||
}
|
||||
let next = self.next.entry(base.to_lowercase()).or_insert(2);
|
||||
loop {
|
||||
let suffix = format!("_{next}");
|
||||
*next += 1;
|
||||
let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect();
|
||||
format!("{keep}{suffix}")
|
||||
})
|
||||
.find(|n| free(n))
|
||||
.unwrap_or(base)
|
||||
let name = format!("{keep}{suffix}");
|
||||
if self.taken.insert(name.to_lowercase()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
@@ -251,12 +287,14 @@ pub(crate) struct Scene<'a> {
|
||||
e: Codes,
|
||||
next: u32,
|
||||
layers: Vec<Layer>,
|
||||
layer_names: Names,
|
||||
/// The DXF layer art goes on, and its flags.
|
||||
layer: String,
|
||||
flags: LayerFlags,
|
||||
linetypes: Vec<LineType>,
|
||||
/// (style name, font family).
|
||||
styles: Vec<(String, String)>,
|
||||
style_names: Names,
|
||||
images: Vec<ImageDef>,
|
||||
files: Vec<DxfImage>,
|
||||
/// Image keys → index into `images` (`None`: left out).
|
||||
@@ -283,10 +321,12 @@ impl<'a> Scene<'a> {
|
||||
e: Codes::new(o.version),
|
||||
next: fixed::FIRST_FREE,
|
||||
layers: vec![],
|
||||
layer_names: Names::with(&["0", "Defpoints"]),
|
||||
layer: "0".into(),
|
||||
flags: LayerFlags { off: false, locked: false, plot: true },
|
||||
linetypes: vec![],
|
||||
styles: vec![],
|
||||
style_names: Names::with(&[crate::writer::STANDARD_STYLE]),
|
||||
images: vec![],
|
||||
files: vec![],
|
||||
by_key: HashMap::new(),
|
||||
@@ -350,6 +390,12 @@ impl<'a> Scene<'a> {
|
||||
if let NodeKind::Layer { template: true, .. } = n.kind {
|
||||
return;
|
||||
}
|
||||
// Symbols that hold symbols are written in full for each instance: depth alone doesn't
|
||||
// bound how many entities that is.
|
||||
if self.next.saturating_sub(fixed::FIRST_FREE) > MAX_ENTITIES {
|
||||
self.warn("the drawing is too large for DXF: what follows 10 million entities was left out");
|
||||
return;
|
||||
}
|
||||
// Hidden layers are written, switched off.
|
||||
let layer = n.is_layer();
|
||||
if !force && !n.visible && !layer {
|
||||
@@ -452,9 +498,7 @@ impl<'a> Scene<'a> {
|
||||
fn add_layer(&mut self, n: &Node) -> String {
|
||||
let long = self.o.version.r2000();
|
||||
let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long);
|
||||
let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
taken.extend(["0", "Defpoints"]);
|
||||
let name = unique(&base, &taken, if long { 255 } else { 31 });
|
||||
let name = self.layer_names.unique(&base, if long { 255 } else { 31 });
|
||||
let aci = match n.kind {
|
||||
NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256),
|
||||
_ => 7,
|
||||
@@ -855,11 +899,18 @@ impl<'a> Scene<'a> {
|
||||
let angle = b.atan2(a).to_degrees();
|
||||
for line in &layout.lines {
|
||||
let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue };
|
||||
for run in glyphs.chunk_by(|x, y| x.run == y.run) {
|
||||
let Some(first) = run.first() else { continue };
|
||||
// A list marker is a TEXT entity of its own, holding the marker's characters where its
|
||||
// characters' glyphs are drawn (not its tab's).
|
||||
let marked: Vec<(usize, &vectorcraft_text::PositionedGlyph, Option<&vectorcraft_text::ListMarker>)> =
|
||||
glyphs.iter().enumerate().map(|(k, g)| (line.glyph_start + k, g, layout.marker_of(line.glyph_start + k))).collect();
|
||||
for chunk in marked.chunk_by(|x, y| x.1.run == y.1.run && x.2.is_some() == y.2.is_some()) {
|
||||
let run: Vec<&vectorcraft_text::PositionedGlyph> =
|
||||
chunk.iter().filter(|(k, _, m)| m.is_none_or(|m| m.tab != *k)).map(|(_, g, _)| *g).collect();
|
||||
let Some(&first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str());
|
||||
let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
|
||||
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
|
||||
let Some(rgb) = self.paint_rgb(paint) else { continue };
|
||||
@@ -897,9 +948,7 @@ impl<'a> Scene<'a> {
|
||||
return name.clone();
|
||||
}
|
||||
let long = self.o.version.r2000();
|
||||
let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect();
|
||||
taken.push(crate::writer::STANDARD_STYLE);
|
||||
let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 });
|
||||
let name = self.style_names.unique(&symbol_name(family, long), if long { 255 } else { 31 });
|
||||
self.styles.push((name.clone(), family.to_string()));
|
||||
name
|
||||
}
|
||||
@@ -910,7 +959,7 @@ impl<'a> Scene<'a> {
|
||||
self.warn(NO_IMAGES);
|
||||
return;
|
||||
}
|
||||
let Some(i) = self.image_def(&im.key) else { return };
|
||||
let Some(i) = self.image_def(im) else { return };
|
||||
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
|
||||
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
let map = self.to_dxf * im.xf;
|
||||
@@ -952,12 +1001,13 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image file for image `key`, encoded once in the chosen format (`None`: left out).
|
||||
fn image_def(&mut self, key: &str) -> Option<usize> {
|
||||
/// The image file for image `im`, encoded once in the chosen format (`None`: left out).
|
||||
fn image_def(&mut self, im: &ImageObject) -> Option<usize> {
|
||||
let key = im.key.as_str();
|
||||
if let Some(i) = self.by_key.get(key) {
|
||||
return *i;
|
||||
}
|
||||
let made = self.encode_image(key);
|
||||
let made = self.encode_image(im);
|
||||
let i = match made {
|
||||
Ok((file, size)) => {
|
||||
let handle = self.handle();
|
||||
@@ -974,9 +1024,18 @@ impl<'a> Scene<'a> {
|
||||
i
|
||||
}
|
||||
|
||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
|
||||
/// Image `im` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, im: &ImageObject) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
|
||||
// The pixels it converts to (RGBA, 4 bytes each) are read from its header before decoding.
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.ok_or_else(|| UNDECODED.to_string())?;
|
||||
if u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
|
||||
return Err("an image is too large for DXF and was left out".to_string());
|
||||
}
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
|
||||
let size = (img.width(), img.height());
|
||||
let mut bytes = vec![];
|
||||
|
||||
+46
-1
@@ -456,6 +456,42 @@ fn type_is_text_or_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's
|
||||
/// text starts after it, at the left indent.
|
||||
#[test]
|
||||
fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||||
let mut d = text_doc("Item", 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||||
t.para.left_indent = 18.0;
|
||||
t.para.first_line_indent = -18.0;
|
||||
}
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
|
||||
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item")));
|
||||
assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on");
|
||||
// In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab
|
||||
// lies between the two.
|
||||
let mut d = text_doc("שלום", 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let mut bullet_x = f64::NAN;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let m = &lay.markers[0];
|
||||
let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap();
|
||||
assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet");
|
||||
bullet_x = 10.0 + lay.glyphs[bullet].origin.x;
|
||||
}
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
|
||||
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}")));
|
||||
assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]);
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
#[test]
|
||||
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
|
||||
@@ -500,6 +536,7 @@ fn images_link_png_or_jpeg_files() {
|
||||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))]
|
||||
});
|
||||
@@ -533,7 +570,15 @@ fn images_without_pixels_are_left_out_with_one_note() {
|
||||
["raster-1", "raster-2"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
let im = ImageObject {
|
||||
key: key.into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -111,6 +111,16 @@ impl Color {
|
||||
pub fn out_of_gamut(&self) -> bool {
|
||||
cms::active().out_of_gamut(self)
|
||||
}
|
||||
/// The colour exactly, as a key: its model and its components' bits (colours that only look
|
||||
/// alike, as a grey and the same RGB grey, differ).
|
||||
pub fn key(&self) -> [u32; 5] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits(), 0],
|
||||
Color::Cmyk { c, m, y, k } => [1, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()],
|
||||
Color::Gray { k } => [2, k.to_bits(), 0, 0, 0],
|
||||
Color::Lab { l, a, b } => [3, l.to_bits(), a.to_bits(), b.to_bits(), 0],
|
||||
}
|
||||
}
|
||||
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
|
||||
@@ -181,7 +181,9 @@ fn css_comment(s: &str) -> String {
|
||||
|
||||
fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name));
|
||||
let mut taken: Vec<String> = vec![];
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
// The next number to try for each base, so repeated names don't search the ones used before.
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
let mut props = |out: &mut String, list: &[Swatch]| {
|
||||
for w in list.iter().filter(&keep) {
|
||||
let value = match &w.paint {
|
||||
@@ -198,8 +200,18 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
};
|
||||
// Names differing only in case or punctuation map to one property: number the others.
|
||||
let base = css_property(&w.name);
|
||||
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base);
|
||||
taken.push(name.clone());
|
||||
let name = if taken.insert(base.clone()) {
|
||||
base
|
||||
} else {
|
||||
let n = next.entry(base.clone()).or_insert(1);
|
||||
loop {
|
||||
*n += 1;
|
||||
let name = format!("{base}-{n}");
|
||||
if taken.insert(name.clone()) {
|
||||
break name;
|
||||
}
|
||||
}
|
||||
};
|
||||
let note = if w.spot { " /* spot */" } else { "" };
|
||||
out.push_str(&format!(" {name}: {value};{note}\n"));
|
||||
}
|
||||
|
||||
@@ -314,15 +314,15 @@ impl PressureProfile {
|
||||
pub fn at(&self, t: f64) -> f64 {
|
||||
let p = &self.points;
|
||||
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
|
||||
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
|
||||
let v = if t <= first.0 {
|
||||
first.1
|
||||
} else {
|
||||
p.windows(2)
|
||||
.find_map(|w| match w {
|
||||
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(last.1)
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
|
||||
_ => last.1,
|
||||
}
|
||||
};
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||||
@@ -659,7 +659,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
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().iter().any(|copy| touches(copy, s, r, scope)),
|
||||
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)),
|
||||
// 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| {
|
||||
@@ -1173,7 +1173,7 @@ mod tests {
|
||||
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
|
||||
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
|
||||
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default() };
|
||||
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let path_only = HitOptions { path_only: true, ..Default::default() };
|
||||
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
|
||||
|
||||
+99
-1
@@ -456,6 +456,18 @@ pub struct Artboard {
|
||||
/// be set.
|
||||
#[serde(default)]
|
||||
pub show_cross_hairs: bool,
|
||||
/// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and
|
||||
/// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written
|
||||
/// only when on, so a document that never shows them saves as before.
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub show_video_safe_areas: bool,
|
||||
/// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its
|
||||
/// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many
|
||||
/// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't
|
||||
/// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is
|
||||
/// brought into it as it opens.
|
||||
#[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")]
|
||||
pub video_ruler_pixel_aspect_ratio: f64,
|
||||
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
|
||||
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
|
||||
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
|
||||
@@ -464,6 +476,42 @@ pub struct Artboard {
|
||||
pub color: Option<vectorcraft_color::Color>,
|
||||
}
|
||||
|
||||
/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times
|
||||
/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and
|
||||
/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare.
|
||||
pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive<f64> = 0.1..=10.0;
|
||||
|
||||
/// How far in from each edge of an artboard the video safe areas lie, as a share of its width
|
||||
/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the
|
||||
/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television
|
||||
/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5).
|
||||
pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05];
|
||||
|
||||
fn square_pixels() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn is_square_pixels(v: &f64) -> bool {
|
||||
*v == 1.0
|
||||
}
|
||||
|
||||
/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that
|
||||
/// isn't a number (NaN, infinite) reads as square pixels.
|
||||
fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
let v = f64::deserialize(d)?;
|
||||
let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end());
|
||||
Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 })
|
||||
}
|
||||
|
||||
impl Artboard {
|
||||
/// The action-safe and the title-safe area of the artboard, in that order, each inset from its
|
||||
/// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas).
|
||||
pub fn video_safe_areas(&self) -> [Rect; 2] {
|
||||
let r = self.rect;
|
||||
VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's locked artboards ([`Document::locked_artboards`]).
|
||||
pub struct LockedArtboards<'a> {
|
||||
rects: Vec<Rect>,
|
||||
@@ -1131,6 +1179,8 @@ impl Document {
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
}],
|
||||
layers: vec![],
|
||||
@@ -1899,10 +1949,14 @@ impl Document {
|
||||
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
|
||||
self.patterns.iter_mut().find(|p| p.name == name)
|
||||
}
|
||||
/// Deep-clone `node` with fresh ids for it and all descendants.
|
||||
/// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken
|
||||
/// in ([`Self::adopt_pixels`]).
|
||||
pub fn reid(&mut self, node: &Node) -> Node {
|
||||
let mut n = node.clone();
|
||||
n.id = self.alloc_id();
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
self.adopt_image_pixels(im);
|
||||
}
|
||||
// A copied annotation must never bind its source ids to unrelated art in a different
|
||||
// document. Keep the measured geometry; new measurements can be linked after pasting.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
@@ -1927,6 +1981,48 @@ impl Document {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
|
||||
pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> {
|
||||
im.pixels.as_deref().or_else(|| self.images.get(&im.key))
|
||||
}
|
||||
|
||||
/// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art
|
||||
/// made outside a document) into [`Self::images`]: under the image's key, or a free one when
|
||||
/// the key shows other pixels.
|
||||
pub fn adopt_pixels(&mut self, n: &mut Node) {
|
||||
// Only what holds such pixels is copied out of the art it shares.
|
||||
fn holds(n: &Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some())
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| holds(&m.art))
|
||||
|| n.children().into_iter().flatten().any(|c| holds(c))
|
||||
}
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
self.adopt_image_pixels(im);
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut())
|
||||
&& holds(&m.art)
|
||||
{
|
||||
self.adopt_pixels(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if holds(c) {
|
||||
self.adopt_pixels(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_image_pixels(&mut self, im: &mut ImageObject) {
|
||||
let Some(blob) = im.pixels.take() else { return };
|
||||
let mut key = im.key.clone();
|
||||
let mut i = 1;
|
||||
while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) {
|
||||
i += 1;
|
||||
key = format!("{}-{i}", im.key);
|
||||
}
|
||||
self.images.entry(key.clone()).or_insert_with(|| (*blob).clone());
|
||||
im.key = key;
|
||||
}
|
||||
|
||||
/// Saved selections as a file gives them, made safe to list and recall: at most
|
||||
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
|
||||
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
|
||||
@@ -2276,6 +2372,8 @@ mod tests {
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
});
|
||||
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
|
||||
|
||||
@@ -377,6 +377,7 @@ mod tests {
|
||||
xf: Affine::IDENTITY,
|
||||
link,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1476,6 +1476,9 @@ pub struct Hooks<'a> {
|
||||
pub appearance: Outliner<'a>,
|
||||
/// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]).
|
||||
pub pattern: Option<PatternTiles<'a>>,
|
||||
/// The document whose symbols' art counts toward the instance budget of a Repeat of their
|
||||
/// instances ([`crate::RepeatSpec::expand`]).
|
||||
pub symbols: Option<&'a crate::Document>,
|
||||
}
|
||||
|
||||
impl<'a> From<Outliner<'a>> for Hooks<'a> {
|
||||
@@ -1792,7 +1795,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
||||
NodeKind::Blend { children, spec } => blend_expand(children, spec),
|
||||
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
NodeKind::Repeat(r) => r.expand(hooks.symbols),
|
||||
NodeKind::PlacedDocument(p) => p.art(n),
|
||||
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
|
||||
_ => vec![n.clone()],
|
||||
|
||||
@@ -433,6 +433,11 @@ pub struct ImageObject {
|
||||
/// Links panel → Placement Options: how a file read again takes this image's place.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub placement: crate::links::PlacementOptions,
|
||||
/// The pixels of an image in art made outside a document (type's outlines, a glyph's art),
|
||||
/// which has no [`crate::Document::images`] to keep them in. A document takes them in
|
||||
/// ([`crate::Document::adopt_pixels`]): no image in one holds its own.
|
||||
#[serde(skip)]
|
||||
pub pixels: Option<Arc<crate::ImageBlob>>,
|
||||
}
|
||||
|
||||
/// Written with its `type` first, then its fields (an internally tagged enum); read by
|
||||
@@ -1227,7 +1232,7 @@ impl Node {
|
||||
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.
|
||||
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
|
||||
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())),
|
||||
NodeKind::PlacedDocument(p) => Some(p.bounds()),
|
||||
// The members drawn over the others are drawn where they are.
|
||||
NodeKind::Intertwine { children } => union(children),
|
||||
@@ -2543,7 +2548,8 @@ mod tests {
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
let image =
|
||||
ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
|
||||
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
|
||||
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
|
||||
|
||||
+80
-39
@@ -11,12 +11,15 @@
|
||||
//! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the
|
||||
//! instances are the source under [`repeat_transforms`] (evaluated on demand like blends).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::Document;
|
||||
use crate::appearance::AppearanceItem;
|
||||
use crate::node::{Node, NodeId, NodeKind};
|
||||
|
||||
@@ -519,17 +522,51 @@ pub struct RepeatSpec {
|
||||
/// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps.
|
||||
const MAX_REPEAT_INSTANCES: usize = 250_000;
|
||||
|
||||
/// Instances the Repeats in `nodes` make in all (0 when there are none).
|
||||
fn nested_instances(nodes: &[Arc<Node>]) -> usize {
|
||||
let mut sum = 0usize;
|
||||
for n in nodes {
|
||||
sum = sum.saturating_add(match (&n.kind, n.children()) {
|
||||
(NodeKind::Repeat(r), _) => r.instances_in_all(),
|
||||
(_, Some(c)) => nested_instances(c),
|
||||
_ => 0,
|
||||
});
|
||||
/// Counts the instances Repeats make in all, following symbol instances into their symbol's art
|
||||
/// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol
|
||||
/// inside its own art, counts as none.
|
||||
///
|
||||
/// Limitation: symbols that show each other (A's art holds an instance of B and B's holds one of
|
||||
/// A) are counted along the first path that reaches them. While A is counted, the instance of A
|
||||
/// in B's art counts as none, and B keeps that count; had B been counted first, A's count would
|
||||
/// include B's Repeats and B's would not. So within one count, what such symbols add can depend on
|
||||
/// which of them came first. A symbol whose art reaches no such loop always counts the same.
|
||||
struct NestedCount<'a> {
|
||||
doc: Option<&'a Document>,
|
||||
/// The counts made so far, by symbol name (`None` while that symbol's art is being counted).
|
||||
symbols: RefCell<HashMap<String, Option<usize>>>,
|
||||
}
|
||||
|
||||
impl<'a> NestedCount<'a> {
|
||||
fn new(doc: Option<&'a Document>) -> Self {
|
||||
Self { doc, symbols: RefCell::default() }
|
||||
}
|
||||
|
||||
/// Instances the Repeats in the art of symbol `name` make in all.
|
||||
fn symbol(&self, name: &str) -> usize {
|
||||
if let Some(known) = self.symbols.borrow().get(name) {
|
||||
return known.unwrap_or(0);
|
||||
}
|
||||
let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 };
|
||||
self.symbols.borrow_mut().insert(name.to_owned(), None);
|
||||
let n = self.nodes(std::slice::from_ref(&sym.art));
|
||||
self.symbols.borrow_mut().insert(name.to_owned(), Some(n));
|
||||
n
|
||||
}
|
||||
|
||||
/// Instances the Repeats in `nodes` make in all (0 when there are none).
|
||||
fn nodes(&self, nodes: &[Arc<Node>]) -> usize {
|
||||
let mut sum = 0usize;
|
||||
for n in nodes {
|
||||
sum = sum.saturating_add(match (&n.kind, n.children()) {
|
||||
(NodeKind::Repeat(r), _) => r.instances_in_all(self),
|
||||
(NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol),
|
||||
(_, Some(c)) => self.nodes(c),
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
sum
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// Reflection across the line through `c` with direction angle `deg`.
|
||||
@@ -592,11 +629,13 @@ impl RepeatSpec {
|
||||
pub fn source_bounds(&self) -> Option<Rect> {
|
||||
crate::live::nodes_bounds(&self.source)
|
||||
}
|
||||
pub fn transforms(&self) -> Vec<Affine> {
|
||||
/// Where the instances go, counting toward the budget the Repeats in the art of the symbols
|
||||
/// of `doc` that instances in the source show (none without a document).
|
||||
pub fn transforms(&self, doc: Option<&Document>) -> Vec<Affine> {
|
||||
let mut v = repeat_transforms(&self.kind, self.source_bounds());
|
||||
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
|
||||
// grid of one is 62.5 billion instances): keep the first that fit the budget.
|
||||
v.truncate(self.kept(nested_instances(&self.source)));
|
||||
v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source)));
|
||||
v
|
||||
}
|
||||
/// How many of its instances this Repeat keeps when each makes `nested` instances of the
|
||||
@@ -604,19 +643,21 @@ impl RepeatSpec {
|
||||
fn kept(&self, nested: usize) -> usize {
|
||||
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1)
|
||||
}
|
||||
/// How many instances this Repeat draws ([`Self::transforms`]): as many as its options make,
|
||||
/// fewer when the Repeats in its source would take it past [`MAX_REPEAT_INSTANCES`].
|
||||
pub fn instances(&self) -> usize {
|
||||
self.kept(nested_instances(&self.source))
|
||||
/// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its
|
||||
/// options make, fewer when the Repeats in its source would take it past
|
||||
/// [`MAX_REPEAT_INSTANCES`].
|
||||
pub fn instances(&self, doc: Option<&Document>) -> usize {
|
||||
self.kept(NestedCount::new(doc).nodes(&self.source))
|
||||
}
|
||||
/// Instances made in all, those of the Repeats nested in the source included.
|
||||
fn instances_in_all(&self) -> usize {
|
||||
let nested = nested_instances(&self.source);
|
||||
fn instances_in_all(&self, count: &NestedCount) -> usize {
|
||||
let nested = count.nodes(&self.source);
|
||||
self.kept(nested).saturating_mul(nested.max(1))
|
||||
}
|
||||
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
|
||||
pub fn expand(&self) -> Vec<Node> {
|
||||
self.transforms()
|
||||
/// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many
|
||||
/// as [`Self::transforms`] keeps for `doc`.
|
||||
pub fn expand(&self, doc: Option<&Document>) -> Vec<Node> {
|
||||
self.transforms(doc)
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let children = self
|
||||
@@ -633,14 +674,14 @@ impl RepeatSpec {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Geometric bounds of all instances.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
self.copies_bounds(self.source_bounds()?)
|
||||
/// Geometric bounds of all instances ([`Self::transforms`] for `doc`).
|
||||
pub fn bounds(&self, doc: Option<&Document>) -> Option<Rect> {
|
||||
self.copies_bounds(self.source_bounds()?, doc)
|
||||
}
|
||||
/// The bounds of all instances of a source measuring `source`, each placed by its transform
|
||||
/// ([`Self::transforms`]).
|
||||
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
|
||||
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
|
||||
/// ([`Self::transforms`] for `doc`).
|
||||
pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option<Rect> {
|
||||
self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
|
||||
}
|
||||
/// Apply a document transform (source + arrangement parameters).
|
||||
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
|
||||
@@ -864,11 +905,11 @@ mod tests {
|
||||
let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
|
||||
let RepeatKind::Radial { center, .. } = r.kind else { panic!() };
|
||||
assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}");
|
||||
let cs: Vec<Point> = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
|
||||
let cs: Vec<Point> = r.transforms(None).iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
|
||||
assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9);
|
||||
assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}");
|
||||
assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9);
|
||||
assert_eq!(r.expand().len(), 4);
|
||||
assert_eq!(r.expand(None).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -877,18 +918,18 @@ mod tests {
|
||||
if let RepeatKind::Radial { radius, .. } = &mut r.kind {
|
||||
*radius = 50.0;
|
||||
}
|
||||
let m = r.transforms()[0];
|
||||
let m = r.transforms(None)[0];
|
||||
assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_repeat_transforms() {
|
||||
let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0);
|
||||
let t = g.transforms();
|
||||
let t = g.transforms(None);
|
||||
assert_eq!(t.len(), 9);
|
||||
assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9);
|
||||
assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9);
|
||||
assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
|
||||
assert_eq!(g.bounds(None), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -897,7 +938,7 @@ mod tests {
|
||||
if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind {
|
||||
*flip_cols = true;
|
||||
}
|
||||
let t = g.transforms();
|
||||
let t = g.transforms(None);
|
||||
// Column 1 is mirrored about the source centre, then moved one tile right.
|
||||
assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9);
|
||||
}
|
||||
@@ -905,10 +946,10 @@ mod tests {
|
||||
#[test]
|
||||
fn mirror_transforms() {
|
||||
let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
|
||||
let t = m.transforms();
|
||||
let t = m.transforms(None);
|
||||
assert_eq!(t.len(), 2);
|
||||
assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9);
|
||||
let b = m.bounds().unwrap();
|
||||
let b = m.bounds(None).unwrap();
|
||||
assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}");
|
||||
// Horizontal axis.
|
||||
let h = reflect_about(Point::new(0.0, 10.0), 0.0);
|
||||
@@ -918,9 +959,9 @@ mod tests {
|
||||
#[test]
|
||||
fn repeat_transform_moves_arrangement() {
|
||||
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
|
||||
let before = r.bounds().unwrap();
|
||||
let before = r.bounds(None).unwrap();
|
||||
r.transform(Affine::translate((50.0, 0.0)), false);
|
||||
let after = r.bounds().unwrap();
|
||||
let after = r.bounds(None).unwrap();
|
||||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9);
|
||||
let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
|
||||
m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
|
||||
@@ -933,7 +974,7 @@ mod tests {
|
||||
// Instances made in all, counted from the transforms without drawing any.
|
||||
fn made(r: &RepeatSpec) -> usize {
|
||||
let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum();
|
||||
r.transforms().len().saturating_mul(each)
|
||||
r.transforms(None).len().saturating_mul(each)
|
||||
}
|
||||
let grid = |source: Arc<Node>| {
|
||||
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
|
||||
@@ -950,7 +991,7 @@ mod tests {
|
||||
// A 500 × 500 grid of it made 62.5 billion.
|
||||
let outer = grid(repeat(inner.clone()));
|
||||
assert!(made(&outer) <= 250_000, "{} instances", made(&outer));
|
||||
assert_eq!(outer.expand().len(), outer.transforms().len());
|
||||
assert_eq!(outer.expand(None).len(), outer.transforms(None).len());
|
||||
assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000);
|
||||
// Small nests are left as they are.
|
||||
let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0);
|
||||
|
||||
@@ -146,7 +146,7 @@ impl PlacedDocument {
|
||||
pub fn preview_image(&self, bytes: &[u8]) -> Option<ImageObject> {
|
||||
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
|
||||
let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64);
|
||||
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() })
|
||||
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default(), pixels: None })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,11 +288,23 @@ pub fn set_file_reader(f: FileReader) {
|
||||
let _ = FILE_READER.set(f);
|
||||
}
|
||||
|
||||
/// Whether the caller may read the file at a path (`true`: it may). The engine registers one with
|
||||
/// [`set_read_check`]: the cache below is shared by every caller, and a cached file is handed out
|
||||
/// only to a caller that may read the file it came from.
|
||||
pub type ReadCheck = fn(&str) -> bool;
|
||||
|
||||
static READ_CHECK: OnceLock<ReadCheck> = OnceLock::new();
|
||||
|
||||
/// Register how a caller's right to read a file is checked (the first registration stays).
|
||||
pub fn set_read_check(f: ReadCheck) {
|
||||
let _ = READ_CHECK.set(f);
|
||||
}
|
||||
|
||||
/// The most bytes of files read again kept.
|
||||
const FULL_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Files read again, by key, the most recently read last.
|
||||
type FullFiles = Vec<(String, Arc<Vec<u8>>)>;
|
||||
/// Files read again, by key, with the path each was read from, the most recently read last.
|
||||
type FullFiles = Vec<(String, String, Arc<Vec<u8>>)>;
|
||||
|
||||
static FULL: Mutex<FullFiles> = Mutex::new(Vec::new());
|
||||
|
||||
@@ -307,7 +319,11 @@ pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec
|
||||
if !blob.is_proxy() {
|
||||
return Some(blob.bytes.clone());
|
||||
}
|
||||
full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone())
|
||||
let allowed = |path: &str| READ_CHECK.get().is_none_or(|ok| ok(path));
|
||||
// Copy the candidates, newest first, so the filesystem checks run without the lock.
|
||||
let candidates: Vec<(String, Arc<Vec<u8>>)> =
|
||||
full().iter().rev().filter(|(k, _, _)| *k == p.key).map(|(_, path, b)| (path.clone(), b.clone())).collect();
|
||||
candidates.into_iter().find(|(path, _)| allowed(path)).map(|(_, b)| b)
|
||||
}
|
||||
|
||||
/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None`
|
||||
@@ -318,10 +334,10 @@ pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>>
|
||||
}
|
||||
let b = Arc::new(FILE_READER.get()?(p)?);
|
||||
let mut c = full();
|
||||
c.push((p.key.clone(), b.clone()));
|
||||
let mut total: usize = c.iter().map(|(_, b)| b.len()).sum();
|
||||
c.push((p.key.clone(), p.link.path.clone(), b.clone()));
|
||||
let mut total: usize = c.iter().map(|(_, _, b)| b.len()).sum();
|
||||
while total > FULL_BYTES && c.len() > 1 {
|
||||
total = total.saturating_sub(c.remove(0).1.len());
|
||||
total = total.saturating_sub(c.remove(0).2.len());
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
@@ -444,7 +460,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocu
|
||||
let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" };
|
||||
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
|
||||
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
|
||||
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
|
||||
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default(), pixels: None };
|
||||
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(Box::new(im))))], clip: false });
|
||||
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
|
||||
cache().insert(e.clone());
|
||||
@@ -637,6 +653,7 @@ mod tests {
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
|
||||
|
||||
+19
-1
@@ -9,7 +9,12 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
|
||||
if t.is_empty() {
|
||||
return Ok(open);
|
||||
}
|
||||
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
|
||||
t.bytes()
|
||||
.all(|b| b.is_ascii_digit())
|
||||
.then(|| t.parse().ok())
|
||||
.flatten()
|
||||
.filter(|n| (1..=count).contains(n))
|
||||
.ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
|
||||
};
|
||||
let mut out = vec![];
|
||||
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
@@ -35,3 +40,16 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_range;
|
||||
|
||||
#[test]
|
||||
fn sign_is_not_part_of_a_page_number() {
|
||||
assert!(parse_range("+2", 5).is_err());
|
||||
assert!(parse_range("1-+3", 5).is_err());
|
||||
assert!(parse_range("-+2", 5).is_err());
|
||||
assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi
|
||||
}
|
||||
changed += ch as usize;
|
||||
}
|
||||
NodeKind::Image(im) if reach.images && im.link.is_none() => {
|
||||
// An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its
|
||||
// colours, as the glyph does in type.
|
||||
NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => {
|
||||
if let Some(k) = v.image(&im.key) {
|
||||
im.key = k;
|
||||
changed += 1;
|
||||
|
||||
@@ -481,6 +481,80 @@ fn default_align_on() -> char {
|
||||
/// Distance between default tab stops when no explicit stop applies (½ inch).
|
||||
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
|
||||
|
||||
/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Bullet {
|
||||
/// A filled dot (•).
|
||||
#[default]
|
||||
Dot,
|
||||
/// A dash (–).
|
||||
Dash,
|
||||
/// A hollow circle (◦).
|
||||
Circle,
|
||||
/// A filled square (▪).
|
||||
Square,
|
||||
}
|
||||
|
||||
impl Bullet {
|
||||
/// The character the layout draws for the bullet.
|
||||
pub fn character(self) -> char {
|
||||
match self {
|
||||
Bullet::Dot => '\u{2022}',
|
||||
Bullet::Dash => '\u{2013}',
|
||||
Bullet::Circle => '\u{25E6}',
|
||||
Bullet::Square => '\u{25AA}',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets). The layout draws the list's marker
|
||||
/// before the paragraph's first line, in the font, size and color of the paragraph's first
|
||||
/// character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
|
||||
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
|
||||
/// out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
/// The bullet each paragraph of the list starts with.
|
||||
pub bullet: Bullet,
|
||||
}
|
||||
|
||||
impl ParaList {
|
||||
/// A bulleted list with `bullet`.
|
||||
pub fn bulleted(bullet: Bullet) -> Self {
|
||||
Self { bullet }
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph.
|
||||
pub fn marker(&self) -> String {
|
||||
self.bullet.character().to_string()
|
||||
}
|
||||
|
||||
/// The style the marker is set in: `first` (the style of the paragraph's first character)
|
||||
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
|
||||
/// tate-chu-yoko, underline and strikethrough.
|
||||
pub fn marker_style(first: &CharStyle) -> CharStyle {
|
||||
CharStyle {
|
||||
baseline_shift: 0.0,
|
||||
rotation: 0.0,
|
||||
position: CharPosition::Normal,
|
||||
tracking: 0.0,
|
||||
kerning: None,
|
||||
tate_chu_yoko: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
..first.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the
|
||||
/// marker hangs in the first line's 18 pt before the text.
|
||||
pub const LIST_HANGING_INDENT: f64 = 18.0;
|
||||
|
||||
/// Paragraph composer (Paragraph panel menu): how lines are broken.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -674,6 +748,9 @@ pub struct ParaStyle {
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
pub tabs: Vec<TabStop>,
|
||||
/// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not
|
||||
/// in a list.
|
||||
pub list: Option<ParaList>,
|
||||
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
|
||||
pub style_name: Option<String>,
|
||||
/// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made
|
||||
@@ -762,6 +839,8 @@ struct ParaStyleFile {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tabs: Vec<TabStop>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
list: Option<ParaList>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
style_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
|
||||
mojikumi: Mojikumi,
|
||||
@@ -798,6 +877,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
list,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
@@ -821,6 +901,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
list,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
@@ -848,6 +929,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
list,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
@@ -870,6 +952,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
list,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
|
||||
@@ -103,6 +103,7 @@ mod tests {
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
source.visible = false;
|
||||
|
||||
@@ -73,7 +73,7 @@ fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
let end = body.find('_')?;
|
||||
let hex = body.get(..end)?;
|
||||
if hex.is_empty() || hex.len() > 6 {
|
||||
if hex.is_empty() || hex.len() > 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
|
||||
@@ -113,6 +113,12 @@ mod tests {
|
||||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_is_not_a_hex_digit_in_escapes() {
|
||||
assert_eq!(to_object_name("a_x+41_b"), "a x+41 b");
|
||||
assert_eq!(to_object_name("a_x-41_b"), "a x-41 b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_names_that_cannot_start_an_xml_name() {
|
||||
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
|
||||
|
||||
@@ -415,7 +415,7 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone());
|
||||
art = crate::reshape::as_art(n, symbol.as_deref())?;
|
||||
art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?;
|
||||
&art
|
||||
}
|
||||
_ => n,
|
||||
|
||||
@@ -104,7 +104,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
let m = evaluate_container(Some(d), &m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
@@ -119,21 +119,21 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
let m = reshape(Some(d), n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
|
||||
// every copy and its strokes and patterns can differ from the others'.
|
||||
if crate::has_transform_art(n)
|
||||
&& let Some(m) = reshape(n, None)
|
||||
&& let Some(m) = reshape(Some(d), n, None)
|
||||
{
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
|
||||
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
|
||||
// fills' and strokes' art) get ids of their own.
|
||||
if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) {
|
||||
if let Some(m) = container_keeping_type(d, n).or_else(|| evaluate_container(Some(d), n)) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
@@ -324,7 +324,7 @@ fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
/// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is
|
||||
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
|
||||
/// them. `None` when no type stays type.
|
||||
fn container_keeping_type(n: &Node) -> Option<Node> {
|
||||
fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
@@ -349,7 +349,7 @@ fn container_keeping_type(n: &Node) -> Option<Node> {
|
||||
}
|
||||
}
|
||||
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
None => as_art(c, None),
|
||||
None => as_art(None, c, None),
|
||||
};
|
||||
if let Some(o) = art {
|
||||
*c = Arc::new(o);
|
||||
@@ -366,8 +366,7 @@ fn container_keeping_type(n: &Node) -> Option<Node> {
|
||||
if !text {
|
||||
return None;
|
||||
}
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
let mut m = crate::reshape::evaluated(Some(d), n);
|
||||
let mut kept = false;
|
||||
keep(&mut m, &moves, false, &mut kept);
|
||||
if !kept {
|
||||
@@ -455,7 +454,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
let m = evaluate_container(Some(d), &m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
@@ -755,7 +754,7 @@ mod tests {
|
||||
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
|
||||
// The renderer and Expand Appearance still draw it as outlines.
|
||||
let mut text = 0;
|
||||
evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
|
||||
evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
|
||||
assert_eq!(text, 0, "{params}");
|
||||
}
|
||||
// Stacked effects that only move: every copy of a copy.
|
||||
|
||||
+59
-18
@@ -4,7 +4,8 @@
|
||||
//! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect,
|
||||
//! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its
|
||||
//! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each
|
||||
//! result keeps the appearance of the object whose paint it takes.
|
||||
//! result keeps the appearance of the object whose paint it takes. Trap ([`crate::trap`]) keeps the
|
||||
//! children and adds its traps above them.
|
||||
//!
|
||||
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
|
||||
//! paint every member's geometry, below or above the members as its contents slot says
|
||||
@@ -15,7 +16,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops::{self as po, PathfinderOp};
|
||||
|
||||
@@ -197,6 +198,25 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
|
||||
let source: &[Arc<Node>] = group.children()?;
|
||||
let mut stack: Vec<Arc<Node>> = source.to_vec();
|
||||
for e in group.appearance.effects.iter().filter(|e| e.visible) {
|
||||
if e.id == crate::trap::TRAP {
|
||||
let mut lv = vec![];
|
||||
for c in &stack {
|
||||
leaves(c, hook, &mut lv);
|
||||
}
|
||||
if !within_budget(&lv) {
|
||||
return Some(source.to_vec());
|
||||
}
|
||||
let (ids, srcs): (Vec<NodeId>, Vec<crate::trap::TrapSource>) = lv
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let (path, rule) = geometry(l)?;
|
||||
Some((l.id, crate::trap::TrapSource { path, rule, fill: l.appearance.fill_paint() }))
|
||||
})
|
||||
.unzip();
|
||||
let made = crate::trap::traps(&srcs, &crate::trap::TrapOptions::from_params(&e.params));
|
||||
stack.extend(made.into_iter().map(|p| Arc::new(crate::trap::trap_node(ids.get(p.from).copied().unwrap_or(NodeId(0)), p))));
|
||||
continue;
|
||||
}
|
||||
let Some(op) = op_of(&e.id) else { continue };
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
@@ -346,9 +366,10 @@ pub fn has_container_appearance(n: &Node) -> bool {
|
||||
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
|
||||
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
|
||||
/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
|
||||
/// Images, symbol instances and guides add nothing.
|
||||
pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> {
|
||||
fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
|
||||
/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of
|
||||
/// `doc`'s symbols that its instances show toward its instance budget.
|
||||
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
|
||||
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
@@ -360,19 +381,19 @@ pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> {
|
||||
out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero));
|
||||
}
|
||||
}
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out),
|
||||
_ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => members(doc, n, out),
|
||||
_ if vectorcraft_doc::live::is_live(n) => members(doc, &crate::reshape::outlined_members(doc, n), out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
|
||||
fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
|
||||
let clip = usize::from(n.clips());
|
||||
for c in n.children().into_iter().flatten().skip(clip) {
|
||||
push(c, out);
|
||||
push(doc, c, out);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
members(n, &mut out);
|
||||
members(doc, n, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -414,8 +435,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
|
||||
/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its
|
||||
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
|
||||
/// the members (after a clipping path) or above them, as its contents slot says. Its
|
||||
/// transparency, opacity mask and raster effects stay. `None` when it has none of these.
|
||||
pub fn evaluate_container(n: &Node) -> Option<Node> {
|
||||
/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live
|
||||
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]).
|
||||
pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if !is_container(n) {
|
||||
return None;
|
||||
}
|
||||
@@ -434,12 +456,12 @@ pub fn evaluate_container(n: &Node) -> Option<Node> {
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(&m);
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
crate::reshape::reshape_leaves(&mut m, &fx);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
|
||||
if paint {
|
||||
let shapes = member_shapes(&m);
|
||||
let shapes = member_shapes(doc, &m);
|
||||
let bounds = m.geometric_bounds();
|
||||
let art =
|
||||
|items: &[AppearanceItem]| -> Vec<Arc<Node>> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() };
|
||||
@@ -517,6 +539,25 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter
|
||||
/// colour inside the darker one along the edge where they meet.
|
||||
#[test]
|
||||
fn trap_effect_adds_an_overprinting_strip_above_the_members() {
|
||||
let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0);
|
||||
let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0);
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]);
|
||||
let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100});
|
||||
g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true });
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 3, "both squares and one trap");
|
||||
assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2)));
|
||||
let b = out[2].geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}");
|
||||
let fill = out[2].appearance.fill().unwrap();
|
||||
assert!(fill.overprint);
|
||||
assert_eq!(fill.paint.color(), Some(yellow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() {
|
||||
let mut g = two_overlapping_cmyk_squares();
|
||||
@@ -624,7 +665,7 @@ mod tests {
|
||||
fn a_random_transform_on_a_group_moves_its_members_together() {
|
||||
let mut g = group_with("distort.transform");
|
||||
g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true});
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap();
|
||||
let (a, b) = (corner(0), corner(1));
|
||||
assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}");
|
||||
@@ -640,7 +681,7 @@ mod tests {
|
||||
style(&mut t);
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
|
||||
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
(*art.children().unwrap()[1]).clone()
|
||||
}
|
||||
|
||||
@@ -698,7 +739,7 @@ mod tests {
|
||||
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
|
||||
let mut g = Node::group(NodeId(10), vec![square, t]);
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let members = art.children().unwrap();
|
||||
let after = start(&members[1].children().unwrap()[0]);
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
@@ -718,7 +759,7 @@ mod tests {
|
||||
let mut g = Node::group(NodeId(10), vec![square]);
|
||||
let params = serde_json::json!({"moveH": 30, "copies": 2});
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
|
||||
assert_eq!(copies.len(), 3);
|
||||
for (i, c) in copies.iter().enumerate() {
|
||||
|
||||
@@ -51,6 +51,7 @@ mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
pub mod trap;
|
||||
mod util;
|
||||
mod warp;
|
||||
|
||||
@@ -89,6 +90,7 @@ pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
validate_three_d, view_options,
|
||||
};
|
||||
pub use trap::TRAP;
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
@@ -169,6 +171,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"sketch.notePaper" => always(&["relief"]),
|
||||
"sketch.tornEdges" => always(&["smoothness"]),
|
||||
"sketch.waterPaper" => always(&["fiberLength"]),
|
||||
TRAP => always(&["thickness", "precision"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
@@ -850,6 +853,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
|
||||
}
|
||||
}
|
||||
v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults()));
|
||||
v.push(g(
|
||||
CROP_MARKS,
|
||||
"Crop Marks",
|
||||
|
||||
@@ -37,7 +37,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
let outline = |n: &Node| outline(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) })
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
|
||||
}
|
||||
|
||||
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
|
||||
|
||||
@@ -932,22 +932,41 @@ pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
let (mut inner, mut ring) = (vec![0.0f32; w], vec![0.0f32; w]);
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
window_max(row, whole, &mut inner);
|
||||
window_max(row, whole + 1, &mut ring);
|
||||
for ((o, i), r) in out.iter_mut().zip(&inner).zip(&ring) {
|
||||
*o = i + (r - i) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For each `x`, the largest of `row[x - radius..=x + radius]` (cut at the row's ends) into `out`,
|
||||
/// in time linear in the row (a sliding window, not a scan of the window for each `x`).
|
||||
fn window_max(row: &[f32], radius: usize, out: &mut [f32]) {
|
||||
use std::collections::VecDeque;
|
||||
// Indices of the window's values that can still be its largest, falling in value.
|
||||
let mut keep: VecDeque<usize> = VecDeque::new();
|
||||
let mut next = 0;
|
||||
for (x, o) in out.iter_mut().enumerate().take(row.len()) {
|
||||
let hi = x.saturating_add(radius).min(row.len().saturating_sub(1));
|
||||
while next <= hi {
|
||||
let v = row[next];
|
||||
while keep.back().is_some_and(|&b| row[b] <= v) {
|
||||
keep.pop_back();
|
||||
}
|
||||
keep.push_back(next);
|
||||
next += 1;
|
||||
}
|
||||
let lo = x.saturating_sub(radius);
|
||||
while keep.front().is_some_and(|&f| f < lo) {
|
||||
keep.pop_front();
|
||||
}
|
||||
*o = keep.front().map_or(row[x], |&f| row[f]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
@@ -1061,3 +1080,22 @@ mod tests;
|
||||
mod tests_paint;
|
||||
#[cfg(test)]
|
||||
mod tests_sketch;
|
||||
|
||||
#[cfg(test)]
|
||||
mod window_max_tests {
|
||||
use super::window_max;
|
||||
|
||||
#[test]
|
||||
fn the_sliding_maximum_matches_a_scan_of_each_window() {
|
||||
let row: Vec<f32> = (0..40u32).map(|i| ((i * 7919) % 31) as f32).collect();
|
||||
for radius in [0, 1, 2, 5, 39, 100] {
|
||||
let mut out = vec![0.0; row.len()];
|
||||
window_max(&row, radius, &mut out);
|
||||
for (x, got) in out.iter().enumerate() {
|
||||
let (lo, hi) = (x.saturating_sub(radius), (x + radius).min(row.len() - 1));
|
||||
let want = row[lo..=hi].iter().copied().fold(f32::MIN, f32::max);
|
||||
assert_eq!(*got, want, "radius {radius} at {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl<'s, 'd> Drawn<'s, 'd> {
|
||||
let inner = || n.children().is_some_and(|ch| ch.iter().any(|c| self.draws_geometry(c)));
|
||||
let painted = || has_own_paint(n) && inner();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(n),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n),
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) {
|
||||
@@ -196,9 +196,10 @@ impl<'s, 'd> Drawn<'s, 'd> {
|
||||
self.left.set(left);
|
||||
let art = self.symbols.measure_art(symbol, Arc::clone)?;
|
||||
// An instance in perspective is projected by `reshape`.
|
||||
reshape(n, Some(&*art)).or_else(|| outline_art(n, Some(&*art)))
|
||||
let doc = Some(self.symbols.document());
|
||||
reshape(doc, n, Some(&*art)).or_else(|| outline_art(doc, n, Some(&*art)))
|
||||
}
|
||||
_ if reshapes(n) => reshape(n, None),
|
||||
_ if reshapes(n) => reshape(Some(self.symbols.document()), n, None),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) if inner() => {
|
||||
Some(expanded_live_group(Some(self.symbols.document()), n))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_doc::live::Hooks;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
|
||||
@@ -118,7 +119,7 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(art) = reshape(n, None) else { return vec![] };
|
||||
let Some(art) = reshape(None, n, None) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
|
||||
@@ -140,12 +141,14 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
/// path per run, and all of them as one.
|
||||
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
|
||||
let (runs, all, _) = laid_out(t);
|
||||
let (runs, all, ..) = laid_out(t);
|
||||
(runs, all)
|
||||
}
|
||||
|
||||
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
|
||||
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
|
||||
/// [`glyph_outlines`], the bounds of `t`'s layout in text space, and its colour glyphs, which have
|
||||
/// no outlines, placed in the document with their runs: bitmap glyphs (emoji) as images holding
|
||||
/// their pixels, then COLR and SVG glyphs as their art, as the renderer paints them.
|
||||
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect, Vec<(usize, Node)>) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let layout = vectorcraft_text::layout(db, t);
|
||||
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
|
||||
@@ -163,34 +166,37 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::Bez
|
||||
}
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
(runs, all, layout.bounds)
|
||||
let images = layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_image_node(t.xf)?)));
|
||||
let colours = images.chain(layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_colour(t.xf)?)))).collect();
|
||||
(runs, all, layout.bounds, colours)
|
||||
}
|
||||
|
||||
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
|
||||
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
|
||||
/// the characters under them and the others over them, as the renderer paints them. Each run's
|
||||
/// path is placed by the type's transform, which also scales its stroke weights and dashes and
|
||||
/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in
|
||||
/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds
|
||||
/// in the document. The group is marked as type's outlines
|
||||
/// over its colour glyphs (images, art), and (when the object has its own fills/strokes) the whole
|
||||
/// outline with the object's items below the characters under them and the others over them, as
|
||||
/// the renderer paints them. Each run's path is placed by the type's transform, which also scales its stroke
|
||||
/// weights and dashes and transforms its pattern tiles; its unplaced gradients are first fixed to
|
||||
/// the layout's bounds in text space. The object's own unplaced gradients are fixed to the
|
||||
/// bounding box of those bounds in the document. The group is marked as type's outlines
|
||||
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
|
||||
pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let (runs, all, bounds) = laid_out(t);
|
||||
let (runs, all, bounds, colours) = laid_out(t);
|
||||
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
|
||||
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
|
||||
let mut children: Vec<Arc<Node>> = runs
|
||||
.iter()
|
||||
.zip(&t.runs)
|
||||
.filter(|(bp, _)| !bp.elements().is_empty())
|
||||
.map(|(bp, run)| {
|
||||
let mut ap = run.style.appearance();
|
||||
ap.pin_gradients(bounds);
|
||||
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
|
||||
piece.transform(t.xf, scaling);
|
||||
Arc::new(piece)
|
||||
})
|
||||
.collect();
|
||||
let mut children: Vec<Arc<Node>> = vec![];
|
||||
for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() {
|
||||
// Colour glyphs paint themselves, under their run's fill, as the renderer paints them.
|
||||
children.extend(colours.iter().filter(|(r, _)| *r == i).map(|(_, art)| Arc::new(with_id(art, n.id))));
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut ap = run.style.appearance();
|
||||
ap.pin_gradients(bounds);
|
||||
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
|
||||
piece.transform(t.xf, scaling);
|
||||
children.push(Arc::new(piece));
|
||||
}
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let tb = t.xf.transform_rect_bbox(bounds);
|
||||
@@ -211,12 +217,23 @@ pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// `art` with `id` for it and all its members, as the other pieces of outlined type have the type's
|
||||
/// (Expand Appearance gives them ids of their own).
|
||||
fn with_id(art: &Node, id: NodeId) -> Node {
|
||||
let mut art = art.clone();
|
||||
art.id = id;
|
||||
for c in art.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(with_id(c, id));
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
/// `n` as plain art that geometry effects can reshape, without its transparency and object
|
||||
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
|
||||
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
|
||||
/// the instance's transform. `None` for other kinds (images, paths, containers).
|
||||
pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts
|
||||
/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
|
||||
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Text(_) => outline_text(n)?,
|
||||
NodeKind::SymbolInstance { xf, .. } => {
|
||||
@@ -234,7 +251,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
one.appearance.effects.clear();
|
||||
Node::group(n.id, vec![Arc::new(one)])
|
||||
}
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n),
|
||||
_ => return None,
|
||||
};
|
||||
outline_nested_text(&mut art, false);
|
||||
@@ -248,6 +265,14 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// `n` with every live object in it evaluated, type inside them outlined ([`outline_text`]). The
|
||||
/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show
|
||||
/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]).
|
||||
pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
|
||||
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
|
||||
/// its plain outlines ([`outline_text`]) with its visibility, which is what
|
||||
@@ -259,7 +284,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
let clipping = clipping || (clips && i == 0);
|
||||
let art = match &c.kind {
|
||||
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
NodeKind::Text(_) => as_art(c, None),
|
||||
NodeKind::Text(_) => as_art(None, c, None),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(o) = art {
|
||||
@@ -279,22 +304,22 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
as_art(doc, n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own.
|
||||
if has_transform_art(n) {
|
||||
return as_art(n, symbol_art);
|
||||
return as_art(doc, n, symbol_art);
|
||||
}
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
@@ -303,11 +328,11 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
as_art(doc, n, symbol_art)
|
||||
}
|
||||
|
||||
/// [`reshape`] without its checks (no geometry effects: just the art).
|
||||
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
@@ -335,7 +360,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
_ => outline_art(doc, n, symbol_art)?,
|
||||
};
|
||||
reshape_leaves(&mut art, &fx);
|
||||
// Type and symbols in perspective: the art projected onto their plane.
|
||||
@@ -679,9 +704,8 @@ fn placed_count(n: &Node) -> usize {
|
||||
|
||||
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
|
||||
/// it reach every member.
|
||||
pub(crate) fn outlined_members(n: &Node) -> Node {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
|
||||
let mut art = evaluated(doc, n);
|
||||
outline_nested_text(&mut art, false);
|
||||
art
|
||||
}
|
||||
|
||||
@@ -184,9 +184,14 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
row: u64,
|
||||
}
|
||||
let mut chains: Vec<Chain> = vec![];
|
||||
let mut y = y0 + step / 2.0;
|
||||
let mut row = 0u64;
|
||||
while y < y1 {
|
||||
// Rows are counted, not summed: adding a step that is below the spacing of the numbers at `y`
|
||||
// would never move it.
|
||||
while row <= 2001 {
|
||||
let y = y0 + step / 2.0 + row as f64 * step;
|
||||
if y.partial_cmp(&y1) != Some(std::cmp::Ordering::Less) {
|
||||
break;
|
||||
}
|
||||
let mut xs: Vec<f64> = vec![];
|
||||
for pl in &polys {
|
||||
for w in pl.windows(2) {
|
||||
@@ -225,7 +230,6 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
y += step;
|
||||
}
|
||||
let lines: Vec<SubPath> = chains
|
||||
.into_iter()
|
||||
|
||||
@@ -262,6 +262,7 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
)
|
||||
};
|
||||
@@ -284,7 +285,7 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
assert_eq!(count(&art), 25);
|
||||
let mut g = Node::group(id, vec![Arc::new(image())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
|
||||
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
|
||||
assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001);
|
||||
}
|
||||
|
||||
/// What `effects` make of `path` within `budget`.
|
||||
@@ -458,11 +459,12 @@ fn transform_on_a_group_moves_and_copies_its_images() {
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
@@ -479,11 +481,12 @@ fn transform_on_a_group_moves_and_copies_its_images() {
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
))],
|
||||
);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
// The bounds are the image itself, so the bottom-right reference is its own: it halves in place.
|
||||
@@ -508,11 +511,11 @@ fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
|
||||
};
|
||||
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
|
||||
assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
|
||||
// Without copies the instance itself takes the transform, as a path does.
|
||||
let mut g = Node::group(id, vec![Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
|
||||
assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [75.0]);
|
||||
}
|
||||
|
||||
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
|
||||
@@ -531,6 +534,7 @@ fn images_and_symbol_instances_share_the_copy_budget() {
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
|
||||
@@ -560,13 +564,14 @@ fn transform_copies_of_images_stop_at_the_reach_limit() {
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(image)]);
|
||||
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
|
||||
let mut xs = vec![];
|
||||
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
|
||||
image_xs(&evaluate_container(None, &g).unwrap(), &mut xs);
|
||||
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
|
||||
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
|
||||
}
|
||||
@@ -1061,3 +1066,43 @@ fn bench_catalog_lookup() {
|
||||
}
|
||||
eprintln!("10k lookups: {:?} ({n})", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{Document, NodeId, RepeatKind, RepeatSpec, Symbol};
|
||||
let unit = || {
|
||||
Arc::new(Node::path(
|
||||
NodeId(0),
|
||||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0)], true)),
|
||||
Appearance::default(),
|
||||
))
|
||||
};
|
||||
let grid = |source: Arc<Node>, n: u32| {
|
||||
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
|
||||
if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind {
|
||||
*rows = n;
|
||||
*cols = n;
|
||||
}
|
||||
g
|
||||
};
|
||||
let repeat = |r: RepeatSpec| Arc::new(Node::new(NodeId(0), NodeKind::Repeat(r)));
|
||||
let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself.
|
||||
d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) });
|
||||
d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) });
|
||||
d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) });
|
||||
d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) });
|
||||
// A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits.
|
||||
for name in ["Grid", "Grouped"] {
|
||||
let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3)));
|
||||
let kept = expand_live(Some(&d), &outer).len();
|
||||
assert!(kept <= 1, "{name}: {kept} copies of 250,000 instances");
|
||||
}
|
||||
// A symbol holding itself counts no Repeats, and small nests keep every instance.
|
||||
let looped = Node::new(NodeId(0), NodeKind::Repeat(grid(instance("Loop"), 3)));
|
||||
assert_eq!(expand_live(Some(&d), &looped).len(), 9);
|
||||
let small = Node::new(NodeId(0), NodeKind::Repeat(RepeatSpec::mirror(vec![instance("Small")], 90.0, 5.0)));
|
||||
assert_eq!(expand_live(Some(&d), &small).len(), 2);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn transform_objects_off_maps_nothing_and_makes_no_copies() {
|
||||
fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() {
|
||||
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "strokes": true}));
|
||||
assert!(has_transform_art(&n));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let (w, dash) = stroke_of(paths[0]);
|
||||
@@ -103,7 +103,7 @@ fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() {
|
||||
#[test]
|
||||
fn each_copy_scales_its_stroke_once_more() {
|
||||
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let widths: Vec<f64> = leaves(&art).iter().map(|p| stroke_of(p).0).collect();
|
||||
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
|
||||
// Without the option the copies keep the original's weight.
|
||||
@@ -114,7 +114,7 @@ fn each_copy_scales_its_stroke_once_more() {
|
||||
#[test]
|
||||
fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() {
|
||||
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1, "no copies");
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
@@ -125,7 +125,7 @@ fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() {
|
||||
fn transform_patterns_moves_each_copys_pattern() {
|
||||
let n = patterned(json!({"moveH": 30, "copies": 2, "patterns": true}));
|
||||
assert!(has_transform_options(&n));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let xs: Vec<f64> = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
|
||||
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
|
||||
// Without it, every copy's pattern stays put (the tiles don't follow the copies).
|
||||
@@ -136,7 +136,7 @@ fn transform_patterns_moves_each_copys_pattern() {
|
||||
#[test]
|
||||
fn transform_patterns_with_objects_off_moves_only_the_pattern() {
|
||||
let n = patterned(json!({"rotate": 45, "objects": false, "patterns": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
@@ -150,7 +150,7 @@ fn stroke_scaling_across_copies_stays_finite() {
|
||||
let mut n =
|
||||
Node::path(NodeId(9), shapes::line((0.0, 0.0).into(), (100.0, 0.0).into()), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
|
||||
n.appearance.effects.push(transform(json!({"scaleH": 4000, "scaleV": 4000, "copies": 1000, "strokes": true})));
|
||||
if let Some(art) = reshape(&n, None) {
|
||||
if let Some(art) = reshape(None, &n, None) {
|
||||
for p in leaves(&art) {
|
||||
let w = stroke_of(p).0;
|
||||
assert!(w.is_finite() && w <= 1.0e6, "{w}");
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Pathfinder › Trap, shared by the Pathfinder panel's Trap command (`object.pathfinder.trap`) and
|
||||
//! the live effect Effect › Pathfinder › Trap ([`TRAP`]).
|
||||
//!
|
||||
//! Where two colours printed from separate plates meet, a press that registers the plates slightly
|
||||
//! off leaves a sliver of paper showing between them. A trap closes that gap: a thin extra shape
|
||||
//! along the edge where the colours meet, lying over the darker colour, painted with the lighter
|
||||
//! colour and set to overprint, so the lighter colour seems to spread into the darker one (a
|
||||
//! lighter object on a darker background) or to choke it (a lighter background around a darker
|
||||
//! object). The options:
|
||||
//!
|
||||
//! - **Thickness** (0.01–5000 pt): how far the lighter colour reaches into the darker one across
|
||||
//! vertical edges.
|
||||
//! - **Height/Width** (%): the reach across horizontal edges as a percentage of the vertical one
|
||||
//! (100%: the same everywhere), for paper that stretches more one way on press.
|
||||
//! - **Tint Reduction** (%): the trap paints the lighter colour at this percentage of its tint
|
||||
//! (100: at full strength), so two light colours don't print a dark border where they overlap.
|
||||
//! - **Traps with Process Color**: a trap of a swatch colour (a spot colour) is painted with the
|
||||
//! equivalent CMYK process colour instead of the swatch.
|
||||
//! - **Reverse Traps**: the darker colour spreads into the lighter one instead; not when the
|
||||
//! darker colour is a rich black (100% K with other inks), which stays where it is.
|
||||
//! - **Precision** and **Remove Redundant Points** (the effect only): the tolerance in points the
|
||||
//! trap's outline is refitted to, and whether anchors that don't change its shape are dropped.
|
||||
//!
|
||||
//! Only objects filled with a solid colour take part; each object's visible part (what the objects
|
||||
//! above leave of it) is what traps, so a trap follows the edges that actually show. Two objects
|
||||
//! filled with the same colour need no trap. The lighter colour is the one with the higher CIE
|
||||
//! lightness (L*); between colours of equal lightness, the one with less ink.
|
||||
//!
|
||||
//! Behaviour from the public descriptions only:
|
||||
//! <https://helpx.adobe.com/illustrator/using/trapping.html> and
|
||||
//! <https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html> (feature names and
|
||||
//! option ranges; the defaults of 0.25 pt, 100% and 40% are VectorCraft's own choices, the pages
|
||||
//! give none).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops as po;
|
||||
|
||||
use crate::util::{flag, num};
|
||||
|
||||
/// Effect id of Effect › Pathfinder › Trap.
|
||||
pub const TRAP: &str = "pathfinder.trap";
|
||||
|
||||
/// Undo label and name of the Pathfinder panel's Trap.
|
||||
pub const TRAP_LABEL: &str = "Trap";
|
||||
|
||||
/// Parameter documentation of the effect (the panel command `object.pathfinder.trap` documents the
|
||||
/// same keys but precision and removeRedundantPoints).
|
||||
pub const EFFECT_PARAMS_DOC: &str = "{thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black), precision?: pt 0.001..10 (0.01; the tolerance the trap's outline is refitted to), removeRedundantPoints?: bool (true)} adds overprinting traps where the members' visible areas of different solid colours meet (groups and layers: live Pathfinder over the members)";
|
||||
|
||||
/// The options of a trap; [`TrapOptions::from_params`] reads and bounds them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TrapOptions {
|
||||
/// Reach across vertical edges, in points (0.01..=5000).
|
||||
pub thickness: f64,
|
||||
/// Reach across horizontal edges as a fraction of `thickness` (0.01..=10).
|
||||
pub height_width: f64,
|
||||
/// The fraction of the lighter colour's tint the trap paints with (0..=1).
|
||||
pub tint: f32,
|
||||
/// Paint traps of swatch colours with their CMYK equivalent.
|
||||
pub process: bool,
|
||||
/// Spread the darker colour into the lighter one.
|
||||
pub reverse: bool,
|
||||
/// Refit tolerance of the trap outline, in points (0.001..=10).
|
||||
pub precision: f64,
|
||||
/// Drop anchors that don't change the trap's shape.
|
||||
pub remove_redundant: bool,
|
||||
}
|
||||
|
||||
impl Default for TrapOptions {
|
||||
fn default() -> Self {
|
||||
TrapOptions {
|
||||
thickness: 0.25,
|
||||
height_width: 1.0,
|
||||
tint: 0.4,
|
||||
process: false,
|
||||
reverse: false,
|
||||
precision: po::DEFAULT_PRECISION,
|
||||
remove_redundant: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrapOptions {
|
||||
/// The options in `p` (keys of [`EFFECT_PARAMS_DOC`]): a missing or non-numeric value takes its
|
||||
/// default and a value out of range its nearest bound, so a live effect always evaluates.
|
||||
pub fn from_params(p: &Value) -> Self {
|
||||
let d = TrapOptions::default();
|
||||
let (hw_min, hw_max) = po::TRAP_HEIGHT_WIDTH_RANGE;
|
||||
TrapOptions {
|
||||
thickness: num(p, "thickness", d.thickness).clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH),
|
||||
height_width: (num(p, "heightWidth", d.height_width * 100.0) / 100.0).clamp(hw_min, hw_max),
|
||||
tint: (num(p, "tintReduction", f64::from(d.tint) * 100.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
process: flag(p, "trapsWithProcessColor", d.process),
|
||||
reverse: flag(p, "reverseTraps", d.reverse),
|
||||
precision: num(p, "precision", d.precision).clamp(0.001, 10.0),
|
||||
remove_redundant: flag(p, "removeRedundantPoints", d.remove_redundant),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pathfinder panel Trap dialog's fields, at their defaults.
|
||||
pub fn command_defaults() -> Value {
|
||||
let d = TrapOptions::default();
|
||||
json!({
|
||||
"thickness": d.thickness,
|
||||
"heightWidth": d.height_width * 100.0,
|
||||
"tintReduction": (f64::from(d.tint) * 100.0).round(),
|
||||
"trapsWithProcessColor": d.process,
|
||||
"reverseTraps": d.reverse,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Effect › Pathfinder › Trap dialog's fields, at their defaults.
|
||||
pub fn effect_defaults() -> Value {
|
||||
let d = TrapOptions::default();
|
||||
let mut v = command_defaults();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("precision".into(), json!(d.precision));
|
||||
o.insert("removeRedundantPoints".into(), json!(d.remove_redundant));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One object taking part in a trap, back to front: its outline and its fill.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrapSource {
|
||||
pub path: PathData,
|
||||
pub rule: FillRule,
|
||||
pub fill: Paint,
|
||||
}
|
||||
|
||||
/// One trap: its outline, its paint (the spreading colour, tinted) and the sources it spreads
|
||||
/// from and into (indices into the sources given to [`traps`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrapPiece {
|
||||
pub path: PathData,
|
||||
pub paint: Paint,
|
||||
pub from: usize,
|
||||
pub into: usize,
|
||||
}
|
||||
|
||||
/// The solid colour a source traps with: none for no fill, gradients, patterns and Registration.
|
||||
fn trap_color(p: &Paint) -> Option<Color> {
|
||||
if p.is_registration() { None } else { p.color() }
|
||||
}
|
||||
|
||||
fn finite(p: &PathData) -> bool {
|
||||
p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| a.p.x.is_finite() && a.p.y.is_finite())
|
||||
}
|
||||
|
||||
/// Is `a` lighter than `b`? Higher L*; at equal lightness, less ink.
|
||||
fn lighter(a: Color, b: Color) -> bool {
|
||||
let (la, lb) = (a.to_lab().l, b.to_lab().l);
|
||||
if (la - lb).abs() > 1e-3 {
|
||||
return la > lb;
|
||||
}
|
||||
let ink = |c: Color| c.to_cmyk().iter().sum::<f32>();
|
||||
ink(a) < ink(b)
|
||||
}
|
||||
|
||||
/// 100% black with other inks under it.
|
||||
fn rich_black(c: Color) -> bool {
|
||||
matches!(c, Color::Cmyk { c, m, y, k } if k >= 0.995 && c + m + y > 0.005)
|
||||
}
|
||||
|
||||
/// Do `a` and `b` print the same (no trap needed)?
|
||||
fn same_color(a: &Paint, b: &Paint, ca: Color, cb: Color) -> bool {
|
||||
let link = |p: &Paint| match p {
|
||||
Paint::Solid { swatch, .. } => swatch.clone(),
|
||||
_ => None,
|
||||
};
|
||||
link(a) == link(b) && ca.to_cmyk().iter().zip(cb.to_cmyk()).all(|(x, y)| (x - y).abs() < 1e-4)
|
||||
}
|
||||
|
||||
/// The paint of a trap spreading `p` at tint `t`.
|
||||
fn trap_paint(p: &Paint, color: Color, o: &TrapOptions) -> Paint {
|
||||
// A solid colour shows its swatch at `tint` already, so tinting it again gives the swatch at
|
||||
// `tint × t` ([`Color::tinted`] multiplies in every model).
|
||||
let shown = color.tinted(o.tint);
|
||||
match p {
|
||||
Paint::Solid { swatch: Some(_), .. } if o.process => {
|
||||
let [c, m, y, k] = shown.to_cmyk();
|
||||
Paint::solid(Color::cmyk(c, m, y, k))
|
||||
}
|
||||
Paint::Solid { swatch: Some(s), tint, .. } => Paint::Solid { color: shown, swatch: Some(s.clone()), tint: tint * o.tint },
|
||||
_ => Paint::solid(shown),
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible part of each source: the faces of their arrangement that it is the top one of.
|
||||
/// Sources that paint no fill hide nothing and have none.
|
||||
fn visible(sources: &[TrapSource]) -> Vec<PathData> {
|
||||
let mut shapes = vec![];
|
||||
for (i, s) in sources.iter().enumerate() {
|
||||
if !s.fill.is_none() && !s.path.is_empty() && finite(&s.path) {
|
||||
shapes.push(po::Shape::new(s.path.clone(), s.rule, i as u64));
|
||||
}
|
||||
}
|
||||
let mut faces: Vec<Vec<PathData>> = vec![vec![]; sources.len()];
|
||||
for r in po::regions(&shapes) {
|
||||
if let Some(face) = r.sources.last().and_then(|&si| shapes.get(si)).and_then(|s| faces.get_mut(s.key as usize)) {
|
||||
face.push(r.path);
|
||||
}
|
||||
}
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|mut f| match f.len() {
|
||||
0 => PathData::default(),
|
||||
1 => f.pop().unwrap_or_default(),
|
||||
_ => po::unite_all(&f.iter().map(|p| (p, FillRule::NonZero)).collect::<Vec<_>>()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within
|
||||
/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their
|
||||
/// left edge, so shapes spread over the page cost about one check each rather than one per pair.
|
||||
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> {
|
||||
let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect();
|
||||
order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0)));
|
||||
let mut pairs = vec![];
|
||||
for (k, &(i, a)) in order.iter().enumerate() {
|
||||
// Later rects start at or right of `a`, so only how far right they start can rule them out.
|
||||
for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) {
|
||||
if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y {
|
||||
pairs.push((i.min(j), i.max(j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.sort_unstable();
|
||||
pairs
|
||||
}
|
||||
|
||||
/// The traps between `sources` (back to front) with options `o`: for each two sources of
|
||||
/// different solid colours whose visible parts meet, the strip of the darker one's visible part
|
||||
/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per
|
||||
/// [`trap_paint`]. Empty when no visible edges of different colours meet.
|
||||
pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
|
||||
let colors: Vec<Option<Color>> = sources.iter().map(|s| trap_color(&s.fill)).collect();
|
||||
if colors.iter().flatten().count() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
let (w, r) = (o.thickness, o.height_width);
|
||||
if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) {
|
||||
return vec![]; // `trap_band` cuts nothing with these.
|
||||
}
|
||||
let vis = visible(sources);
|
||||
// Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects
|
||||
// the rest by the same bounds), so find those first instead of trying every pair. The hair
|
||||
// keeps rounding from rejecting a pair `trap_band` would keep.
|
||||
let rects: Vec<Option<Rect>> =
|
||||
vis.iter().zip(&colors).map(|(p, c)| c.and(p.bounds()).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))).collect();
|
||||
let (w, r) = (w.clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH), r.clamp(po::TRAP_HEIGHT_WIDTH_RANGE.0, po::TRAP_HEIGHT_WIDTH_RANGE.1));
|
||||
let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs()));
|
||||
let hair = 1e-9 * (1.0 + extent + w * r.max(1.0));
|
||||
let mut out = vec![];
|
||||
for (i, j) in near_pairs(&rects, w + hair, w * r + hair) {
|
||||
let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue };
|
||||
if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) {
|
||||
continue;
|
||||
}
|
||||
let (mut from, mut into) = if lighter(ci, cj) { (i, j) } else { (j, i) };
|
||||
if o.reverse && colors[into].is_some_and(|c| !rich_black(c)) {
|
||||
(from, into) = (into, from);
|
||||
}
|
||||
let mut band = po::trap_band(&vis[from], FillRule::NonZero, &vis[into], FillRule::NonZero, o.thickness, o.height_width, o.precision);
|
||||
if o.remove_redundant {
|
||||
band = po::remove_redundant_points(&band, o.precision);
|
||||
}
|
||||
if band.is_empty() || po::area(&band, FillRule::NonZero) <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let Some(color) = colors[from] else { continue };
|
||||
out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A trap's appearance: one overprinting fill of `paint`, no stroke.
|
||||
pub fn trap_appearance(paint: Paint) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(paint) })], ..Default::default() }
|
||||
}
|
||||
|
||||
/// A trap as a path object with id `id`.
|
||||
pub fn trap_node(id: NodeId, piece: TrapPiece) -> Node {
|
||||
Node::path(id, piece.path, trap_appearance(piece.paint))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn src(r: Rect, c: Color) -> TrapSource {
|
||||
TrapSource { path: rectangle(r), rule: FillRule::NonZero, fill: Paint::solid(c) }
|
||||
}
|
||||
|
||||
fn yellow() -> Color {
|
||||
Color::cmyk(0.0, 0.0, 1.0, 0.0)
|
||||
}
|
||||
|
||||
fn blue() -> Color {
|
||||
Color::cmyk(1.0, 0.6, 0.0, 0.0)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 0.02
|
||||
}
|
||||
|
||||
fn abutting() -> Vec<TrapSource> {
|
||||
vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), blue())]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lighter_colour_spreads_into_the_darker_along_the_shared_edge() {
|
||||
let o = TrapOptions { thickness: 3.0, tint: 1.0, ..Default::default() };
|
||||
let t = traps(&abutting(), &o);
|
||||
assert_eq!(t.len(), 1);
|
||||
let b = t[0].path.bounds().unwrap();
|
||||
assert!(near(b.x0, 50.0) && near(b.x1, 53.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "inside the blue, 3 pt wide: {b:?}");
|
||||
assert_eq!((t[0].from, t[0].into), (0, 1));
|
||||
assert_eq!(t[0].paint.color(), Some(yellow()));
|
||||
let n = trap_node(NodeId(7), t[0].clone());
|
||||
assert!(n.appearance.fill().is_some_and(|f| f.overprint), "the trap overprints");
|
||||
assert!(n.appearance.stroke_paint().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_traps_spread_the_darker_colour() {
|
||||
let o = TrapOptions { thickness: 2.0, reverse: true, tint: 1.0, ..Default::default() };
|
||||
let t = traps(&abutting(), &o);
|
||||
assert_eq!(t.len(), 1);
|
||||
let b = t[0].path.bounds().unwrap();
|
||||
assert!(near(b.x0, 48.0) && near(b.x1, 50.0), "inside the yellow: {b:?}");
|
||||
assert_eq!(t[0].paint.color(), Some(blue()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_traps_leave_rich_black_alone() {
|
||||
let rich = Color::cmyk(0.6, 0.4, 0.4, 1.0);
|
||||
let s = vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), rich)];
|
||||
let t = traps(&s, &TrapOptions { reverse: true, ..Default::default() });
|
||||
assert_eq!(t[0].from, 0, "the yellow still spreads into the rich black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_reduction_tints_the_trap_and_keeps_a_swatch_unless_process_colour() {
|
||||
let mut s = abutting();
|
||||
s[0].fill = Paint::Solid { color: yellow(), swatch: Some("PANTONE Yellow".into()), tint: 1.0 };
|
||||
let t = traps(&s, &TrapOptions { tint: 0.5, ..Default::default() });
|
||||
match &t[0].paint {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
assert_eq!(swatch.as_deref(), Some("PANTONE Yellow"));
|
||||
assert!((tint - 0.5).abs() < 1e-6);
|
||||
assert!((color.to_cmyk()[2] - 0.5).abs() < 1e-4, "{color:?}");
|
||||
}
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
let t = traps(&s, &TrapOptions { tint: 0.5, process: true, ..Default::default() });
|
||||
match &t[0].paint {
|
||||
Paint::Solid { color, swatch: None, .. } => assert!(matches!(color, Color::Cmyk { .. })),
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lighter_object_on_a_darker_background_spreads_all_round() {
|
||||
// Yellow square on top of a blue background: a ring around the yellow, inside the blue.
|
||||
let s = vec![src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()), src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow())];
|
||||
let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() });
|
||||
assert_eq!(t.len(), 1);
|
||||
let b = t[0].path.bounds().unwrap();
|
||||
assert!(near(b.x0, 39.0) && near(b.x1, 61.0) && near(b.y0, 39.0) && near(b.y1, 61.0), "{b:?}");
|
||||
assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trap_without_adjoining_edges_of_different_colours() {
|
||||
let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())];
|
||||
assert!(traps(&apart, &TrapOptions::default()).is_empty());
|
||||
let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())];
|
||||
assert!(traps(&same, &TrapOptions::default()).is_empty());
|
||||
let mut unfilled = abutting();
|
||||
unfilled[1].fill = Paint::None;
|
||||
assert!(traps(&unfilled, &TrapOptions::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_are_bounded_and_bad_numbers_take_defaults() {
|
||||
let o = TrapOptions::from_params(&json!({"thickness": 1e300, "heightWidth": -5, "tintReduction": 400, "precision": "x"}));
|
||||
assert_eq!(o.thickness, po::TRAP_MAX_WIDTH);
|
||||
assert_eq!(o.height_width, po::TRAP_HEIGHT_WIDTH_RANGE.0);
|
||||
assert_eq!(o.tint, 1.0);
|
||||
assert_eq!(o.precision, po::DEFAULT_PRECISION);
|
||||
let o = TrapOptions::from_params(&json!({"thickness": "NaN"}));
|
||||
assert_eq!(o.thickness, TrapOptions::default().thickness);
|
||||
// The widest trap still stays inside the darker shape.
|
||||
let t = traps(&abutting(), &TrapOptions { thickness: f64::NAN, ..Default::default() });
|
||||
assert!(t.is_empty(), "a NaN thickness traps nothing");
|
||||
let t = traps(&abutting(), &TrapOptions { thickness: po::TRAP_MAX_WIDTH, ..Default::default() });
|
||||
let b = t[0].path.bounds().unwrap();
|
||||
assert!(near(b.x0, 50.0) && near(b.x1, 100.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_coordinates_are_left_out() {
|
||||
let mut s = abutting();
|
||||
s[1].path.subpaths[0].anchors[0].p.x = f64::NAN;
|
||||
assert!(traps(&s, &TrapOptions::default()).is_empty());
|
||||
}
|
||||
|
||||
/// 2,000 separated 10 pt squares in alternating colours on a 20 pt grid, then a yellow square
|
||||
/// abutting the first (blue) one's right edge.
|
||||
fn many_apart_and_one_abutting() -> Vec<TrapSource> {
|
||||
let mut s: Vec<TrapSource> = (0..2000)
|
||||
.map(|k| {
|
||||
let (x, y) = (f64::from(k % 50) * 20.0, f64::from(k / 50) * 20.0);
|
||||
src(Rect::new(x, y, x + 10.0, y + 10.0), if (k + k / 50) % 2 == 0 { blue() } else { yellow() })
|
||||
})
|
||||
.collect();
|
||||
s.push(src(Rect::new(10.0, 0.0, 15.0, 10.0), yellow()));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_shapes_within_reach_are_paired() {
|
||||
let s = many_apart_and_one_abutting();
|
||||
let rects: Vec<Option<Rect>> = s.iter().map(|t| t.path.bounds()).collect();
|
||||
assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired");
|
||||
// Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do.
|
||||
let row: Vec<Option<Rect>> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect();
|
||||
assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]);
|
||||
let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() });
|
||||
assert_eq!(t.len(), 1, "only the abutting pair traps");
|
||||
assert_eq!((t[0].from, t[0].into), (2000, 0));
|
||||
let b = t[0].path.bounds().unwrap();
|
||||
assert!(near(b.x0, 9.0) && near(b.x1, 10.0) && near(b.y0, 0.0) && near(b.y1, 10.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_by_bounds_keeps_every_trap_in_order() {
|
||||
// Shapes abutting across x and y, overlapping, and half a point apart (within a 1 pt trap),
|
||||
// none of them hiding another's edges: the traps are those of each pair alone.
|
||||
let s = vec![
|
||||
src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()),
|
||||
src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow()),
|
||||
src(Rect::new(100.0, 0.0, 120.0, 50.0), yellow()),
|
||||
src(Rect::new(0.0, 100.5, 50.0, 120.0), yellow()),
|
||||
src(Rect::new(55.0, 100.0, 80.0, 130.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)),
|
||||
];
|
||||
let o = TrapOptions { thickness: 1.0, ..Default::default() };
|
||||
let got: Vec<(usize, usize)> = traps(&s, &o).iter().map(|t| (t.from, t.into)).collect();
|
||||
let mut want = vec![];
|
||||
for i in 0..s.len() {
|
||||
for j in i + 1..s.len() {
|
||||
if let Some(t) = traps(&[s[i].clone(), s[j].clone()], &o).first() {
|
||||
want.push(if t.from == 0 { (i, j) } else { (j, i) });
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(want.len(), 4, "{want:?}");
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
|
||||
const CURRENT_SYMBOL: &str = "currentSymbol";
|
||||
pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol";
|
||||
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
|
||||
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
|
||||
|
||||
@@ -727,7 +727,7 @@ fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- symbols ----------
|
||||
|
||||
fn natural_size(d: &Document, name: &str) -> (f64, f64) {
|
||||
pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) {
|
||||
d.unknown
|
||||
.get(SIZES_KEY)
|
||||
.and_then(|m| m.get(name))
|
||||
@@ -735,7 +735,7 @@ fn natural_size(d: &Document, name: &str) -> (f64, f64) {
|
||||
.unwrap_or((2.0 * HALF, 2.0 * HALF))
|
||||
}
|
||||
|
||||
fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
|
||||
pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
|
||||
let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({}));
|
||||
if let Some(o) = m.as_object_mut() {
|
||||
match size {
|
||||
@@ -746,7 +746,7 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
|
||||
}
|
||||
|
||||
/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`.
|
||||
fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
let c = at - reg.offset_from_center(w, h);
|
||||
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
|
||||
}
|
||||
@@ -806,7 +806,7 @@ fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
}
|
||||
|
||||
/// Normalise art for a symbol definition: returns (art, natural size, centre in the document).
|
||||
fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?;
|
||||
let (w, h) = (b.width().max(1.0), b.height().max(1.0));
|
||||
let c = b.center();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! text besides it (see `flavours`).
|
||||
|
||||
mod flavours;
|
||||
mod resources;
|
||||
pub(crate) mod resources;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -421,8 +421,15 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image =
|
||||
ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() };
|
||||
let image = ImageObject {
|
||||
key: img.key.clone(),
|
||||
width: w,
|
||||
height: h,
|
||||
xf: Affine::scale_non_uniform(sx, sy),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() };
|
||||
clip.images.insert(img.key, img.blob);
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
|
||||
@@ -500,7 +500,7 @@ impl Imported {
|
||||
|
||||
impl Clipboard {
|
||||
/// `nodes` (from `src`) and the resources of `src` they use.
|
||||
pub(super) fn gather(src: &Document, nodes: Vec<Node>) -> Self {
|
||||
pub(crate) fn gather(src: &Document, nodes: Vec<Node>) -> Self {
|
||||
let used = Used::of(&Defs::of(src), &nodes);
|
||||
let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::<Vec<_>>();
|
||||
let brushes = match used.names.get(&Res::Brush) {
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
flare
|
||||
),
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2, fill?: bool (Fill Line: the current fill, else none)} → {id}", has_doc, line),
|
||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||
cmd!(
|
||||
"shape.arc",
|
||||
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drawing Mode",
|
||||
[],
|
||||
Some("Shift+D"),
|
||||
"{mode?: normal|behind|inside} (inside needs one selected path, compound path or type; no param cycles normal → behind → inside → normal, going from behind back to normal when Draw Inside can't draw into the selection)",
|
||||
"{mode?: normal|behind|inside} (inside needs one selected path, compound path or type; no param cycles normal → behind → inside → normal, going from behind back to normal when Draw Inside can't draw into the selection; when the Draw Inside target is isolated, drawing or pasting into it moves isolation to its clip group)",
|
||||
has_doc,
|
||||
draw_mode
|
||||
),
|
||||
@@ -149,16 +149,18 @@ fn insert_look_turned(
|
||||
let mode = s.draw_mode;
|
||||
let inside = s.draw_inside;
|
||||
let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied();
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let (id, into) = s.edit(label, |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, kind);
|
||||
look.apply(d, &mut n);
|
||||
n.name = name;
|
||||
n.bbox_angle = bbox_angle;
|
||||
let mut into = None;
|
||||
match (mode, inside) {
|
||||
(crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => {
|
||||
let group = draw_inside_group(d, target)?;
|
||||
d.insert(Some(group), usize::MAX, n)?;
|
||||
into = Some((target, group));
|
||||
}
|
||||
(crate::DrawMode::Behind, _) => match behind_of.and_then(|b| d.position(b)) {
|
||||
Some((par, idx, _)) => {
|
||||
@@ -175,8 +177,11 @@ fn insert_look_turned(
|
||||
if select {
|
||||
sel.set([id]);
|
||||
}
|
||||
Ok(id)
|
||||
Ok((id, into))
|
||||
})?;
|
||||
if let Some((target, group)) = into {
|
||||
isolate_draw_inside_group(s, target, group)?;
|
||||
}
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
@@ -204,6 +209,21 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
/// Draw Inside into the isolated object (#3705): isolation mode moves to the clip group the art
|
||||
/// went into ([`draw_inside_group`]), so what is drawn or pasted inside the object is in isolation
|
||||
/// mode with it, shown undimmed and picked by clicks, instead of locked beside it. Art added in
|
||||
/// isolation mode belongs with what is isolated: it takes the isolated art's place when the mode
|
||||
/// ends (<https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/isolate-objects.html>).
|
||||
/// A drag's next preview, replayed on the drag's snapshot, makes the group again under the same
|
||||
/// id, so isolation stays on it; cancelling the drag restores the isolation it began with.
|
||||
pub(crate) fn isolate_draw_inside_group(s: &mut Session, target: NodeId, group: NodeId) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
if st.isolation == Some(target) {
|
||||
st.isolation = Some(group);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The object Draw Inside would draw into: the one selected object, when it is a path, a compound
|
||||
/// path or type.
|
||||
fn inside_target(st: &crate::DocState) -> Option<NodeId> {
|
||||
@@ -466,9 +486,10 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?);
|
||||
let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?);
|
||||
let live = LiveShape::Line { a, b };
|
||||
// Lines are stroked, never filled (Illustrator ignores the fill for new open lines).
|
||||
// Lines are stroked and, unless Fill Line asks for the current fill, not filled.
|
||||
let fill = if switch_or(p, "fill", false, "shape.line")? { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(
|
||||
vectorcraft_color::Paint::None,
|
||||
fill,
|
||||
if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
|
||||
s.paint.stroke_width.max(0.1),
|
||||
);
|
||||
|
||||
@@ -101,10 +101,10 @@ type ImageRow = (String, u32, u32, Option<String>);
|
||||
/// A Repeat object as Document Info lists it: its `repeatObjects` entry, and its row, which gives
|
||||
/// its name and then its kind with the number of instances it draws (for a grid, its rows and
|
||||
/// columns, followed by the number it draws when that is fewer).
|
||||
fn repeat_entry(n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) {
|
||||
fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) {
|
||||
let name = n.display_name();
|
||||
let count = |n: usize, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") };
|
||||
let instances = spec.instances();
|
||||
let instances = spec.instances(Some(d));
|
||||
let mut entry = json!({ "id": n.id.0, "name": name, "instances": instances });
|
||||
let kind = &spec.kind;
|
||||
let detail = match kind {
|
||||
@@ -377,7 +377,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let NodeKind::Repeat(spec) = &n.kind {
|
||||
let (entry, row) = repeat_entry(n, spec);
|
||||
let (entry, row) = repeat_entry(d, n, spec);
|
||||
repeats.push(entry);
|
||||
repeat_rows.push(row);
|
||||
}
|
||||
|
||||
@@ -411,6 +411,8 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
@@ -436,9 +438,20 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
/// After undo or redo, isolation mode ends when the isolated object is no longer in the document,
|
||||
/// such as the clip group Draw Inside made around the isolated object (#3705), rather than naming
|
||||
/// an id a later object may take.
|
||||
fn leave_isolation_if_gone(st: &mut crate::DocState) {
|
||||
if st.isolation.is_some_and(|i| st.doc.node(i).is_none()) {
|
||||
st.isolation = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// History panel › Clear History: removes the steps after the current state and the steps before
|
||||
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
|
||||
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
|
||||
@@ -724,7 +737,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
|
||||
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
// Draw Inside: the clip group of the object drawn into, made on the first paste.
|
||||
let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?;
|
||||
@@ -771,8 +784,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
Ok((new_ids, imported, artboards))
|
||||
Ok((new_ids, imported, artboards, into))
|
||||
})?;
|
||||
if let (Some(target), Some(group)) = (inside, into) {
|
||||
super::create::isolate_draw_inside_group(s, target, group)?;
|
||||
}
|
||||
// The Artboard tool selects the pasted artboards, the first one active.
|
||||
if !artboards.is_empty() && s.tool_id() == "artboard" {
|
||||
s.set_tool_option("selected", &json!(artboards));
|
||||
|
||||
@@ -558,10 +558,12 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
let mut listed = vec![];
|
||||
let m = expanded_node(d, n, brushes, &mut listed);
|
||||
out.extend(listed.into_iter().map(|(id, _)| id));
|
||||
if let Some(m) = m
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
if let Some(mut m) = m {
|
||||
// Art made from type takes its emoji's pixels into the document.
|
||||
d.adopt_pixels(&mut m);
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +628,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
image => {
|
||||
let m = if container {
|
||||
// A group's or layer's own fills and strokes become art among its members.
|
||||
effects::evaluate_container(&v).map(|mut m| {
|
||||
effects::evaluate_container(Some(&*d), &v).map(|mut m| {
|
||||
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
@@ -639,7 +641,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
|
||||
effects::expand_outlined(Some(&*d), &v, symbol.as_deref()).map(|mut m| {
|
||||
expand_pieces(d, &mut m, true, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
@@ -1111,7 +1113,7 @@ mod tests {
|
||||
let glyphs = effects::outline_text(&node(&s, t)).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}");
|
||||
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
|
||||
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
|
||||
let glyph_area = area(&glyphs);
|
||||
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::Model;
|
||||
use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::GradientMesh;
|
||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
@@ -412,6 +413,45 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint
|
||||
/// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other
|
||||
/// pattern fills stay. A tile image counts as
|
||||
/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group
|
||||
/// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending
|
||||
/// mode. Returns how many shapes changed.
|
||||
pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet<String>) -> Result<usize> {
|
||||
let (_, n) = replace_leaves(d, root, |d, l| {
|
||||
// Most leaves have no pattern fill: skip the ancestry walk for them.
|
||||
if !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended);
|
||||
let transparent = |d: &Document, i: &AppearanceItem| {
|
||||
pattern_fill(d, i).is_some_and(|(f, def, _)| {
|
||||
let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal;
|
||||
def.art.iter().any(|a| transparent_image(a, see_through, on))
|
||||
})
|
||||
};
|
||||
expand_patterns_where(d, l, transparent)
|
||||
})?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Whether `n` has opacity or a blending mode of its own.
|
||||
fn blended(n: &Node) -> bool {
|
||||
n.opacity < 1.0 || n.blend != BlendMode::Normal
|
||||
}
|
||||
|
||||
/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or
|
||||
/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`.
|
||||
fn transparent_image(n: &Node, keys: &BTreeSet<String>, on: bool) -> bool {
|
||||
let on = on || blended(n);
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => on || keys.contains(&im.key),
|
||||
_ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it
|
||||
/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects
|
||||
/// other than geometry effects, in appearance order: the other items stay on plain copies of the
|
||||
@@ -420,7 +460,15 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill
|
||||
/// lays a tile.
|
||||
fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
expand_patterns_where(d, l, |_, _| true)
|
||||
}
|
||||
|
||||
/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other
|
||||
/// items.
|
||||
fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
|| !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
@@ -431,7 +479,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let fill = pattern_fill(d, item).filter(|_| which(d, item)).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Builder {
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
})
|
||||
.collect();
|
||||
@@ -1272,7 +1274,15 @@ impl Builder {
|
||||
let sy = img.height as f64 / f64::from(r.height.max(1));
|
||||
let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy);
|
||||
self.doc.images.entry(r.key.clone()).or_insert(r.blob);
|
||||
Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })))
|
||||
Some(NodeKind::Image(Box::new(ImageObject {
|
||||
key: r.key,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
xf,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,17 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
|
||||
|
||||
/// `d` with one artboard, `rect`.
|
||||
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, locked: false, show_center_mark: false, show_cross_hairs: false, color: None }];
|
||||
d.artboards = vec![Artboard {
|
||||
id: 1,
|
||||
name: name.into(),
|
||||
rect,
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
}];
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,13 @@
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded
|
||||
//! is missing from the images that flattening and expanding raster effects draw, and the warnings
|
||||
//! the art under it stays as it was. An image in a group with opacity or a blending mode is
|
||||
//! flattened the same way, and a pattern fill that paints a transparent image (see-through
|
||||
//! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is
|
||||
//! expanded into its tiles first so its images flatten too. With `omit` the images with
|
||||
//! see-through pixels, opacity or a blending mode of their own are left out, and pattern fills stay
|
||||
//! as the writer draws them (their tiles opaque over white). An image that can't be decoded is
|
||||
//! missing from the images that flattening and expanding raster effects draw, and the warnings
|
||||
//! then carry the raster exports' note once.
|
||||
//!
|
||||
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
|
||||
@@ -117,6 +122,9 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
let mut d = full.into_owned();
|
||||
expand_appearances(&mut d);
|
||||
if mode == SeeThrough::Flatten {
|
||||
expand_image_patterns(&mut d)?;
|
||||
}
|
||||
let images = see_through_images(&d, mode);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
@@ -153,17 +161,30 @@ fn expand_appearances(d: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those
|
||||
/// images flatten like any other transparent image. The writer would otherwise draw them opaque
|
||||
/// over white.
|
||||
fn expand_image_patterns(d: &mut Document) -> Result<()> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
for id in layers {
|
||||
super::super::expand::expand_transparent_image_patterns(d, id, &keys)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
|
||||
/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only
|
||||
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
|
||||
/// white); all are left out with `omit`.
|
||||
/// [`Document::walk`] counts). These are the images with see-through pixels, and the images
|
||||
/// that have opacity or a blending mode of their own or, with `flatten`, of a group they are in.
|
||||
/// With `flatten` only visible images are returned, because a hidden one (which the hidden objects
|
||||
/// option may write) is written over white. With `omit` all of them are returned.
|
||||
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal)
|
||||
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id)))
|
||||
{
|
||||
out.push((at, n.id));
|
||||
}
|
||||
@@ -175,6 +196,12 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether node `id` or a group it is in has opacity or a blending mode. [`Document::ancestry`]
|
||||
/// lists `id` itself last, so its own opacity and blending mode count too.
|
||||
fn transparent(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(|n| n.opacity < 1.0 || n.blend != BlendMode::Normal)
|
||||
}
|
||||
|
||||
/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows
|
||||
/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is
|
||||
/// rendered from `d` as it was, so images over one another flatten as they look.
|
||||
@@ -188,7 +215,8 @@ fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
|
||||
let png = flat.png;
|
||||
let key = super::super::menucmds::unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
|
||||
let image =
|
||||
ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default(), pixels: None };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
|
||||
group.name = src.node(id).and_then(|n| n.name.clone());
|
||||
|
||||
@@ -454,6 +454,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
let st = s.doc_mut()?;
|
||||
if saves_back {
|
||||
st.format = format.id;
|
||||
// An SVG that reopened as the full document saves as one again: Save keeps Preserve
|
||||
// Editing, as it would after a save of this session (#3867).
|
||||
if restored && matches!(format.id, "svg" | "svgz") {
|
||||
st.save_options.insert("preserveEditing".into(), json!(true));
|
||||
}
|
||||
}
|
||||
st.converted = converted;
|
||||
st.imported_from = source_path;
|
||||
@@ -653,8 +658,10 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::scale_non_uniform(sx, sy);
|
||||
let mut n =
|
||||
Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })));
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })),
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
|
||||
@@ -979,6 +979,10 @@ fn may_write(path: &str) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
may_read(path)?;
|
||||
// A device or pipe may never end: only a regular file is read to its end.
|
||||
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: not a file")));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
@@ -1184,7 +1188,7 @@ pub(crate) fn document_path_with(
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve_absolute(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
@@ -1192,9 +1196,6 @@ pub(crate) fn document_path_with(
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
@@ -1366,15 +1367,17 @@ pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, St
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
// The next number to try for each base: a name repeated many times doesn't search the numbers
|
||||
// taken before it.
|
||||
let mut next: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|base| {
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
while !taken.insert(name.to_lowercase()) {
|
||||
let n = next.entry(base.to_lowercase()).or_insert(1);
|
||||
*n += 1;
|
||||
name = format!("{base}-{n}");
|
||||
}
|
||||
name
|
||||
})
|
||||
|
||||
@@ -385,22 +385,27 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<V
|
||||
pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
let mut d = doc.clone();
|
||||
let Some(a) = doc.artboards.get(i) else { return d };
|
||||
let r = a.rect;
|
||||
d.artboards = vec![a.clone()];
|
||||
// It opens fitted to its artboard, not at the master file's view.
|
||||
keep_art_on(&mut d, a.rect);
|
||||
d
|
||||
}
|
||||
|
||||
/// Reduce `d` to the objects touching `r`, in their layers (resources are kept), opening
|
||||
/// fitted to its artboard rather than at the master file's view.
|
||||
pub(super) fn keep_art_on(d: &mut Document, r: vectorcraft_geom::Rect) {
|
||||
d.last_view = None;
|
||||
d.layers_open = None;
|
||||
for layer in &mut d.layers {
|
||||
keep_on(layer, r);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Keep in layer `n` (and its sublayers) only the objects touching `r`.
|
||||
/// Keep in layer `n` (and its sublayers) only the objects touching `r`, and those without bounds.
|
||||
fn keep_on(n: &mut Arc<vectorcraft_doc::Node>, r: vectorcraft_geom::Rect) {
|
||||
let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0;
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches));
|
||||
// An object whose bounds can't be measured (type in a missing font) stays: dropping it loses art.
|
||||
children.retain(|c| c.is_layer() || c.visual_bounds().is_none_or(touches));
|
||||
for c in children.iter_mut().filter(|c| c.is_layer()) {
|
||||
keep_on(c, r);
|
||||
}
|
||||
|
||||
@@ -784,8 +784,21 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
write(all, "pdf", &options)?
|
||||
} else if r.whole_document() && item.artboard.is_some() {
|
||||
let b = item.artboard.unwrap_or(0);
|
||||
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
|
||||
write(&d, r.format.id, &r.options_for(item.region, None)?)?
|
||||
// That artboard alone, as Save's separateArtboards writes it: its art, not the others' (#3868).
|
||||
let d = board_docs
|
||||
.entry(b)
|
||||
.or_insert_with(|| {
|
||||
let mut d = single_artboard(&item.doc, item.region, &item.name);
|
||||
super::save::keep_art_on(&mut d, item.region);
|
||||
Arc::new(d)
|
||||
})
|
||||
.clone();
|
||||
let mut options = r.options_for(item.region, None)?;
|
||||
// An EPS page is the artboard, not its art's bounds: an artboard without art still writes.
|
||||
if r.format.id == "eps" {
|
||||
options["useArtboards"] = json!(true);
|
||||
}
|
||||
write(&d, r.format.id, &options)?
|
||||
} else {
|
||||
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
|
||||
};
|
||||
|
||||
@@ -134,17 +134,25 @@ fn layers_and_the_selection_are_exported_by_name() {
|
||||
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
|
||||
}
|
||||
|
||||
/// Document formats write each artboard as a document of its own: one artboard, its art.
|
||||
/// Document formats write each artboard as a document of its own: one artboard, its art and
|
||||
/// not the other artboards' (#3868).
|
||||
#[test]
|
||||
fn document_formats_write_one_artboard_each() {
|
||||
let mut s = session(2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect;
|
||||
s.execute("shape.ellipse", &json!({"x": second.x0 + 5.0, "y": 5, "width": 10, "height": 10})).unwrap();
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
|
||||
let n = names(&r);
|
||||
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
|
||||
let native = &files(&r)[0].1;
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
|
||||
let all = files(&r);
|
||||
for (name, bytes) in &all {
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
let d = &t.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.len(), 1, "{name}: that artboard alone");
|
||||
let objects: usize = d.layers.iter().filter_map(|l| l.children()).map(|c| c.len()).sum();
|
||||
assert_eq!(objects, 1, "{name}: only its own artboard's art");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -248,6 +248,7 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
xf: vectorcraft_geom::Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
|
||||
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
|
||||
@@ -162,6 +162,48 @@ fn saving_several_artboards_as_plain_svg_warns_they_are_lost() {
|
||||
assert_eq!(artboards(&session(1).execute("document.save", &json!({"format": "svg"})).unwrap()), 0);
|
||||
}
|
||||
|
||||
/// #3867: a Preserve Editing SVG opened from disk and saved with plain Save keeps the full
|
||||
/// document (every artboard), not just the artwork of the first.
|
||||
#[test]
|
||||
fn reopened_preserve_editing_svg_saves_as_the_full_document() {
|
||||
let dir = tmp_dir("reopen");
|
||||
let path = dir.join("keep.svg").to_string_lossy().to_string();
|
||||
session(3).execute("document.save", &json!({"path": path, "svg": {"preserveEditing": true}})).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
let mut t = Session::new();
|
||||
let r = t.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 3, "every artboard survives open and Save");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #3869: small capitals, superscript and subscript, and tab leaders, which SVG text can't draw,
|
||||
/// are written as outlines (as vertical type and ruby are) rather than as plain text at full size;
|
||||
/// type without them stays text.
|
||||
#[test]
|
||||
fn type_svg_text_cant_draw_is_written_as_outlines() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Small caps x2 H2O", "size": 30})).unwrap();
|
||||
let plain = svg(&mut s, json!({}));
|
||||
assert!(plain.contains("<text"), "plain type stays text: {plain}");
|
||||
for style in [json!({"smallCaps": true}), json!({"position": "superscript"}), json!({"position": "subscript"})] {
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({"width": 500, "height": 200})).unwrap();
|
||||
let id = t.execute("text.create", &json!({"x": 50, "y": 100, "text": "Small caps x2 H2O", "size": 30})).unwrap()["id"].clone();
|
||||
let mut p = style.clone();
|
||||
p["id"] = id;
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(5);
|
||||
t.execute("text.setRangeStyle", &p).unwrap();
|
||||
let out = svg(&mut t, json!({}));
|
||||
assert!(!out.contains("<text") && out.contains("<path"), "{style}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_writes_svg_and_remembers_its_options() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -259,6 +259,10 @@ pub(crate) fn start_in(
|
||||
}
|
||||
// The walkers follow no links, so a folder automation may read holds all they read.
|
||||
crate::file_access::check_read(&folder.to_string_lossy())?;
|
||||
// The check above reads a lossy string: under roots, a path it would alter names another folder.
|
||||
if folder.to_str().is_none() && crate::file_access::current().is_some() {
|
||||
return Err("automation path rejected: the folder's name isn't valid text".into());
|
||||
}
|
||||
let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 };
|
||||
let n = pool.reserve(threads.clamp(1, MAX_THREADS));
|
||||
if n == 0 {
|
||||
@@ -279,6 +283,7 @@ pub(crate) fn start_in(
|
||||
walk: Mutex::new(Walk { root: Some(folder), alive: n, found: vec![0; items], ..Walk::default() }),
|
||||
wake: Condvar::new(),
|
||||
pool,
|
||||
scope: crate::file_access::ScopeHandle::capture(),
|
||||
});
|
||||
let mut spawned = 0;
|
||||
for _ in 0..n {
|
||||
@@ -325,6 +330,8 @@ struct Shared {
|
||||
/// Woken when folders are queued, a walker finishes a folder, or the search ends.
|
||||
wake: Condvar,
|
||||
pool: &'static Pool,
|
||||
/// The automation roots in force when the search started, for the folders the walkers add.
|
||||
scope: crate::file_access::ScopeHandle,
|
||||
}
|
||||
|
||||
/// The state walkers change under the lock.
|
||||
@@ -490,7 +497,10 @@ impl Shared {
|
||||
}
|
||||
queue.push((path, depth + 1));
|
||||
}
|
||||
let home = rules.home_below(&listed);
|
||||
// The home folder is added by a rule, not picked: it needs the picked folder's authority.
|
||||
let home = rules
|
||||
.home_below(&listed)
|
||||
.filter(|h| !rules.is_refused(h) && self.scope.clone().run(|| crate::file_access::check_read(&h.to_string_lossy())).is_ok());
|
||||
{
|
||||
let mut w = self.lock();
|
||||
w.files += files;
|
||||
|
||||
@@ -1136,7 +1136,15 @@ fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
d.images.insert(key.clone(), r.blob);
|
||||
let image = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
xf: r.xf,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
let Some(path) = r.clip else { return image };
|
||||
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
|
||||
@@ -1226,13 +1234,14 @@ impl Scratch {
|
||||
NodeKind::Text(_) => outlined_text(n),
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n));
|
||||
art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g))
|
||||
art.and_then(|a| effects::outline_art(Some(&self.doc), n, Some(&a))).map(|g| carry(n, g))
|
||||
}
|
||||
// A placed document stays one: its art's resources aren't the document's.
|
||||
NodeKind::PlacedDocument(_) => None,
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &effects::outline_text;
|
||||
Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook))))
|
||||
let hooks = vectorcraft_doc::live::Hooks { outline: Some(hook), symbols: Some(&self.doc), ..Default::default() };
|
||||
Some(carry(n, vectorcraft_doc::live::expanded_group_hooks(n, hooks)))
|
||||
}
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n),
|
||||
_ => None,
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options.
|
||||
//!
|
||||
//! Behaviour from the public help pages "Edit similar objects"
|
||||
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>)
|
||||
//! and "Update assets quickly with Global Edit"
|
||||
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
|
||||
//!
|
||||
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
|
||||
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
|
||||
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
|
||||
//! every artboard and the canvas around them by default; it can be limited to a range of
|
||||
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
|
||||
//! masks, linked files and selections of more than one object are not supported.
|
||||
//! - While Global Edit is on, an edit made to one of the found objects is made to all of them,
|
||||
//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt
|
||||
//! match to 10 pt. Stop Global Edit (or Escape) ends it.
|
||||
//!
|
||||
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
|
||||
//! object that was selected, so that the Transform panel and the bounding box measure that object
|
||||
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
|
||||
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
|
||||
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
|
||||
//! changed is the model: what changed about it is given to every other member, and nothing else,
|
||||
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
|
||||
//! them:
|
||||
//!
|
||||
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
|
||||
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
|
||||
//! - its opacity, blending, knockout and the object's effects, each when it changed;
|
||||
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
|
||||
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
|
||||
//! same rank (the second fill from the bottom to the second fill), and a member without one is
|
||||
//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a
|
||||
//! stroke was drawn with stays each member's own;
|
||||
//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's
|
||||
//! former order gains the same item at the same place (or loses the one at that place). A
|
||||
//! member with another order, and any other change to the list (items moved, or several added
|
||||
//! or removed at once), takes the model's whole list of fills and strokes.
|
||||
//!
|
||||
//! Members that are hidden or locked at the time are left as they are. An edit that would take any
|
||||
//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that
|
||||
//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag
|
||||
//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global
|
||||
//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made
|
||||
//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with
|
||||
//! similar objects") and excluding a member with a Shift-click are not covered.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
|
||||
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "select.globalEdit";
|
||||
const OPTIONS: &str = "select.globalEditOptions";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"select.globalEdit",
|
||||
"Start Global Edit",
|
||||
["Select"],
|
||||
None,
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
|
||||
can_toggle,
|
||||
toggle
|
||||
),
|
||||
cmd!(
|
||||
"select.globalEditOptions",
|
||||
"Global Edit Options",
|
||||
[],
|
||||
None,
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}",
|
||||
has_doc,
|
||||
set_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Global Edit Options: what a similar object must share with the selected one besides its
|
||||
/// shape, and where it may lie. Kept for the session.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Options {
|
||||
/// Match the fill and stroke too.
|
||||
pub appearance: bool,
|
||||
/// Match the width and height too.
|
||||
pub size: bool,
|
||||
/// The artboards to search (1-based range as typed); `None`: all of them.
|
||||
pub artboards: Option<String>,
|
||||
/// Include the objects that lie on no artboard.
|
||||
pub include_canvas: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { appearance: false, size: false, artboards: None, include_canvas: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// These options with the ones `p` gives, checked against `d`'s artboards.
|
||||
fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result<Self> {
|
||||
let mut o = self.clone();
|
||||
o.appearance = bool_or(p, "appearance", o.appearance);
|
||||
o.size = bool_or(p, "size", o.size);
|
||||
o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas);
|
||||
match p.get("artboards") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None,
|
||||
Some(Value::String(s)) => {
|
||||
vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
o.artboards = Some(s.trim().to_string());
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))),
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn json(&self, count: usize) -> Value {
|
||||
json!({
|
||||
"appearance": self.appearance,
|
||||
"size": self.size,
|
||||
"artboards": self.artboards.as_deref().unwrap_or("all"),
|
||||
"includeCanvas": self.include_canvas,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Outlines with their layer colours ([`highlights`]).
|
||||
pub type Outlines = Vec<(BezPath, [u8; 3])>;
|
||||
|
||||
/// A Global Edit in progress (per document; not saved, not an undo step).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlobalEdit {
|
||||
/// The objects edited together, the one selected at the start first, each with the transform
|
||||
/// (a move and a uniform scale) that maps that first object's outline onto its own.
|
||||
pub members: Vec<(NodeId, Affine)>,
|
||||
/// [`highlights`] as of a document revision: the canvas asks every frame.
|
||||
outlines: std::cell::RefCell<Option<(u64, Outlines)>>,
|
||||
}
|
||||
|
||||
/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells
|
||||
/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed).
|
||||
struct Form {
|
||||
tag: Vec<u64>,
|
||||
symbol: Option<String>,
|
||||
points: Vec<Point>,
|
||||
/// The unit box → the object.
|
||||
frame: Affine,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn of(n: &Node) -> Option<Self> {
|
||||
let mut tag = vec![];
|
||||
let mut points = vec![];
|
||||
let mut symbol = None;
|
||||
let mut push = |path: &PathData, tag: &mut Vec<u64>| {
|
||||
tag.push(path.subpaths.len() as u64);
|
||||
for sp in &path.subpaths {
|
||||
tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]);
|
||||
points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]));
|
||||
}
|
||||
};
|
||||
let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd);
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => {
|
||||
tag.extend([0, rule(*r)]);
|
||||
push(path, &mut tag);
|
||||
}
|
||||
NodeKind::Compound { children, rule: r } => {
|
||||
tag.extend([1, rule(*r), children.len() as u64]);
|
||||
for c in children {
|
||||
push(c.path_data()?, &mut tag);
|
||||
}
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol: s, xf } => {
|
||||
tag.push(2);
|
||||
symbol = Some(s.clone());
|
||||
points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
let first = *points.first()?;
|
||||
let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
|
||||
let side = b.width().max(b.height());
|
||||
if !(side.is_finite() && side > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side);
|
||||
let back = frame.inverse();
|
||||
points.iter_mut().for_each(|p| *p = back * *p);
|
||||
Some(Self { tag, symbol, points, frame })
|
||||
}
|
||||
|
||||
fn same(&self, o: &Form) -> bool {
|
||||
self.tag == o.tag
|
||||
&& self.symbol == o.symbol
|
||||
&& self.points.len() == o.points.len()
|
||||
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked
|
||||
/// files, objects with an opacity mask, clipping paths or guides (the help page lists images,
|
||||
/// text, masks and links as unsupported).
|
||||
fn supported(n: &Node) -> bool {
|
||||
n.extras().mask.is_none() && Form::of(n).is_some()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0)
|
||||
}
|
||||
|
||||
/// The members a Global Edit started on the one selected object would take, with `o`: that
|
||||
/// object first, then the others in paint order.
|
||||
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
|
||||
let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
let form = match Form::of(key) {
|
||||
Some(f) if supported(key) => f,
|
||||
_ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")),
|
||||
};
|
||||
let symbols = d.symbol_boxes();
|
||||
let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height()));
|
||||
let key_size = size(key);
|
||||
let (fill, stroke) = (fill_of(key), stroke_of(key));
|
||||
let boards: Option<Vec<Rect>> = match &o.artboards {
|
||||
None => None,
|
||||
Some(r) => Some(
|
||||
vectorcraft_doc::range::parse_range(r, d.artboards.len())
|
||||
.map_err(|e| bad(C, e))?
|
||||
.into_iter()
|
||||
.filter_map(|i| d.artboards.get(i).map(|a| a.rect))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center());
|
||||
let in_scope = |n: &Node| {
|
||||
let Some(b) = n.geometric_bounds_with(&symbols) else { return false };
|
||||
if !d.artboards.iter().any(|a| on(a.rect, b)) {
|
||||
return o.include_canvas;
|
||||
}
|
||||
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
|
||||
};
|
||||
let mut out = vec![(*id, Affine::IDENTITY)];
|
||||
let back = form.frame.inverse();
|
||||
super::select::walk_selectable(d, st.isolation, |n| {
|
||||
if n.id == *id {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
// Look into layers and plain groups (clip groups too: their art, not their clipping path).
|
||||
NodeKind::Layer { .. } => return true,
|
||||
NodeKind::Group { .. } => {
|
||||
let x = n.extras();
|
||||
return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
|
||||
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
|
||||
&& (!o.appearance || {
|
||||
let (f2, s2) = (fill_of(n), stroke_of(n));
|
||||
super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1)
|
||||
})
|
||||
&& in_scope(n)
|
||||
{
|
||||
out.push((n.id, f.frame * back));
|
||||
}
|
||||
// Compound paths, symbol instances and the other objects are taken whole.
|
||||
false
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.global_edit.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
_ => Err("Global Edit works on paths, compound paths and symbol instances".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.global_edit.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
st.global_edit = None;
|
||||
st.revision += 1;
|
||||
return Ok(json!({ "active": false }));
|
||||
}
|
||||
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
|
||||
let members = matches(s, &o)?;
|
||||
if members.len() < 2 {
|
||||
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
|
||||
}
|
||||
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
|
||||
let st = s.doc_mut()?;
|
||||
st.global_edit = Some(GlobalEdit { members, outlines: Default::default() });
|
||||
st.revision += 1;
|
||||
Ok(json!({ "active": true, "count": ids.len(), "ids": ids }))
|
||||
}
|
||||
|
||||
fn set_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?;
|
||||
s.global_edit_options = o.clone();
|
||||
Ok(o.json(match_count(s)))
|
||||
}
|
||||
|
||||
/// How many objects Start Global Edit would take now with the session's options (the Result(s)
|
||||
/// line of the options): 0 unless one supported object is selected.
|
||||
pub fn match_count(s: &Session) -> usize {
|
||||
match s.active() {
|
||||
Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()),
|
||||
Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `id` there, shown and unlocked, with every layer and group it is in?
|
||||
fn editable(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())))
|
||||
}
|
||||
|
||||
/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction
|
||||
/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the
|
||||
/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later
|
||||
/// under a freed id is never edited; with fewer than two left, Global Edit ends.
|
||||
pub(crate) fn follow_history(st: &mut crate::DocState) {
|
||||
let Some(ge) = &mut st.global_edit else { return };
|
||||
let d = &st.doc;
|
||||
ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported));
|
||||
if ge.members.len() < 2 {
|
||||
st.global_edit = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// What an edit changed about a member: what [`propagate`] gives the others.
|
||||
#[derive(Default)]
|
||||
struct Changed {
|
||||
outline: bool,
|
||||
/// The member's appearance before and after the edit, when it changed.
|
||||
appearance: Option<[Shared<Appearance>; 2]>,
|
||||
opacity: bool,
|
||||
blend: bool,
|
||||
isolate: bool,
|
||||
knockout: bool,
|
||||
knockout_shape: bool,
|
||||
}
|
||||
|
||||
impl Changed {
|
||||
fn between(a: &Node, b: &Node) -> Self {
|
||||
Self {
|
||||
outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle,
|
||||
appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]),
|
||||
opacity: a.opacity != b.opacity,
|
||||
blend: a.blend != b.blend,
|
||||
isolate: a.isolate != b.isolate,
|
||||
knockout: a.knockout != b.knockout,
|
||||
knockout_shape: a.knockout_shape != b.knockout_shape,
|
||||
}
|
||||
}
|
||||
|
||||
fn any(&self) -> bool {
|
||||
self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape
|
||||
}
|
||||
|
||||
/// Give `n` what changed, from `model` (the changed member placed on `n`).
|
||||
fn apply(&self, model: Node, n: &mut Node) {
|
||||
if self.outline {
|
||||
n.kind = model.kind;
|
||||
n.bbox_angle = model.bbox_angle;
|
||||
}
|
||||
if let Some([a, b]) = &self.appearance {
|
||||
give_appearance(a, b, &model.appearance, &mut n.appearance);
|
||||
}
|
||||
if self.opacity {
|
||||
n.opacity = model.opacity;
|
||||
}
|
||||
if self.blend {
|
||||
n.blend = model.blend;
|
||||
}
|
||||
if self.isolate {
|
||||
n.isolate = model.isolate;
|
||||
}
|
||||
if self.knockout {
|
||||
n.knockout = model.knockout;
|
||||
}
|
||||
if self.knockout_shape {
|
||||
n.knockout_shape = model.knockout_shape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name.
|
||||
macro_rules! give_fields {
|
||||
($a:ident, $b:ident, $n:ident: $($f:ident),+) => {
|
||||
$(
|
||||
if $a.$f != $b.$f {
|
||||
$n.$f = Clone::clone($f);
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after),
|
||||
/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules.
|
||||
fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared<Appearance>) {
|
||||
let Appearance { items, effects, contents_index } = m;
|
||||
if a.effects != b.effects || a.contents_index != b.contents_index {
|
||||
let n = &mut **n;
|
||||
give_fields!(a, b, n: effects, contents_index);
|
||||
}
|
||||
if a.items == b.items {
|
||||
return;
|
||||
}
|
||||
let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::<Vec<_>>();
|
||||
if kinds(&a.items) == kinds(&b.items) {
|
||||
// Fills and strokes kept in order: each changed one goes to the member's of its kind and
|
||||
// rank.
|
||||
for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() {
|
||||
if p == q {
|
||||
continue;
|
||||
}
|
||||
let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count();
|
||||
if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) {
|
||||
give_item(p, q, mq, t);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// One item added (`grew`) or removed at `at`, the others kept in order; the member must hold
|
||||
// the model's former kinds in order.
|
||||
let one = |long: &[AppearanceItem], short: &[AppearanceItem]| {
|
||||
if long.len() != short.len() + 1 {
|
||||
return None;
|
||||
}
|
||||
let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len());
|
||||
let mut rest = kinds(long);
|
||||
rest.remove(at);
|
||||
(rest == kinds(short)).then_some(at)
|
||||
};
|
||||
let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) {
|
||||
(Some(at), _) => Some((at, true)),
|
||||
(None, Some(at)) => Some((at, false)),
|
||||
_ => None,
|
||||
};
|
||||
match step {
|
||||
Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => {
|
||||
let n = &mut n.items;
|
||||
for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() {
|
||||
// The model's item after the edit that was `p` before it.
|
||||
let j = match (grew, i < at) {
|
||||
(_, true) => i,
|
||||
(true, false) => i + 1,
|
||||
(false, false) if i == at => continue,
|
||||
(false, false) => i - 1,
|
||||
};
|
||||
if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j))
|
||||
&& p != q
|
||||
{
|
||||
give_item(p, q, mq, t);
|
||||
}
|
||||
}
|
||||
match (grew, items.get(at)) {
|
||||
(true, Some(new)) => n.insert(at.min(n.len()), new.clone()),
|
||||
(false, _) if at < n.len() => {
|
||||
n.remove(at);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => n.items = items.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`,
|
||||
/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own.
|
||||
fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) {
|
||||
use AppearanceItem::{Fill, Stroke};
|
||||
match (a, b, m, n) {
|
||||
(Fill(a), Fill(b), Fill(m), Fill(n)) => {
|
||||
let FillLayer { paint, opacity, blend, visible, effects, overprint } = m;
|
||||
give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint);
|
||||
}
|
||||
(Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => {
|
||||
let StrokeLayer {
|
||||
paint,
|
||||
width,
|
||||
cap,
|
||||
join,
|
||||
miter_limit,
|
||||
align,
|
||||
dash,
|
||||
start_arrow,
|
||||
end_arrow,
|
||||
arrow_scale,
|
||||
profile,
|
||||
brush,
|
||||
opacity,
|
||||
blend,
|
||||
visible,
|
||||
effects,
|
||||
arrow_align,
|
||||
overprint,
|
||||
gradient_mode,
|
||||
pressure: _,
|
||||
} = m;
|
||||
give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the children of a copy placed on another object ids of that object's: its own children's
|
||||
/// in order, new ids for any more and for everything deeper.
|
||||
fn renumber(children: &mut [std::sync::Arc<Node>], own: &[NodeId], d: &mut Document) {
|
||||
for (i, c) in children.iter_mut().enumerate() {
|
||||
let c = std::sync::Arc::make_mut(c);
|
||||
c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id());
|
||||
if let Some(ch) = c.children_mut() {
|
||||
renumber(ch, &[], d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before
|
||||
/// or after the edit that it changed is the model, and every other member that is shown and
|
||||
/// unlocked gets what changed about it, placed by the members' transforms. → the members it
|
||||
/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection).
|
||||
pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec<NodeId> {
|
||||
let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| {
|
||||
let (a, b) = (before.node(*id)?, d.node(*id)?);
|
||||
if std::ptr::eq(a, b) {
|
||||
return None;
|
||||
}
|
||||
let changed = Changed::between(a, b);
|
||||
changed.any().then(|| (*id, *xf, changed, b.clone()))
|
||||
});
|
||||
let Some((model_id, model_xf, changed, model)) = model else { return vec![] };
|
||||
let back = model_xf.inverse();
|
||||
let mut edited = vec![];
|
||||
for (id, xf) in &ge.members {
|
||||
if *id == model_id || !editable(d, *id) {
|
||||
continue;
|
||||
}
|
||||
let mut placed = model.clone();
|
||||
placed.transform(*xf * back, Scaling::default());
|
||||
if changed.outline {
|
||||
let own: Vec<NodeId> = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
if let Some(ch) = placed.children_mut() {
|
||||
renumber(ch, &own, d);
|
||||
}
|
||||
}
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
changed.apply(placed, n);
|
||||
edited.push(*id);
|
||||
}
|
||||
}
|
||||
edited
|
||||
}
|
||||
|
||||
/// The outlines of the members of the active document's Global Edit that are not selected, in
|
||||
/// their layer's colour: the canvas highlights them while Global Edit is on.
|
||||
pub fn highlights(s: &Session) -> Outlines {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let Some(ge) = &st.global_edit else { return vec![] };
|
||||
if let Some((rev, out)) = &*ge.outlines.borrow()
|
||||
&& *rev == st.revision
|
||||
{
|
||||
return out.clone();
|
||||
}
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let out: Vec<_> = ge
|
||||
.members
|
||||
.iter()
|
||||
.filter(|(id, _)| !st.selection.contains(*id))
|
||||
.filter_map(|(id, _)| {
|
||||
let n = st.doc.node(*id)?;
|
||||
let path = match n.stroke_path() {
|
||||
Some(p) => p,
|
||||
None => n.geometric_bounds_with(&symbols)?.to_path(0.1),
|
||||
};
|
||||
Some((path, st.doc.layer_color(*id)))
|
||||
})
|
||||
.collect();
|
||||
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
|
||||
out
|
||||
}
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool (as artboard.setProps takes them; default false)} (default place: right of the last)",
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)",
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center; both are screen-only, as the artboard's edges are: exports and print leave them out), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
"{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -813,19 +813,44 @@ fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_colo
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within
|
||||
/// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`].
|
||||
fn video_pixel_aspect(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get("videoRulerPixelAspectRatio") {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|r| vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE.contains(r))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, "videoRulerPixelAspectRatio must be a number from 0.1 to 10")),
|
||||
}
|
||||
}
|
||||
|
||||
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = &s.doc()?.doc;
|
||||
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
|
||||
let r = rect_from(p, def);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
check_switches(p, &["showCenterMark", "showCrossHairs"], "artboard.new")?;
|
||||
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
|
||||
let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false));
|
||||
let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false);
|
||||
let video_ruler_pixel_aspect_ratio = video_pixel_aspect(p, "artboard.new")?.unwrap_or(1.0);
|
||||
let idx = s.edit("New Artboard", |d, _| {
|
||||
let id = d.next_artboard_id();
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
|
||||
d.artboards.push(Artboard { id, name, rect: r, locked: false, show_center_mark, show_cross_hairs, color });
|
||||
d.artboards.push(Artboard {
|
||||
id,
|
||||
name,
|
||||
rect: r,
|
||||
locked: false,
|
||||
show_center_mark,
|
||||
show_cross_hairs,
|
||||
show_video_safe_areas,
|
||||
video_ruler_pixel_aspect_ratio,
|
||||
color,
|
||||
});
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": idx }))
|
||||
@@ -848,9 +873,10 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock and
|
||||
/// display marks (Show Center Mark, Show Cross Hairs), how far it moved, whether it was resized,
|
||||
/// the map its art and guides take when they go with it (`fit` when it scales them), and that art.
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock,
|
||||
/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel
|
||||
/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when
|
||||
/// they go with it (`fit` when it scales them), and that art.
|
||||
struct ArtboardEdit<'a> {
|
||||
index: usize,
|
||||
now: Rect,
|
||||
@@ -859,6 +885,8 @@ struct ArtboardEdit<'a> {
|
||||
lock: Option<bool>,
|
||||
center_mark: Option<bool>,
|
||||
cross_hairs: Option<bool>,
|
||||
safe_areas: Option<bool>,
|
||||
pixel_aspect: Option<f64>,
|
||||
delta: vectorcraft_geom::Vec2,
|
||||
resized: bool,
|
||||
fit: Option<Affine>,
|
||||
@@ -876,7 +904,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
|
||||
for q in &entries {
|
||||
check_switches(q, &["locked", "showCenterMark", "showCrossHairs"], "artboard.setProps")?;
|
||||
check_switches(q, &["locked", "showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.setProps")?;
|
||||
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
|
||||
}
|
||||
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
||||
@@ -900,6 +928,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let now = rect_from(q, was);
|
||||
let lock = q.get("locked").and_then(Value::as_bool);
|
||||
let (center_mark, cross_hairs) = (q.get("showCenterMark").and_then(Value::as_bool), q.get("showCrossHairs").and_then(Value::as_bool));
|
||||
let (safe_areas, pixel_aspect) = (q.get("showVideoSafeAreas").and_then(Value::as_bool), video_pixel_aspect(q, "artboard.setProps")?);
|
||||
// A locked artboard keeps its rectangle unless this unlocks it; its name can change.
|
||||
let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6);
|
||||
if reshaped && lock != Some(false) {
|
||||
@@ -925,6 +954,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lock,
|
||||
center_mark,
|
||||
cross_hairs,
|
||||
safe_areas,
|
||||
pixel_aspect,
|
||||
delta,
|
||||
resized,
|
||||
fit,
|
||||
@@ -961,6 +992,12 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(c) = e.cross_hairs {
|
||||
a.show_cross_hairs = c;
|
||||
}
|
||||
if let Some(v) = e.safe_areas {
|
||||
a.show_video_safe_areas = v;
|
||||
}
|
||||
if let Some(r) = e.pixel_aspect {
|
||||
a.video_ruler_pixel_aspect_ratio = r;
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = e.transform {
|
||||
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
|
||||
|
||||
@@ -182,6 +182,8 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = vec![];
|
||||
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
|
||||
// Looked up for every linked node: a set, not the list (which may repeat an id many times).
|
||||
let wanted: Option<std::collections::HashSet<NodeId>> = ids.map(|ids| ids.iter().copied().collect());
|
||||
let mut add = |link: &LinkInfo, key: &str, document: Option<Shown>, id: Option<NodeId>| {
|
||||
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
|
||||
@@ -193,7 +195,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
};
|
||||
d.walk(|n| {
|
||||
if let Some((l, key, document)) = linked(n)
|
||||
&& ids.is_none_or(|ids| ids.contains(&n.id))
|
||||
&& wanted.as_ref().is_none_or(|ids| ids.contains(&n.id))
|
||||
{
|
||||
add(l, key, document, Some(n.id));
|
||||
}
|
||||
|
||||
@@ -1334,6 +1334,8 @@ fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n);
|
||||
fix_ids(d, &mut g);
|
||||
// Outlined type's emoji take their pixels into the document.
|
||||
d.adopt_pixels(&mut g);
|
||||
// Outlined type repeats its object's id on its pieces.
|
||||
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
|
||||
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
|
||||
|
||||
@@ -469,7 +469,7 @@ impl Rasterized {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
|
||||
let id = d.alloc_id();
|
||||
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() };
|
||||
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None };
|
||||
let node = Node::new(id, NodeKind::Image(Box::new(image)));
|
||||
match self.clip {
|
||||
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
|
||||
@@ -721,6 +721,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
@@ -788,6 +789,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
@@ -975,6 +977,8 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
});
|
||||
converted.push(id);
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod flatten;
|
||||
pub mod fontfiles;
|
||||
mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub mod globaledit;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
@@ -79,6 +80,7 @@ mod style;
|
||||
pub mod stylelib;
|
||||
mod swatch;
|
||||
pub mod swatchlib;
|
||||
pub mod symbollib;
|
||||
pub(crate) mod tabs;
|
||||
mod textedit;
|
||||
pub mod textstyles;
|
||||
@@ -214,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(object::specs());
|
||||
v.extend(path::specs());
|
||||
v.extend(select::specs());
|
||||
v.extend(globaledit::specs());
|
||||
v.extend(wand::specs());
|
||||
v.extend(paint::specs());
|
||||
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
|
||||
@@ -267,6 +270,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(freeform::specs());
|
||||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(symbollib::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
|
||||
@@ -341,6 +341,8 @@ impl DocSettings {
|
||||
locked: false,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
})
|
||||
.collect();
|
||||
@@ -515,6 +517,7 @@ pub const CATEGORIES: &[Category] = &[
|
||||
("Logo 1000×1000", 1000.0, 1000.0, PX),
|
||||
("Business Card 3.5 × 2 in", 3.5, 2.0, IN),
|
||||
("Business Card 85 × 55 mm", 85.0, 55.0, MM),
|
||||
("Business Card 91 × 55 mm", 91.0, 55.0, MM),
|
||||
("Letterhead Letter", 8.5, 11.0, IN),
|
||||
("Letterhead A4", 210.0, 297.0, MM),
|
||||
("Envelope DL", 220.0, 110.0, MM),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user