Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ba987ee7a | ||
|
|
3198abe2fd | ||
|
|
1c19c27fef | ||
|
|
b9b880a5de | ||
|
|
60831b87d9 | ||
|
|
800baacbba | ||
|
|
11aaf816d0 | ||
|
|
2d371849df | ||
|
|
d28cbacbf7 | ||
|
|
2762f61e83 | ||
|
|
94f27299d4 | ||
|
|
6fed61b4db | ||
|
|
09a0ff23ad | ||
|
|
1090385507 | ||
|
|
11cf254a07 | ||
|
|
7b7e10e82e | ||
|
|
5709e636a5 | ||
|
|
796accffc0 | ||
|
|
ef574b6319 | ||
|
|
ec32a0b537 | ||
|
|
8b95f79f3b | ||
|
|
b6625f6964 | ||
|
|
8edee45588 | ||
|
|
d38a2fa023 | ||
|
|
c2339fca81 | ||
|
|
1d0305fc2a | ||
|
|
f2ba41d1c1 | ||
|
|
57932f1204 | ||
|
|
537fbece16 | ||
|
|
866ea5878a | ||
|
|
4f0809953d | ||
|
|
c599522061 | ||
|
|
f203ae6334 | ||
|
|
569b4ac240 | ||
|
|
2c1a1526df | ||
|
|
5b27fdc321 | ||
|
|
338ed529e4 | ||
|
|
b2b3b4fe6a | ||
|
|
77a3cef4d1 | ||
|
|
72c9cec0e0 | ||
|
|
4181755db6 | ||
|
|
c93d4ad995 | ||
|
|
975d4975ee | ||
|
|
4cf912fa94 | ||
|
|
78f0297127 | ||
|
|
d857d2c0b7 | ||
|
|
9df597a46d | ||
|
|
4c175ed099 | ||
|
|
a262ee5c9e | ||
|
|
986b83280f | ||
|
|
445bceb681 | ||
|
|
4f9b292213 | ||
|
|
c9ba5db4fb | ||
|
|
562e09707a | ||
|
|
b43e633329 | ||
|
|
8c1be41e08 | ||
|
|
cbacb47fdb | ||
|
|
116573ba86 | ||
|
|
783540a34a | ||
|
|
b4784c02ee | ||
|
|
6f260fac3e | ||
|
|
7d75280ab4 | ||
|
|
c8642eeddb | ||
|
|
0b326c48bc | ||
|
|
35a6015d88 | ||
|
|
71ea5c4215 | ||
|
|
3e1d9b7bbb | ||
|
|
cee9feea2e | ||
|
|
d522c1d7be | ||
|
|
01f329aa99 | ||
|
|
f300321e28 | ||
|
|
0b14b45610 | ||
|
|
0928cafab2 | ||
|
|
636d9b24c8 | ||
|
|
04439bb01d | ||
|
|
21355945ea | ||
|
|
bd8fa795ed | ||
|
|
825d4fdb8b | ||
|
|
4b44265965 | ||
|
|
9de418a4a4 | ||
|
|
8e0e2eb904 | ||
|
|
8124cf7b43 | ||
|
|
f9384dfed7 | ||
|
|
71484fd013 | ||
|
|
7c3687af29 | ||
|
|
ba1b7a834f | ||
|
|
bbb465a0d3 | ||
|
|
f549f6ad4d | ||
|
|
a9e3d85b1d | ||
|
|
a4a040653d | ||
|
|
75f2145b92 | ||
|
|
deff7bb757 | ||
|
|
022069d5a5 | ||
|
|
1f668dfaad | ||
|
|
d168d4f843 | ||
|
|
816a0fe762 | ||
|
|
4fe06391d4 | ||
|
|
ae68704dfc | ||
|
|
eacef005b0 | ||
|
|
c461369421 | ||
|
|
9ff0277306 | ||
|
|
573f27e6c9 | ||
|
|
4b00c2d968 | ||
|
|
a61d058f1b | ||
|
|
0a91fd9b44 | ||
|
|
75151bd79a | ||
|
|
be9a879e51 | ||
|
|
16dcbc6138 | ||
|
|
7dd69aaf81 | ||
|
|
d35db792dc | ||
|
|
806ba127e7 | ||
|
|
3e20c2cf29 | ||
|
|
e1d35131d3 | ||
|
|
aa548d2bef | ||
|
|
79c5d673ff | ||
|
|
f837c9305a | ||
|
|
f95e320907 | ||
|
|
1c692e5191 | ||
|
|
a89426c2f6 | ||
|
|
7255ac394d | ||
|
|
3c85f3f9e3 | ||
|
|
c2711df30b | ||
|
|
f86618777e | ||
|
|
14d8efcabf | ||
|
|
8a19490ea0 | ||
|
|
0e6ca8f88d | ||
|
|
e8255f2bd5 | ||
|
|
dd1a32bb8e | ||
|
|
257c8ec435 | ||
|
|
ad8458832d | ||
|
|
a01b0dfc7a | ||
|
|
ddd1c8fade | ||
|
|
eefe849e14 | ||
|
|
00344f9b68 | ||
|
|
b8a61e90ce | ||
|
|
f2b35fc039 | ||
|
|
133ad87541 | ||
|
|
f8a7695c3a | ||
|
|
66cb7848f3 | ||
|
|
ee16d10fd4 | ||
|
|
82af7a3108 | ||
|
|
46e4262376 | ||
|
|
c16804237e | ||
|
|
4b9f9b9bc2 | ||
|
|
008b5e6d6c | ||
|
|
640a46a76f | ||
|
|
2d12b39261 | ||
|
|
d53074487c |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
name: Affinity 3 corpus
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: affinity-corpus-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
affinity3:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
AFFINITY_CORPUS_REQUIRED: "1"
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.95"
|
||||
- name: Fetch pinned and checksum-verified corpus
|
||||
run: cargo xtask corpus --affinity
|
||||
- name: Parse every file and freeze actual Affinity 3 feature retention
|
||||
run: cargo test -p vectorcraft-affinity --all-features -- --nocapture
|
||||
- name: Check every artboard, native save, SVG, PDF and PSD exports
|
||||
run: cargo test -p vectorcraft-engine --test affinity_artboards --test affinity_text --test affinity_corpus -- --nocapture
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
|
||||
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -67,10 +67,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -130,10 +130,10 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -150,7 +150,9 @@ jobs:
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -165,7 +167,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -189,10 +191,10 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -234,7 +236,7 @@ jobs:
|
||||
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -257,8 +259,8 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -272,7 +274,7 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -290,10 +292,10 @@ jobs:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -326,7 +328,7 @@ jobs:
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
@@ -342,10 +344,10 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
@@ -369,7 +371,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
@@ -390,7 +392,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
@@ -45,13 +45,15 @@ jobs:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -64,7 +66,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
|
||||
with:
|
||||
arch: x64
|
||||
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
|
||||
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
|
||||
with:
|
||||
toolchain: nightly-2026-10-01
|
||||
components: rust-src
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Build, check and test the portable package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/windows7.ps1 -Test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vectorcraft-windows7-x64-portable
|
||||
path: dist/release/vectorcraft-windows7-x64-portable.zip
|
||||
|
||||
Generated
+48
-26
@@ -2553,6 +2553,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4311,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4323,6 +4332,8 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -4333,21 +4344,23 @@ dependencies = [
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"proptest",
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4359,7 +4372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4375,9 +4388,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -4394,7 +4409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4406,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4423,7 +4438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4437,7 +4452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4472,7 +4487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4491,7 +4506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4505,7 +4520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4517,7 +4532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4536,7 +4551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4550,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4562,7 +4577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4591,7 +4606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4605,7 +4620,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4630,7 +4645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4654,7 +4669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4671,7 +4686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4689,7 +4704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4704,7 +4719,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4717,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4746,7 +4761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4755,6 +4770,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -5729,6 +5745,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@@ -5849,7 +5871,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -169,6 +169,11 @@ Graphics Processor** and restart. If a graphics processor can't show the window,
|
||||
next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low`), or `WGPU_ADAPTER_NAME` to part of
|
||||
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
|
||||
to place artwork in the current document, copy and paste the file in a file manager; or run
|
||||
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
@@ -190,7 +195,7 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
**Where we are (2026-10-09):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
|
||||
@@ -198,9 +203,11 @@ Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their c
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||
doesn't write Affinity files. [Scope and limits](crates/affinity/README.md).
|
||||
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
|
||||
+32
-30
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -38,7 +38,7 @@ since 2026-10-01.
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
@@ -53,11 +53,11 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort,
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 51 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place
|
||||
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
|
||||
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
|
||||
(Normal mode only) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and
|
||||
(Normal mode only), Sharpen › Unsharp Mask and Stylize › Glowing Edges; each new filter is now one function, a catalogue row, menu and
|
||||
translations. Parallelizes well across agents. 26–40 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
@@ -74,8 +74,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
|
||||
(the native reader is checked against 22 public files' own thumbnails; synthetic one-feature oracles saved by
|
||||
Affinity itself are still missing), idle-machine perf budgets, Windows, Linux and
|
||||
(51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents,
|
||||
complex typography and independently paired `.af`/PSD renders reopened in Affinity remain missing), idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
|
||||
|
||||
### Where we're going
|
||||
@@ -107,14 +107,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). About 20 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). About 19 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
|
||||
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
|
||||
@@ -125,33 +125,35 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
@@ -161,14 +163,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.3): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. 22 public CC0/MIT/Apache-2.0 files, pinned and sha256-verified (`cargo xtask corpus --affinity`), must render within a few levels of the thumbnails Affinity saved in them; [scope, provenance and limits](crates/affinity/README.md).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
@@ -181,10 +183,10 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
@@ -217,21 +219,21 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394) | 2–4 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (area type and type on a path, appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740) | 2–4 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 5–8 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 19 Preferences options stored but not yet acted on (#394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
@@ -250,7 +252,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
|
||||
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
|
||||
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ log = { workspace = true }
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
|
||||
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
|
||||
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -9,8 +9,8 @@
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! ```
|
||||
// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows
|
||||
// it for its COM calls, and nothing else may.
|
||||
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
||||
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
use std::io::Write;
|
||||
@@ -36,7 +36,14 @@ macro_rules! out {
|
||||
}};
|
||||
}
|
||||
|
||||
/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../../vectorcraft/src/mac_fonts.rs"]
|
||||
mod mac_fonts;
|
||||
mod perf;
|
||||
/// The desktop app's settings folder, shared: exports and MCP read the same Fonts folder.
|
||||
#[path = "../../vectorcraft/src/prefs_dir.rs"]
|
||||
mod prefs_dir;
|
||||
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[path = "../../vectorcraft/src/system_fonts.rs"]
|
||||
@@ -104,6 +111,9 @@ fn main() -> ExitCode {
|
||||
// Before any font lookup: the fonts font services load (#579).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
system_fonts::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
prefs_dir::install_fonts();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
|
||||
@@ -17,6 +17,7 @@ path = "src/main.rs"
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -27,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -8,3 +8,11 @@ filehash = true
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8766
|
||||
|
||||
# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/
|
||||
# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so
|
||||
# it needs no shell (xtask/src/web_fonts.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-fonts"]
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
|
||||
//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup`
|
||||
//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks
|
||||
//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped:
|
||||
//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// Per startup face: the page shows its loading text meanwhile.
|
||||
const STARTUP_TIMEOUT_MS: i32 = 20_000;
|
||||
/// Per background face.
|
||||
const BACKGROUND_TIMEOUT_MS: i32 = 120_000;
|
||||
/// Background fetches at once, leaving the connection to the app's own requests.
|
||||
const BACKGROUND_PARALLEL: usize = 4;
|
||||
|
||||
thread_local! {
|
||||
/// The background fetch runs once per page, though a graphics restart creates the app again.
|
||||
static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Fetch and register the `startup` faces (one after the other). Returns how many loaded.
|
||||
pub async fn load_startup_fonts() -> usize {
|
||||
let mut loaded = 0;
|
||||
for f in WEB_FONTS.iter().filter(|f| f.startup) {
|
||||
if load(f, STARTUP_TIMEOUT_MS).await {
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for
|
||||
/// a frame after each so the font menus and the canvas pick it up. Only the first call does
|
||||
/// anything.
|
||||
pub fn spawn_background_fonts(ctx: egui::Context) {
|
||||
if BACKGROUND_STARTED.with(|s| s.replace(true)) {
|
||||
return;
|
||||
}
|
||||
let queue: Rc<Vec<&'static WebFont>> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect());
|
||||
let next = Rc::new(Cell::new(0usize));
|
||||
for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) {
|
||||
let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
let i = next.get();
|
||||
next.set(i + 1);
|
||||
let Some(f) = queue.get(i) else { break };
|
||||
if load(f, BACKGROUND_TIMEOUT_MS).await {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one face and add it to the font database. False (and a console warning) on failure.
|
||||
async fn load(f: &WebFont, timeout_ms: i32) -> bool {
|
||||
let bytes = match fetch(f.url, f.integrity, timeout_ms).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("web font {} {}: {e}", f.family, f.style);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if FontDb::global().add_font(bytes) == 0 {
|
||||
log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after
|
||||
/// `timeout_ms`.
|
||||
async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let abort = web_sys::AbortController::new().map_err(js)?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("GET");
|
||||
init.set_integrity(integrity);
|
||||
init.set_signal(Some(&abort.signal()));
|
||||
let on_timeout = {
|
||||
let abort = abort.clone();
|
||||
Closure::<dyn FnMut()>::new(move || abort.abort())
|
||||
};
|
||||
let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?;
|
||||
let result = async {
|
||||
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
|
||||
if !response.ok() {
|
||||
return Err(format!("HTTP {} for {url}", response.status()));
|
||||
}
|
||||
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
|
||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||
}
|
||||
.await;
|
||||
window.clear_timeout_with_handle(timer);
|
||||
// Kept alive until the timer can no longer fire.
|
||||
drop(on_timeout);
|
||||
result
|
||||
}
|
||||
|
||||
fn js(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -64,6 +64,9 @@ pub fn start() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Before the first frame, which looks for copies a crash left behind.
|
||||
let locks = WebLocks::start(RECOVERY_PREFIX).await;
|
||||
// Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text
|
||||
// shows; the rest load in the background once the app runs.
|
||||
crate::fonts::load_startup_fonts().await;
|
||||
let host = Host {
|
||||
runner: eframe::WebRunner::new(),
|
||||
inbox: Arc::default(),
|
||||
@@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
web_options(),
|
||||
Box::new(move |cc| {
|
||||
let ctx = &cc.egui_ctx;
|
||||
crate::fonts::spawn_background_fonts(ctx.clone());
|
||||
let loss = GraphicsLoss::default();
|
||||
watch_canvas(&page, &loss, ctx);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
|
||||
@@ -11,7 +11,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = ["wgpu", "accessibility"]
|
||||
wgpu = ["eframe/wgpu"]
|
||||
wgpu = ["eframe/wgpu", "dep:winsafe"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
# Build with --no-default-features --features windows7 and the win7 Rust target.
|
||||
windows7 = ["eframe/glow"]
|
||||
@@ -36,6 +36,10 @@ winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
|
||||
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
|
||||
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
|
||||
|
||||
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
|
||||
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
|
||||
@@ -51,6 +55,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
|
||||
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
|
||||
# objc2-foundation.
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
+413
-28
@@ -4,10 +4,21 @@
|
||||
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
|
||||
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
|
||||
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
|
||||
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference
|
||||
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU that drives a
|
||||
//! display and the one that drives the primary display ([`display_gpus`]), the power preference
|
||||
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
|
||||
//! eframe is handed [`selector`], which takes the first of that order.
|
||||
//!
|
||||
//! A GPU without a monitor is the wrong one even when it can present: on a desktop whose monitors
|
||||
//! all hang off the discrete GPU, power saving picked the Ryzen's integrated GPU, Windows had to
|
||||
//! present every frame across adapters, and the integrated GPU's driver reset under that, which
|
||||
//! lost the graphics device, or took the desktop's compositor and every monitor down for minutes
|
||||
//! (pdfcraft#378). So, when Preferences say Automatic, the GPU that drives the display the user
|
||||
//! looks at comes first, and the power preference only orders the rest. Windows says which adapter
|
||||
//! drives which display (`EnumDisplayDevices`), Linux through sysfs; elsewhere, or when nothing
|
||||
//! matches, the order is the power preference's as before. Power Saving and High Performance,
|
||||
//! chosen by the user, order by kind as they always did.
|
||||
//!
|
||||
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
|
||||
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
|
||||
//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
|
||||
@@ -58,6 +69,18 @@ pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> Pow
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the adapter is the app's to choose: neither `WGPU_POWER_PREF` nor Preferences name a
|
||||
/// kind of GPU (the arguments of [`power_preference`]). Then the GPU that drives the display comes
|
||||
/// first ([`display_gpus`]); a user's own choice is kept as it is.
|
||||
pub fn automatic(pref: Option<&str>, env: Option<PowerPreference>) -> bool {
|
||||
env.is_none() && !matches!(pref, Some("highPerformance" | "lowPower"))
|
||||
}
|
||||
|
||||
/// The GPUs to rank first: those that drive a display, when the choice is [`automatic`].
|
||||
pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> Vec<DisplayGpu> {
|
||||
if automatic(pref, env) { display_gpus() } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// One adapter, as [`adapter_order`] sees it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
@@ -66,6 +89,9 @@ pub struct Candidate {
|
||||
pub name: String,
|
||||
pub backend: Backend,
|
||||
pub device_type: DeviceType,
|
||||
/// PCI vendor and device ids, to match a [`DisplayGpu`]; zero where the backend reports none
|
||||
/// (Metal, and OpenGL's device id).
|
||||
pub pci: (u32, u32),
|
||||
/// Reports that it can present to the window's surface.
|
||||
pub presents: bool,
|
||||
}
|
||||
@@ -79,8 +105,128 @@ impl Candidate {
|
||||
name: info.name,
|
||||
backend: info.backend,
|
||||
device_type: info.device_type,
|
||||
pci: (info.vendor, info.device),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it drives one of `displays`, and whether that is the primary one.
|
||||
fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) {
|
||||
let shown = displays.iter().find(|g| g.pci == self.pci);
|
||||
(shown.is_some(), shown.is_some_and(|g| g.primary))
|
||||
}
|
||||
}
|
||||
|
||||
/// A GPU with a monitor attached, as the system reports it ([`display_gpus`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DisplayGpu {
|
||||
/// PCI `(vendor, device)` ids, as wgpu's `AdapterInfo` reports them.
|
||||
pub pci: (u32, u32),
|
||||
/// Drives the display the user most likely looks at: on Windows the primary display, on Linux
|
||||
/// a built-in panel (`eDP`, `LVDS` or `DSI`).
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DisplayGpu {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:04x}:{:04x}{}", self.pci.0, self.pci.1, if self.primary { " (primary)" } else { "" })
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPUs that drive a display, each once. Windows enumerates its display devices
|
||||
/// (`EnumDisplayDevices`: those attached to the desktop, and the primary one); Linux reads the
|
||||
/// connected connectors in `/sys/class/drm`. macOS and the rest report nothing, so the power
|
||||
/// preference decides as before; so does a system whose GPUs report no PCI ids.
|
||||
pub fn display_gpus() -> Vec<DisplayGpu> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_display_gpus()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_display_gpus(std::path::Path::new("/sys/class/drm"))
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the GPU `pci` drives a display, merging it with its other displays.
|
||||
fn add_display_gpu(gpus: &mut Vec<DisplayGpu>, pci: (u32, u32), primary: bool) {
|
||||
match gpus.iter_mut().find(|g| g.pci == pci) {
|
||||
Some(gpu) => gpu.primary |= primary,
|
||||
None => gpus.push(DisplayGpu { pci, primary }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows' display devices: one per GPU output; those `ATTACHED_TO_DESKTOP` show a monitor, one
|
||||
/// of them the `PRIMARY_DEVICE`. The registry doesn't tell a user which adapter drives a display,
|
||||
/// and WMI reports a resolution for a GPU without a monitor, so this is the way.
|
||||
#[cfg(windows)]
|
||||
fn windows_display_gpus() -> Vec<DisplayGpu> {
|
||||
use winsafe::co::DISPLAY_DEVICE as Flags;
|
||||
let mut gpus = Vec::new();
|
||||
// A machine has a handful of display devices; the cap only bounds a runaway enumeration.
|
||||
for device in winsafe::EnumDisplayDevices(None, None).take(256) {
|
||||
let device = match device {
|
||||
Ok(d) => d,
|
||||
// The enumeration ends with "no more items" or, from a driver, with any other error.
|
||||
Err(e) => {
|
||||
log::debug!("display devices: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if !device.StateFlags.has(Flags::ATTACHED_TO_DESKTOP) {
|
||||
continue;
|
||||
}
|
||||
// Remote Desktop and other non-PCI devices (`ROOT\BasicDisplay\0000`) are left out.
|
||||
let Some(pci) = pci_ids(&device.DeviceID()) else { continue };
|
||||
add_display_gpu(&mut gpus, pci, device.StateFlags.has(Flags::PRIMARY_DEVICE));
|
||||
}
|
||||
gpus
|
||||
}
|
||||
|
||||
/// The PCI `(vendor, device)` of a Windows device id such as
|
||||
/// `PCI\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1`, any case; `None` for anything else.
|
||||
fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
|
||||
let field = |key: &str| {
|
||||
device_id.split(['\\', '&']).find_map(|part| {
|
||||
let (name, hex) = part.split_at_checked(key.len())?;
|
||||
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
})
|
||||
};
|
||||
Some((field("VEN_")?, field("DEV_")?))
|
||||
}
|
||||
|
||||
/// Linux: every connector `cardN-CONNECTOR` under `drm` whose `status` is `connected`, with the
|
||||
/// PCI ids of its card (`cardN/device/{vendor,device}`). A built-in panel is the primary display.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
|
||||
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
|
||||
let s = std::fs::read_to_string(p).ok()?;
|
||||
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
|
||||
};
|
||||
let mut gpus = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
|
||||
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
|
||||
for entry in entries.flatten().take(256) {
|
||||
let name = entry.file_name();
|
||||
let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
|
||||
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
|
||||
if !connected {
|
||||
continue;
|
||||
}
|
||||
let device = drm.join(card).join("device");
|
||||
let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue };
|
||||
add_display_gpu(&mut gpus, (v, d), is_internal_panel(connector));
|
||||
}
|
||||
gpus
|
||||
}
|
||||
|
||||
/// A laptop's built-in panel: an embedded DisplayPort, LVDS or DSI connector.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn is_internal_panel(connector: &str) -> bool {
|
||||
["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
|
||||
}
|
||||
|
||||
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
|
||||
@@ -97,17 +243,30 @@ fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
|
||||
|
||||
/// The order to try `candidates` in (their indices): only those that present to the window and
|
||||
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
|
||||
/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the
|
||||
/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU
|
||||
/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL
|
||||
/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged.
|
||||
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
|
||||
/// software; native backends before OpenGL; the GPUs that drive one of `displays`, the primary
|
||||
/// display's first; then by `power`; then DX12 before Vulkan, keeping the system's order among
|
||||
/// equals (all of it for [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's
|
||||
/// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545);
|
||||
/// elsewhere there is no DX12 adapter, so the order is unchanged.
|
||||
///
|
||||
/// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no
|
||||
/// ids at all), the order is the power preference's alone, as it was.
|
||||
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: &[DisplayGpu], named: Option<&str>, skip: &[String]) -> Vec<usize> {
|
||||
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
|
||||
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
|
||||
// Stable, so equals keep the system's order.
|
||||
order.sort_by_key(|(_, c)| {
|
||||
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
|
||||
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan)
|
||||
let (drives_display, drives_primary) = c.drives(displays);
|
||||
(
|
||||
unnamed,
|
||||
c.device_type == DeviceType::Cpu,
|
||||
c.backend == Backend::Gl,
|
||||
!drives_display,
|
||||
!drives_primary,
|
||||
power_rank(c.device_type, power),
|
||||
c.backend == Backend::Vulkan,
|
||||
)
|
||||
});
|
||||
order.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
@@ -151,21 +310,27 @@ impl Startup {
|
||||
}
|
||||
}
|
||||
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, `WGPU_ADAPTER_NAME` and the
|
||||
/// adapters a restart left out. Every adapter and the choice are logged, for bug reports.
|
||||
pub fn selector(power: PowerPreference, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a
|
||||
/// `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart left out. Every adapter, the displays
|
||||
/// and the choice are logged: which GPU draws is the first question in every black-window report.
|
||||
pub fn selector(power: PowerPreference, displays: Vec<DisplayGpu>, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
|
||||
let named = std::env::var(NAME_ENV).ok();
|
||||
let skip = skipped();
|
||||
Arc::new(move |adapters, surface| {
|
||||
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
|
||||
let order = adapter_order(&candidates, power, named.as_deref(), &skip);
|
||||
let order = adapter_order(&candidates, power, &displays, named.as_deref(), &skip);
|
||||
let listed: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (drives_display, drives_primary) = c.drives(&displays);
|
||||
let note = if !c.presents {
|
||||
", can't show the window"
|
||||
} else if skip.contains(&c.key) {
|
||||
", failed before"
|
||||
} else if drives_primary {
|
||||
", drives the primary display"
|
||||
} else if drives_display {
|
||||
", drives a display"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
@@ -177,6 +342,13 @@ pub fn selector(power: PowerPreference, startup: Arc<Startup>) -> NativeAdapterS
|
||||
let Some((adapter, chosen)) = first else {
|
||||
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
|
||||
};
|
||||
let why = match chosen.drives(&displays) {
|
||||
(_, true) => "it drives the primary display".to_string(),
|
||||
(true, false) => "it drives a display".to_string(),
|
||||
(false, false) if displays.is_empty() => format!("power preference {power:?}"),
|
||||
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
|
||||
};
|
||||
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
|
||||
// Set once; eframe picks the adapter once.
|
||||
let _ = startup.adapter.set(chosen.key.clone());
|
||||
Ok(adapter.clone())
|
||||
@@ -248,13 +420,222 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
|
||||
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, presents: true }
|
||||
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true }
|
||||
}
|
||||
|
||||
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
|
||||
order.iter().map(|&i| c[i].name.clone()).collect()
|
||||
}
|
||||
|
||||
const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204);
|
||||
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
|
||||
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
|
||||
const WARP: (u32, u32) = (0x1414, 0x008c);
|
||||
|
||||
fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate {
|
||||
Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true }
|
||||
}
|
||||
|
||||
fn monitor(pci: (u32, u32)) -> DisplayGpu {
|
||||
DisplayGpu { pci, primary: false }
|
||||
}
|
||||
|
||||
fn primary(pci: (u32, u32)) -> DisplayGpu {
|
||||
DisplayGpu { pci, primary: true }
|
||||
}
|
||||
|
||||
/// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a
|
||||
/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and
|
||||
/// OpenGL (which reports no device id).
|
||||
fn ryzen_desktop() -> Vec<Candidate> {
|
||||
vec![
|
||||
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090),
|
||||
pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU),
|
||||
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090),
|
||||
pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU),
|
||||
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP),
|
||||
pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to
|
||||
/// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now
|
||||
/// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches.
|
||||
#[test]
|
||||
fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() {
|
||||
let c = ryzen_desktop();
|
||||
for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}");
|
||||
}
|
||||
// Without the display information the order is power saving's, as before.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
// After the NVIDIA adapters failed, the integrated GPU is next.
|
||||
let skip = vec![c[2].key.clone(), c[0].key.clone()];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]);
|
||||
}
|
||||
|
||||
/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the
|
||||
/// discrete one. Power saving and the display agree, in either enumeration order.
|
||||
#[test]
|
||||
fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() {
|
||||
let c = ryzen_desktop();
|
||||
for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}");
|
||||
}
|
||||
// Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the
|
||||
// system's order among them (Mesa puts the compositor's GPU first), and a GPU without one
|
||||
// comes after both.
|
||||
let linux = vec![
|
||||
pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU),
|
||||
pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090),
|
||||
pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU),
|
||||
pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)),
|
||||
];
|
||||
let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)];
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]);
|
||||
assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so
|
||||
/// its GPU draws, whichever kind it is.
|
||||
#[test]
|
||||
fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() {
|
||||
let c = ryzen_desktop();
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
// The displays only matter among adapters that can show the window; a GPU driving no
|
||||
// display still loses to one that does.
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]);
|
||||
}
|
||||
|
||||
/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id,
|
||||
/// Metal's zeros) change nothing: power saving's order, the old behaviour.
|
||||
#[test]
|
||||
fn displays_no_adapter_matches_leave_the_power_preference_in_charge() {
|
||||
let c = ryzen_desktop();
|
||||
let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}");
|
||||
}
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090");
|
||||
// The #502 machine's adapters report no ids in these tests: the primary display never matches.
|
||||
let c = issue_502();
|
||||
assert_eq!(
|
||||
adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]),
|
||||
adapter_order(&c, PowerPreference::None, &[], None, &[])
|
||||
);
|
||||
assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays.
|
||||
#[test]
|
||||
fn a_named_adapter_comes_before_the_one_driving_the_display() {
|
||||
let mut c = ryzen_desktop();
|
||||
let displays = [primary(NVIDIA_3090)];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]);
|
||||
c[2].presents = false;
|
||||
c[0].presents = false;
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]);
|
||||
}
|
||||
|
||||
/// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic
|
||||
/// looks at the displays.
|
||||
#[test]
|
||||
fn only_automatic_considers_which_gpu_drives_the_display() {
|
||||
assert!(automatic(None, None));
|
||||
assert!(automatic(Some("automatic"), None));
|
||||
assert!(automatic(Some("powerSaving"), None));
|
||||
assert!(!automatic(Some("lowPower"), None));
|
||||
assert!(!automatic(Some("highPerformance"), None));
|
||||
assert!(!automatic(None, Some(PowerPreference::None)));
|
||||
assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower)));
|
||||
assert!(preferred_displays(Some("lowPower"), None).is_empty());
|
||||
assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty());
|
||||
// Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop).
|
||||
assert_eq!(preferred_displays(None, None), display_gpus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pci_ids_come_from_windows_device_ids() {
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090));
|
||||
assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU));
|
||||
assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU));
|
||||
// Remote Desktop and other non-PCI display devices, and malformed ids.
|
||||
assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None);
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None);
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None);
|
||||
assert_eq!(pci_ids("VEN_&DEV_"), None);
|
||||
assert_eq!(pci_ids(""), None);
|
||||
}
|
||||
|
||||
/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary
|
||||
/// display, and none is listed twice. (A headless CI runner may list none.)
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_lists_each_gpu_with_a_monitor_once() {
|
||||
let gpus = windows_display_gpus();
|
||||
assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}");
|
||||
for (i, g) in gpus.iter().enumerate() {
|
||||
assert_ne!(g.pci.0, 0, "{gpus:?}");
|
||||
assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
|
||||
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
|
||||
assert!(is_internal_panel(name), "{name}");
|
||||
}
|
||||
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
|
||||
assert!(!is_internal_panel(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port
|
||||
/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without
|
||||
/// PCI ids. Each GPU is listed once, the one with the panel as primary.
|
||||
#[test]
|
||||
fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name).join("device"))?;
|
||||
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
|
||||
std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
|
||||
};
|
||||
let connector = |name: &str, status: &str| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
|
||||
};
|
||||
card("card0", AMD_IGPU)?;
|
||||
card("card1", NVIDIA_3090)?;
|
||||
std::fs::create_dir_all(dir.join("card2"))?;
|
||||
connector("card0-eDP-1", "connected")?;
|
||||
connector("card0-HDMI-A-1", "disconnected")?;
|
||||
connector("card1-DP-1", "connected")?;
|
||||
connector("card1-DP-2", "connected")?;
|
||||
connector("card2-Virtual-1", "connected")?;
|
||||
std::fs::write(dir.join("renderD128"), "")?;
|
||||
let mut gpus = linux_display_gpus(&dir);
|
||||
gpus.sort_by_key(|g| g.pci);
|
||||
assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]);
|
||||
assert!(linux_display_gpus(&dir.join("no such dir")).is_empty());
|
||||
std::fs::remove_dir_all(&dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_gpus_are_logged_as_pci_ids() {
|
||||
assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)");
|
||||
assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e");
|
||||
let mut gpus = vec![];
|
||||
add_display_gpu(&mut gpus, NVIDIA_3090, false);
|
||||
add_display_gpu(&mut gpus, NVIDIA_3090, true);
|
||||
add_display_gpu(&mut gpus, AMD_IGPU, false);
|
||||
assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]);
|
||||
}
|
||||
|
||||
/// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's
|
||||
/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software
|
||||
/// renderer, and OpenGL.
|
||||
@@ -270,7 +651,7 @@ mod tests {
|
||||
#[test]
|
||||
fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() {
|
||||
let c = issue_502();
|
||||
let order = adapter_order(&c, power_preference(None, None), None, &[]);
|
||||
let order = adapter_order(&c, power_preference(None, None), &[], None, &[]);
|
||||
let first = &c[order[0]].name;
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
|
||||
@@ -283,7 +664,7 @@ mod tests {
|
||||
#[test]
|
||||
fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() {
|
||||
let c = issue_502();
|
||||
let order = |power| names(&c, &adapter_order(&c, power, None, &[]));
|
||||
let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[]));
|
||||
assert_eq!(
|
||||
order(PowerPreference::None),
|
||||
[
|
||||
@@ -324,14 +705,15 @@ mod tests {
|
||||
name: name.into(),
|
||||
backend: Backend::Metal,
|
||||
device_type,
|
||||
pci: (0, 0),
|
||||
presents: true,
|
||||
};
|
||||
let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)];
|
||||
assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics"));
|
||||
let first = adapter_order(&c, PowerPreference::LowPower, None, &[]);
|
||||
let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics");
|
||||
let skip = parse_skip(&c[first[0]].key);
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, None, &skip)), ["NVIDIA GeForce GT 750M"]);
|
||||
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]);
|
||||
// A comma in a name can't split the list.
|
||||
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
|
||||
}
|
||||
@@ -341,7 +723,7 @@ mod tests {
|
||||
let c = issue_502();
|
||||
let mut skip = vec![];
|
||||
let mut tried = vec![];
|
||||
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, None, &skip).first() {
|
||||
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() {
|
||||
tried.push(c[i].name.clone());
|
||||
skip.push(c[i].key.clone());
|
||||
}
|
||||
@@ -362,33 +744,36 @@ mod tests {
|
||||
c[0].presents = false;
|
||||
c[3].presents = false;
|
||||
for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] {
|
||||
let order = adapter_order(&c, power, None, &[]);
|
||||
let order = adapter_order(&c, power, &[], None, &[]);
|
||||
assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}");
|
||||
assert_eq!(order.len(), 2);
|
||||
}
|
||||
for p in &mut c {
|
||||
p.presents = false;
|
||||
}
|
||||
assert!(adapter_order(&c, PowerPreference::None, None, &[]).is_empty());
|
||||
assert!(adapter_order(&[], PowerPreference::HighPerformance, None, &[]).is_empty());
|
||||
assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty());
|
||||
assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() {
|
||||
let c = issue_502();
|
||||
let order = adapter_order(&c, PowerPreference::HighPerformance, Some("radv"), &[]);
|
||||
let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]);
|
||||
assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
|
||||
assert_eq!(order.len(), 4);
|
||||
// Any case; every match comes before the rest, natively first.
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, Some("GeForce"), &[]);
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]);
|
||||
assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]);
|
||||
// An empty name or one nothing matches changes nothing.
|
||||
for named in ["", "no such gpu"] {
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, None, &[]));
|
||||
assert_eq!(
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]),
|
||||
adapter_order(&c, PowerPreference::LowPower, &[], None, &[])
|
||||
);
|
||||
}
|
||||
// A named adapter that failed is left out like any other.
|
||||
let skip = vec![c[1].key.clone()];
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, Some("radv"), &skip)[0], 0);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0);
|
||||
}
|
||||
|
||||
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
|
||||
@@ -402,9 +787,9 @@ mod tests {
|
||||
gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
|
||||
];
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
|
||||
let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]);
|
||||
assert_eq!(order, [3, 1, 2, 0, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]);
|
||||
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]);
|
||||
}
|
||||
|
||||
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
|
||||
@@ -419,9 +804,9 @@ mod tests {
|
||||
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
|
||||
];
|
||||
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
|
||||
assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}");
|
||||
assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}");
|
||||
}
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
|
||||
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! macOS: the font files CoreText's font manager lists, scanned with the font folders
|
||||
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
|
||||
//! MCP) both install it; the CLI shares this file.
|
||||
//!
|
||||
//! Apps and font managers can register fonts with the font manager from files in any folder,
|
||||
//! outside the font folders
|
||||
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl(_:_:_:)>).
|
||||
//! The font manager's list of available fonts holds them
|
||||
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagercopyavailablefonturls()>), so
|
||||
//! VectorCraft lists them as it does on Windows (`system_fonts.rs`), reading the files in place
|
||||
//! (#579).
|
||||
//!
|
||||
//! CoreText is a C API with no safe binding, hence the scoped `unsafe_code` allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use objc2_core_foundation::{CFType, CFURL};
|
||||
use objc2_core_text::CTFontManagerCopyAvailableFontURLs;
|
||||
|
||||
/// The most fonts read from the list (a system has a few thousand faces).
|
||||
const MAX_FONTS: usize = 1 << 16;
|
||||
|
||||
/// Have the font scans read the font files CoreText lists.
|
||||
pub fn install() {
|
||||
vectorcraft_text::set_platform_font_files(font_files);
|
||||
}
|
||||
|
||||
/// The files of the fonts available to the app as CoreText lists them now (fonts registered since
|
||||
/// the last call included).
|
||||
fn font_files() -> Vec<String> {
|
||||
// SAFETY: a CoreText call without arguments; it returns a new array.
|
||||
let list = unsafe { CTFontManagerCopyAvailableFontURLs() };
|
||||
// SAFETY: the array holds CF objects (CFURLs, as CoreText documents); each one is checked to be
|
||||
// a CFURL before it is read.
|
||||
let fonts = unsafe { list.cast_unchecked::<CFType>() };
|
||||
// Faces of one file (a collection's) name it once.
|
||||
let files: BTreeSet<String> = fonts
|
||||
.iter()
|
||||
.take(MAX_FONTS)
|
||||
.filter_map(|f| f.downcast::<CFURL>().ok())
|
||||
// Only file URLs, as in `open_documents.rs` (#433).
|
||||
.filter(|u| u.scheme().is_some_and(|s| s.to_string().eq_ignore_ascii_case("file")))
|
||||
.filter_map(|u| u.to_file_path()?.into_os_string().into_string().ok())
|
||||
.collect();
|
||||
files.into_iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2_core_foundation::CFURL;
|
||||
use objc2_core_text::{CTFontManagerRegisterFontsForURL, CTFontManagerScope, CTFontManagerUnregisterFontsForURL};
|
||||
|
||||
/// CoreText lists the installed fonts, by their files' full paths.
|
||||
#[test]
|
||||
fn coretext_lists_the_installed_font_files() {
|
||||
let files = super::font_files();
|
||||
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
|
||||
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts/")), "{} files, {:?}", files.len(), files.first());
|
||||
}
|
||||
|
||||
/// A font registered with CoreText from another folder, as apps and font managers register
|
||||
/// fonts, is listed while it is registered.
|
||||
#[test]
|
||||
fn fonts_registered_from_other_folders_are_listed() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
|
||||
// installs.
|
||||
let file = dir.join("Registered Font.ttf");
|
||||
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
|
||||
let want = std::fs::canonicalize(&file).unwrap();
|
||||
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
|
||||
assert!(!listed());
|
||||
let url = CFURL::from_file_path(&file).unwrap();
|
||||
// SAFETY: a file URL, a scope constant and no error out-parameter.
|
||||
assert!(unsafe { CTFontManagerRegisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) });
|
||||
let registered = listed();
|
||||
// SAFETY: as above.
|
||||
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(registered);
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,12 @@ mod control_server;
|
||||
mod gpu;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
@@ -139,27 +142,13 @@ fn discard_marked_text() {
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
prefs_path_for("VectorCraft", "vectorcraft")
|
||||
prefs_dir::prefs_path_for("VectorCraft", "vectorcraft")
|
||||
}
|
||||
|
||||
/// The same place under the project's former name (DrawCraft): read once if there are no
|
||||
/// VectorCraft preferences yet, so settings survive the rename.
|
||||
fn legacy_prefs_path() -> Option<std::path::PathBuf> {
|
||||
prefs_path_for("DrawCraft", "drawcraft")
|
||||
}
|
||||
|
||||
fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
prefs_dir::prefs_path_for("DrawCraft", "drawcraft")
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the preferences folder (see `logging`).
|
||||
@@ -393,6 +382,10 @@ fn main() -> std::process::ExitCode {
|
||||
// Before the first font scan (the app's start): the fonts font services load (#579).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
system_fonts::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
|
||||
prefs_dir::install_fonts();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
@@ -427,7 +420,21 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(feature = "wgpu")]
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let power = gpu::power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let power_env = eframe::wgpu::PowerPreference::from_env();
|
||||
#[cfg(feature = "wgpu")]
|
||||
let power = gpu::power_preference(gpu_pref, power_env);
|
||||
// Automatic draws on the GPU that drives the (primary) display: a GPU without a monitor reset
|
||||
// its driver and took every monitor down (pdfcraft#378). Logged first: the first question in
|
||||
// every black-window report.
|
||||
#[cfg(feature = "wgpu")]
|
||||
let displays = gpu::preferred_displays(gpu_pref, power_env);
|
||||
#[cfg(feature = "wgpu")]
|
||||
if gpu::automatic(gpu_pref, power_env) {
|
||||
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
|
||||
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
|
||||
} else {
|
||||
log::info!("graphics processor chosen by the user ({power:?}): which GPU drives the display isn't considered");
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let options = eframe::NativeOptions {
|
||||
@@ -454,7 +461,7 @@ fn main() -> std::process::ExitCode {
|
||||
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
|
||||
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone()));
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
|
||||
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
|
||||
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
|
||||
@@ -489,6 +496,12 @@ fn main() -> std::process::ExitCode {
|
||||
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);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
// runs) keep theirs for the session only, never touching the user's.
|
||||
if prefs_enabled() {
|
||||
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
|
||||
app.session.libraries.set_dir(libraries);
|
||||
}
|
||||
// Data Recovery copies live next to the preferences too (none for runs without
|
||||
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Where VectorCraft keeps its settings, `ui.json`, and next to it folders of the user's own
|
||||
//! (Swatches, Graphic Styles, Fonts, Data Recovery, logs). The desktop app and `vectorcraft-cli`
|
||||
//! (which shares this file) read the same Fonts folder.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
|
||||
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
|
||||
/// BSD).
|
||||
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
|
||||
/// `text.addFontFiles` copies font files into. The folder is set whether or not
|
||||
/// `VECTORCRAFT_NO_PREFS` is set, as the Swatches and Graphic Styles folders are.
|
||||
pub fn install_fonts() {
|
||||
if let Some(dir) = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts"))) {
|
||||
vectorcraft_text::set_app_font_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_font_scans_read_the_fonts_folder_next_to_the_settings() {
|
||||
install_fonts();
|
||||
let fonts = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts")));
|
||||
assert!(fonts.is_some(), "a home folder to keep the settings in");
|
||||
assert_eq!(vectorcraft_text::app_font_dir().map(std::path::Path::to_path_buf), fonts);
|
||||
assert!(vectorcraft_text::system_font_dirs().contains(&fonts.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
+507
-55
@@ -2,49 +2,49 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/vectorcraft",
|
||||
"branch": "main",
|
||||
"head": "fe9f3afcb7d32032e8f332b0594692a03e275b84",
|
||||
"head_date": "2026-10-08T00:36:16Z",
|
||||
"commits": 607,
|
||||
"head": "866ea5878a7f6db7dd3d6b1287f9a9bb3a241c33",
|
||||
"head_date": "2026-10-10T00:04:42Z",
|
||||
"commits": 906,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 15,
|
||||
"commits": 119,
|
||||
"lines_added": 172276,
|
||||
"lines_deleted": 40329,
|
||||
"prs": 18,
|
||||
"commits": 124,
|
||||
"lines_added": 173772,
|
||||
"lines_deleted": 40385,
|
||||
"binary_added": 37,
|
||||
"binary_deleted": 2,
|
||||
"first_commit": "2026-09-30T09:12:46Z",
|
||||
"last_commit": "2026-10-07T22:04:30Z"
|
||||
"last_commit": "2026-10-08T14:01:10Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 176,
|
||||
"commits": 394,
|
||||
"lines_added": 212929,
|
||||
"lines_deleted": 24160,
|
||||
"prs": 328,
|
||||
"commits": 561,
|
||||
"lines_added": 253056,
|
||||
"lines_deleted": 31036,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-02T15:22:29Z",
|
||||
"last_commit": "2026-10-08T00:36:16Z"
|
||||
"last_commit": "2026-10-09T23:27:19Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 456,
|
||||
"lines_deleted": 49,
|
||||
"prs": 8,
|
||||
"commits": 9,
|
||||
"lines_added": 812,
|
||||
"lines_deleted": 77,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-04T06:45:57Z",
|
||||
"last_commit": "2026-10-04T08:14:20Z"
|
||||
"last_commit": "2026-10-09T04:35:07Z"
|
||||
},
|
||||
{
|
||||
"login": "theironchef",
|
||||
@@ -102,14 +102,14 @@
|
||||
"login": "imachus",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 12,
|
||||
"lines_added": 6302,
|
||||
"lines_deleted": 212,
|
||||
"prs": 21,
|
||||
"commits": 22,
|
||||
"lines_added": 7242,
|
||||
"lines_deleted": 320,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T10:06:18Z",
|
||||
"last_commit": "2026-10-07T22:20:10Z"
|
||||
"last_commit": "2026-10-09T08:36:30Z"
|
||||
},
|
||||
{
|
||||
"login": "petrleocompel",
|
||||
@@ -128,14 +128,14 @@
|
||||
"login": "fahrim",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 6,
|
||||
"lines_added": 1964,
|
||||
"lines_deleted": 76,
|
||||
"prs": 4,
|
||||
"commits": 7,
|
||||
"lines_added": 2134,
|
||||
"lines_deleted": 83,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T12:48:28Z",
|
||||
"last_commit": "2026-10-07T23:36:17Z"
|
||||
"last_commit": "2026-10-08T01:33:25Z"
|
||||
},
|
||||
{
|
||||
"login": "EthanSuquet",
|
||||
@@ -258,14 +258,14 @@
|
||||
"login": "Haven-King",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 467,
|
||||
"lines_deleted": 76,
|
||||
"prs": 9,
|
||||
"commits": 19,
|
||||
"lines_added": 8627,
|
||||
"lines_deleted": 720,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:10:39Z",
|
||||
"last_commit": "2026-10-07T18:40:49Z"
|
||||
"last_commit": "2026-10-09T03:25:56Z"
|
||||
},
|
||||
{
|
||||
"login": "njmueller",
|
||||
@@ -297,14 +297,430 @@
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 76,
|
||||
"lines_deleted": 21,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 392,
|
||||
"lines_deleted": 44,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:09:37Z",
|
||||
"last_commit": "2026-10-07T23:09:37Z"
|
||||
"last_commit": "2026-10-09T17:33:23Z"
|
||||
},
|
||||
{
|
||||
"login": "raytheengineer",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 94,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T03:19:20Z",
|
||||
"last_commit": "2026-10-08T03:19:20Z"
|
||||
},
|
||||
{
|
||||
"login": "bobtherobot",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 6,
|
||||
"lines_added": 824,
|
||||
"lines_deleted": 93,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T03:25:09Z",
|
||||
"last_commit": "2026-10-08T16:57:16Z"
|
||||
},
|
||||
{
|
||||
"login": "devCangrejo",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 859,
|
||||
"lines_deleted": 13,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T06:30:51Z",
|
||||
"last_commit": "2026-10-08T06:30:51Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 749,
|
||||
"lines_deleted": 92,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T07:45:45Z",
|
||||
"last_commit": "2026-10-08T11:46:16Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 638,
|
||||
"lines_deleted": 3,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T09:25:54Z",
|
||||
"last_commit": "2026-10-08T09:42:11Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 52,
|
||||
"lines_deleted": 52,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:01Z",
|
||||
"last_commit": "2026-10-08T14:01:57Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 74,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:58:51Z",
|
||||
"last_commit": "2026-10-08T12:58:51Z"
|
||||
},
|
||||
{
|
||||
"login": "omellado-gh",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 335,
|
||||
"lines_deleted": 7,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:05:09Z",
|
||||
"last_commit": "2026-10-08T13:05:09Z"
|
||||
},
|
||||
{
|
||||
"login": "callmeBalloch",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 13,
|
||||
"lines_added": 11741,
|
||||
"lines_deleted": 698,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T14:01:08Z",
|
||||
"last_commit": "2026-10-09T13:45:55Z"
|
||||
},
|
||||
{
|
||||
"login": "jeremyfry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 57,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T14:57:01Z",
|
||||
"last_commit": "2026-10-08T14:57:01Z"
|
||||
},
|
||||
{
|
||||
"login": "dbambus",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 72,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T15:38:18Z",
|
||||
"last_commit": "2026-10-08T15:38:18Z"
|
||||
},
|
||||
{
|
||||
"login": "mo0kid",
|
||||
"people_entry": true,
|
||||
"github_name": "David John Whiting",
|
||||
"prs": 2,
|
||||
"commits": 6,
|
||||
"lines_added": 1973,
|
||||
"lines_deleted": 144,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T19:50:54Z",
|
||||
"last_commit": "2026-10-08T22:03:34Z"
|
||||
},
|
||||
{
|
||||
"login": "MaksPV",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 3682,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:44:45Z",
|
||||
"last_commit": "2026-10-08T20:44:45Z"
|
||||
},
|
||||
{
|
||||
"login": "omd0",
|
||||
"people_entry": true,
|
||||
"github_name": "Emad Fussi",
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 291,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T21:34:00Z",
|
||||
"last_commit": "2026-10-08T21:34:00Z"
|
||||
},
|
||||
{
|
||||
"login": "leblanc-simon",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 4,
|
||||
"lines_added": 3721,
|
||||
"lines_deleted": 60,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T22:00:03Z",
|
||||
"last_commit": "2026-10-08T23:14:11Z"
|
||||
},
|
||||
{
|
||||
"login": "dotCooCoo",
|
||||
"people_entry": true,
|
||||
"github_name": ".CooCoo",
|
||||
"prs": 5,
|
||||
"commits": 6,
|
||||
"lines_added": 1701,
|
||||
"lines_deleted": 139,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T22:18:57Z",
|
||||
"last_commit": "2026-10-09T15:33:59Z"
|
||||
},
|
||||
{
|
||||
"login": "u9151131269-a11y",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 37,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T23:05:11Z",
|
||||
"last_commit": "2026-10-08T23:05:11Z"
|
||||
},
|
||||
{
|
||||
"login": "HEOJUNFO",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 112,
|
||||
"lines_deleted": 17,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T23:49:03Z",
|
||||
"last_commit": "2026-10-08T23:49:03Z"
|
||||
},
|
||||
{
|
||||
"login": "DisasterAreaDesigns",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 14,
|
||||
"lines_added": 3359,
|
||||
"lines_deleted": 116,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:15:28Z",
|
||||
"last_commit": "2026-10-09T14:43:23Z"
|
||||
},
|
||||
{
|
||||
"login": "jax-spenser",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 3098,
|
||||
"lines_deleted": 39,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:41:46Z",
|
||||
"last_commit": "2026-10-09T01:41:46Z"
|
||||
},
|
||||
{
|
||||
"login": "mateususa",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 18,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T02:19:59Z",
|
||||
"last_commit": "2026-10-09T02:19:59Z"
|
||||
},
|
||||
{
|
||||
"login": "fastmikey99",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 490,
|
||||
"lines_deleted": 31,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:11:07Z",
|
||||
"last_commit": "2026-10-09T04:11:08Z"
|
||||
},
|
||||
{
|
||||
"login": "AutoHenry",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 351,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T10:50:26Z",
|
||||
"last_commit": "2026-10-09T10:50:26Z"
|
||||
},
|
||||
{
|
||||
"login": "jmverges",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 382,
|
||||
"lines_deleted": 73,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T11:17:50Z",
|
||||
"last_commit": "2026-10-09T19:03:09Z"
|
||||
},
|
||||
{
|
||||
"login": "dmatviichuk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 3893,
|
||||
"lines_deleted": 109,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T12:15:12Z",
|
||||
"last_commit": "2026-10-09T17:51:43Z"
|
||||
},
|
||||
{
|
||||
"login": "hamada147",
|
||||
"people_entry": true,
|
||||
"github_name": "Ahmed Moussa",
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 671,
|
||||
"lines_deleted": 62,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T13:09:34Z",
|
||||
"last_commit": "2026-10-09T14:18:54Z"
|
||||
},
|
||||
{
|
||||
"login": "haranishi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 571,
|
||||
"lines_deleted": 7,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:53:07Z",
|
||||
"last_commit": "2026-10-09T17:53:07Z"
|
||||
},
|
||||
{
|
||||
"login": "Messhias",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 268,
|
||||
"lines_deleted": 27,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T19:00:03Z",
|
||||
"last_commit": "2026-10-09T19:26:55Z"
|
||||
},
|
||||
{
|
||||
"login": "rschuerer",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 450,
|
||||
"lines_deleted": 35,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T23:08:33Z",
|
||||
"last_commit": "2026-10-09T23:08:33Z"
|
||||
},
|
||||
{
|
||||
"login": "dylanmtaylor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 34,
|
||||
"lines_deleted": 34,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:04:42Z",
|
||||
"last_commit": "2026-10-10T00:04:42Z"
|
||||
},
|
||||
{
|
||||
"login": "llduyll10",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
},
|
||||
{
|
||||
"login": "visuarte",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
@@ -313,36 +729,72 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 567,
|
||||
"lines_added": 382239,
|
||||
"lines_deleted": 67174
|
||||
"commits": 787,
|
||||
"lines_added": 439476,
|
||||
"lines_deleted": 75146
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 6,
|
||||
"lines_added": 660,
|
||||
"lines_deleted": 34
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 3,
|
||||
"lines_added": 1672,
|
||||
"lines_deleted": 42
|
||||
"commits": 22,
|
||||
"lines_added": 7688,
|
||||
"lines_deleted": 196
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 9,
|
||||
"lines_added": 8008,
|
||||
"lines_deleted": 2754
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.8",
|
||||
"commits": 4,
|
||||
"lines_added": 1729,
|
||||
"lines_deleted": 44
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 3,
|
||||
"lines_added": 448,
|
||||
"lines_deleted": 120
|
||||
},
|
||||
{
|
||||
"id": "anysphere/cursor",
|
||||
"company": "Anysphere",
|
||||
"model": "Cursor",
|
||||
"version": "",
|
||||
"commits": 2,
|
||||
"lines_added": 63,
|
||||
"lines_deleted": 25
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 6809,
|
||||
"lines_deleted": 2627
|
||||
"lines_added": 55,
|
||||
"lines_deleted": 4
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.7",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "4.7",
|
||||
"commits": 1,
|
||||
"lines_added": 94,
|
||||
"lines_deleted": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,3 +35,13 @@ sync_github_name = true
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.AutoHenry]
|
||||
real_name = "Hal Goff"
|
||||
display_name = "Hal Goff, HG Studio"
|
||||
sync_github_name = false
|
||||
|
||||
[people.hamada147]
|
||||
real_name = "Ahmed Moussa"
|
||||
display_name = "Ahmed Moussa"
|
||||
sync_github_name = true
|
||||
@@ -16,6 +16,7 @@ synth = []
|
||||
[dependencies]
|
||||
crc32fast = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+36
-13
@@ -11,7 +11,7 @@ Affinity files.
|
||||
| Affinity | VectorCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||||
| Artboards (`ShpN` with `ABEn`), with name, background fill, any transform | artboards plus a clip group with the background |
|
||||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
|
||||
@@ -23,9 +23,9 @@ Affinity files.
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | embedded PNG images |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
|
||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
@@ -35,7 +35,7 @@ transparency gradients, fill opacity, bitmap fills, master pages, conical gradie
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
|
||||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||||
@@ -78,19 +78,32 @@ of CC0 sample files.
|
||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||
written files in Affinity itself.
|
||||
|
||||
The same reader is shared, as an independent copy, with PhotoCraft's `photocraft-affinity`.
|
||||
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
||||
|
||||
## Validation
|
||||
|
||||
* 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
|
||||
mutations.
|
||||
* `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents, including the
|
||||
four Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
|
||||
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
|
||||
parses them; `engine/tests/affinity_corpus.rs` renders each one and compares it with the
|
||||
thumbnail Affinity saved in it (mean difference 0–4.4 of 255 at the thumbnail's size, with a
|
||||
ceiling per file).
|
||||
* `cargo xtask corpus --affinity` fetches 51 public CC0/MIT/Apache-2.0 documents at pinned
|
||||
commits, SHA-256 verified against `xtask/affinity-corpus.sha256`, with upstream license notices.
|
||||
Of these, 33 are current `.af`: four CC0 vector illustrations and 28 original Affinity 3.2.3
|
||||
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
|
||||
`.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`
|
||||
save/reload, and SVG/PDF/PSD export of every board in all 33 current files. It checks SVG/PDF
|
||||
dimensions and reimport, and independently decodes PSD merged pixels using the published
|
||||
specification. These are VectorCraft consistency checks, not independent Affinity PSD oracles.
|
||||
Saved-thumbnail comparisons cover the existing 22 documents plus nine new representative
|
||||
fixtures, checking every board; unsupported effects and text layout are not claimed visually
|
||||
faithful. The Affinity corpus workflow requires the files, so missing fixtures cannot silently
|
||||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
||||
reader (`container`) and the object stream (`stream`):
|
||||
@@ -100,6 +113,10 @@ The same reader is shared, as an independent copy, with PhotoCraft's `photocraft
|
||||
cargo +nightly fuzz run stream -- -max_total_time=600
|
||||
```
|
||||
|
||||
The source audit, licensing decisions and remaining complex-document/font cases are recorded in
|
||||
[affinity-validation.md](../../docs/affinity-validation.md). Reopening VectorCraft's exports in
|
||||
Affinity remains an independent validation step that hasn't been done.
|
||||
|
||||
What has not been verified: files from Affinity builds or platforms outside the public set, files
|
||||
written by other applications, rotated or skewed images against Affinity's render, mask polarity
|
||||
against a render with and without the mask, and anything listed as a warning above. The `synth`
|
||||
@@ -113,8 +130,14 @@ default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
stops. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
||||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
|
||||
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
|
||||
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
|
||||
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
|
||||
text, preserving its characters.
|
||||
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
## Privacy
|
||||
|
||||
@@ -337,7 +337,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
|
||||
(0x40, _) => byte_delta(b),
|
||||
(0x80, false) => {
|
||||
let mut prev = 0u16;
|
||||
for pair in b.chunks_exact_mut(2) {
|
||||
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
|
||||
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
|
||||
pair.copy_from_slice(&v.to_le_bytes());
|
||||
prev = v;
|
||||
@@ -349,7 +349,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
|
||||
if b.len() == 0x10000 {
|
||||
let planes = b.to_vec();
|
||||
let (hi, lo) = planes.split_at(0x8000);
|
||||
for (i, out) in b.chunks_exact_mut(4).enumerate() {
|
||||
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
|
||||
return Err(Error::Malformed("tile size"));
|
||||
};
|
||||
|
||||
@@ -6,15 +6,77 @@ use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count
|
||||
/// each visit and each curve record / generated contour / transformed segment across the whole
|
||||
/// outline, including repeated references. Match the document reader's nesting limit.
|
||||
const MAX_OUTLINE_DEPTH: usize = 128;
|
||||
const MAX_OUTLINE_VISITS: usize = 100_000;
|
||||
|
||||
struct OutlineBudget {
|
||||
active: Vec<ObjId>,
|
||||
visits: usize,
|
||||
work: usize,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl OutlineBudget {
|
||||
fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> {
|
||||
if !self.failed {
|
||||
r.warn(warning);
|
||||
}
|
||||
self.failed = true;
|
||||
None
|
||||
}
|
||||
|
||||
fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> {
|
||||
if work > self.work {
|
||||
return self.fail(r, "compound outlines exceeding the geometry work limit");
|
||||
}
|
||||
self.work -= work;
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> {
|
||||
for subpath in &path.subpaths {
|
||||
self.spend(r, 1)?;
|
||||
self.spend(r, subpath.segments.len())?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false };
|
||||
bounded_outline(r, id, class, world, &mut budget)
|
||||
}
|
||||
|
||||
fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
|
||||
if budget.active.contains(&id) {
|
||||
budget.fail(r, "a compound outline that contains itself")?;
|
||||
}
|
||||
if budget.active.len() >= MAX_OUTLINE_DEPTH {
|
||||
budget.fail(r, "compound outlines nested deeper than 128 levels")?;
|
||||
}
|
||||
if budget.visits == 0 {
|
||||
budget.fail(r, "compound outlines exceeding the operand visit limit")?;
|
||||
}
|
||||
budget.visits -= 1;
|
||||
budget.active.push(id);
|
||||
let result = outline_inner(r, id, class, world, budget);
|
||||
// The popped value is our own stack marker, and is no longer needed.
|
||||
let _ = budget.active.pop();
|
||||
result
|
||||
}
|
||||
|
||||
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
|
||||
let path = curves(r, crvs)?;
|
||||
let path = curves(r, crvs, budget)?;
|
||||
budget.path(r, &path)?;
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
@@ -29,8 +91,24 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
let child_world = local.then(world);
|
||||
if !child_world.is_finite() {
|
||||
budget.fail(r, "a compound operand with an invalid transform")?;
|
||||
}
|
||||
if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) {
|
||||
// Charge before transforming or extending. Transform the owned path in place
|
||||
// so nested compounds do not make an additional copy of every contour.
|
||||
budget.path(r, &p)?;
|
||||
for subpath in &mut p.subpaths {
|
||||
subpath.start = local.apply(subpath.start);
|
||||
for segment in &mut subpath.segments {
|
||||
*segment = segment.map(|point| local.apply(point));
|
||||
}
|
||||
}
|
||||
path.subpaths.extend(p.subpaths);
|
||||
} else if budget.failed {
|
||||
// Never present a partial compound as complete after a cycle or budget limit.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
@@ -44,7 +122,9 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
}
|
||||
};
|
||||
let b = s.floats::<4>(box_owner, b"ShpB")?;
|
||||
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
|
||||
let path = crate::shapes::shape(r, shape, b, world)?;
|
||||
budget.path(r, &path)?;
|
||||
Some((path, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +133,7 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
|
||||
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
|
||||
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
|
||||
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Path> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvs, b"Data")?;
|
||||
let mut fields = s.positional(data);
|
||||
@@ -62,6 +142,7 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
Value::UInt(n) => usize::try_from(*n).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
budget.spend(r, count)?;
|
||||
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
|
||||
let mut path = Path::default();
|
||||
let mut seen = 0usize;
|
||||
@@ -73,9 +154,9 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
let Value::Array(records) = fields.next()? else { return None };
|
||||
seen = seen.checked_add(records.len())?;
|
||||
if seen > MAX_NODES {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
budget.fail(r, "curves with more than four million nodes")?;
|
||||
}
|
||||
budget.spend(r, records.len())?;
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
|
||||
@@ -172,6 +172,9 @@ pub struct TextRun {
|
||||
/// CSS weight (400 regular, 700 bold).
|
||||
pub weight: i64,
|
||||
pub italic: bool,
|
||||
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
||||
pub features: Vec<String>,
|
||||
pub all_caps: bool,
|
||||
/// Font size in document pixels, before the node's transform.
|
||||
pub size: f64,
|
||||
/// Tracking in em.
|
||||
@@ -223,10 +226,16 @@ pub enum Kind {
|
||||
/// Layer container (`Scop`).
|
||||
Layer,
|
||||
Group,
|
||||
/// Artboard: a rectangle that clips its children; `background` paints behind them.
|
||||
/// Artboard: an export rectangle and vector outline that clips its children.
|
||||
/// `background` paints behind them, and `strokes` over them.
|
||||
Artboard {
|
||||
rect: Rect,
|
||||
/// Actual clipping outline in document pixels. The artboard's export rectangle is its
|
||||
/// bounding box, but a rotated or non-rectangular board still clips to this outline.
|
||||
path: Path,
|
||||
even_odd: bool,
|
||||
background: Vec<Paint>,
|
||||
strokes: Vec<Stroke>,
|
||||
},
|
||||
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
|
||||
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
|
||||
@@ -436,7 +445,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
b"Scop" => Kind::Layer,
|
||||
b"Grup" => Kind::Group,
|
||||
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
|
||||
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
|
||||
// Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp`
|
||||
// property object in `phrp`, while older documents use `ABEn`. Ordinary shapes
|
||||
// in that fixture have neither. See the pinned corpus and validation audit.
|
||||
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
|
||||
return Ok(self.artboard(id, world));
|
||||
}
|
||||
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
|
||||
@@ -523,12 +535,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
|
||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||
let s = self.s;
|
||||
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
|
||||
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
||||
self.warn("an artboard without valid bounds (its children are kept without clipping)");
|
||||
return Kind::Unsupported;
|
||||
};
|
||||
let corners =
|
||||
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
|
||||
self.warn("a rotated or flipped artboard (imported at its bounding box)");
|
||||
self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)");
|
||||
}
|
||||
let xs = corners.map(|p| p.x);
|
||||
let ys = corners.map(|p| p.y);
|
||||
@@ -538,8 +553,17 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
};
|
||||
let background = paint::fills(self, id, world, b"BFFl");
|
||||
Kind::Artboard { rect, background }
|
||||
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 {
|
||||
self.warn("an artboard with an invalid transformed size (its children are kept without clipping)");
|
||||
return Kind::Unsupported;
|
||||
}
|
||||
let class = s.class(id).unwrap_or(Tag::of(b"ShpN"));
|
||||
let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| {
|
||||
self.warn("an artboard whose outline could not be read (clipped to its rectangle)");
|
||||
(Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false)
|
||||
});
|
||||
let (background, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ const MAX_STOPS: usize = 1024;
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.chunks_exact(4).map(|c| f64::from(f32::from_le_bytes([c[0], c[1], c[2], c[3]]))).collect()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
||||
}
|
||||
image_data = true;
|
||||
}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
|
||||
b"IEND" => {}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ const MAX_PIXELS: u64 = 64 << 20;
|
||||
const TILE: usize = 256;
|
||||
const TILE_BYTES: usize = TILE * TILE;
|
||||
|
||||
struct CachedPixels {
|
||||
tiles: HashMap<String, Vec<u8>>,
|
||||
source: Option<(usize, Vec<u8>)>,
|
||||
}
|
||||
|
||||
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else {
|
||||
@@ -53,7 +58,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, width, height));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
|
||||
let source_tiles_only =
|
||||
(1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5))));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") {
|
||||
r.warn("edited placed images use their stored pixels");
|
||||
}
|
||||
decode(r, bitmap, crop, world)
|
||||
@@ -118,7 +125,27 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
}
|
||||
};
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
// Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of
|
||||
// duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture.
|
||||
// Support only the observed, same-size RGBA8 image with a zero bitmap origin; other
|
||||
// representations still receive the existing loss warning rather than guessing a mapping.
|
||||
let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5))));
|
||||
let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) {
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg") {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
|
||||
Ok(pixels) => Some((width as usize, pixels)),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
}
|
||||
@@ -131,7 +158,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
|
||||
}
|
||||
};
|
||||
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
|
||||
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||
let (r2, g, b, a) = if channels == 1 {
|
||||
(v[0], v[0], v[0], 1.0)
|
||||
@@ -158,6 +185,39 @@ fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the source under the same pixel budget as tiled layers, checking its actual
|
||||
/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported.
|
||||
fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use std::io::Cursor;
|
||||
|
||||
if u64::from(width) * u64::from(height) > MAX_PIXELS {
|
||||
return Err(Error::Limit("embedded image pixels"));
|
||||
}
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?;
|
||||
if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) {
|
||||
return Err(Error::Unsupported("embedded source pixel format"));
|
||||
}
|
||||
let dimensions = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| Error::Malformed("embedded image format"))?
|
||||
.into_dimensions()
|
||||
.map_err(|_| Error::Malformed("embedded image dimensions"))?;
|
||||
if dimensions != (width, height) || width == 0 || height == 0 {
|
||||
return Err(Error::Malformed("embedded source and bitmap dimensions differ"));
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = Some(MAX_PIXELS * 8);
|
||||
reader.limits(limits);
|
||||
let decoded = reader.decode().map_err(|e| match e {
|
||||
image::ImageError::Limits(_) => Error::Limit("embedded image decoding"),
|
||||
_ => Error::Malformed("embedded image decoding"),
|
||||
})?;
|
||||
Ok(decoded.into_rgba8().into_raw())
|
||||
}
|
||||
|
||||
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
@@ -169,8 +229,9 @@ fn plane(
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
cache: &mut HashMap<String, Vec<u8>>,
|
||||
cache: &mut CachedPixels,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let CachedPixels { tiles: cache, source } = cache;
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
@@ -219,6 +280,13 @@ fn plane(
|
||||
None
|
||||
}
|
||||
5 => {
|
||||
if let Some((source_width, pixels)) = source.as_ref() {
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
|
||||
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
None
|
||||
}
|
||||
@@ -241,3 +309,106 @@ fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Kind;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
||||
/// not a claim that Affinity itself accepts the synthetic container.
|
||||
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
||||
let mut bitmap = vec![
|
||||
(tag(b"Frmt"), F::Enum(0, 0)),
|
||||
(tag(b"BmpW"), F::I32(300)),
|
||||
(tag(b"BmpH"), F::I32(2)),
|
||||
(tag(b"LInf"), F::I32(origin)),
|
||||
(tag(b"TInf"), F::I32(0)),
|
||||
(tag(b"Bckg"), F::Entry("c/1".into())),
|
||||
];
|
||||
for c in 1..=4 {
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
|
||||
bitmap.push((
|
||||
tag(&super::tag(b"Idx", c)),
|
||||
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
||||
));
|
||||
}
|
||||
let mut crop = vec![0x17];
|
||||
crop.extend(tag(b"BitR").0.to_le_bytes());
|
||||
crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
let raster = F::Def(
|
||||
10,
|
||||
vec![tag(b"Rstr")],
|
||||
vec![
|
||||
(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)),
|
||||
(tag(b"BitR"), F::Raw(crop)),
|
||||
(tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]);
|
||||
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let mut blob = vec![0x2d];
|
||||
blob.extend(tag(b"Data").0.to_le_bytes());
|
||||
blob.extend((source.len() as u32).to_le_bytes());
|
||||
blob.extend(source);
|
||||
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
synth::container(
|
||||
&[
|
||||
("doc.dat", &doc, Method::Zlib),
|
||||
("c/1", &block, Method::Zlib),
|
||||
("d/1", &tiles[0], Method::Zlib),
|
||||
("d/2", &tiles[1], Method::Zlib),
|
||||
("d/3", &tiles[2], Method::Zlib),
|
||||
("d/4", &tiles[3], Method::Zlib),
|
||||
],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() {
|
||||
let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap();
|
||||
assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]);
|
||||
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
|
||||
assert_eq!((image.width, image.height), (20, 2));
|
||||
assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0]));
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
for y in 0..2 {
|
||||
for x in 0..20 {
|
||||
let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] };
|
||||
assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() {
|
||||
for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] {
|
||||
let doc = crate::read(&bytes, crate::Limits::default()).unwrap();
|
||||
assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)"));
|
||||
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
assert_eq!(&pixels[..4], &[0, 0, 0, 0]);
|
||||
assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
|
||||
let bytes = png();
|
||||
assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_))));
|
||||
assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_))));
|
||||
assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4);
|
||||
}
|
||||
}
|
||||
@@ -115,13 +115,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
|
||||
// large enough that floating-point addition no longer changes the endpoint.
|
||||
let start = raw_start.rem_euclid(TAU);
|
||||
let delta = raw_end - raw_start;
|
||||
let sweep = if raw_end >= raw_start {
|
||||
delta.min(TAU)
|
||||
} else if delta.is_finite() {
|
||||
delta.rem_euclid(TAU)
|
||||
} else {
|
||||
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
|
||||
};
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
pie(cx, cy, rx, ry, start, start + sweep, inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
|
||||
+201
-30
@@ -3,11 +3,13 @@
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::paint::{self, Paint};
|
||||
use crate::paint::{self, Color, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
||||
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
@@ -19,46 +21,76 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let glyphs = s.obj(block, b"Glyp")?;
|
||||
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().collect(),
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
};
|
||||
// Bound collection itself, including placeholders, before allocating the character buffer.
|
||||
let budget = MAX_CHARS - total;
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in s.objs(glyphs, b"Mixd") {
|
||||
for seg in segments {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
total = total.checked_add(chars.len())?;
|
||||
if total > MAX_CHARS {
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
break;
|
||||
}
|
||||
total += chars.len();
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
|
||||
let text = characters(&chars, start, end);
|
||||
start = end;
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
if !text.is_empty() {
|
||||
runs.push(run_attrs(r, attrs, text, world));
|
||||
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
|
||||
}
|
||||
if start < chars.len() {
|
||||
let text = characters(&chars, start, chars.len());
|
||||
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
|
||||
}
|
||||
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
if let Some(attrs) = s.obj(paragraph, b"Item") {
|
||||
let paragraph = paragraph_align(r, attrs);
|
||||
match align {
|
||||
None => align = Some(paragraph),
|
||||
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if align.is_none() {
|
||||
align = s
|
||||
.obj(block, b"PAtt")
|
||||
.and_then(|p| s.objs(p, b"Runs").first().copied())
|
||||
.and_then(|run| s.obj(run, b"Item"))
|
||||
.map(|p| paragraph_align(r, p));
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
@@ -82,6 +114,39 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
}
|
||||
|
||||
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
||||
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
||||
}
|
||||
|
||||
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let run = match attrs {
|
||||
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
|
||||
_ => {
|
||||
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
||||
if attrs.is_none() {
|
||||
r.warn("text without character attributes (characters preserved with default attributes)");
|
||||
}
|
||||
TextRun {
|
||||
text,
|
||||
postscript: String::new(),
|
||||
family: String::new(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
features: Vec::new(),
|
||||
all_caps: false,
|
||||
size: 12.0 * r.dpi / 72.0,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
||||
}
|
||||
}
|
||||
};
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
match v? {
|
||||
Value::Array(a) => a.get(i).and_then(f),
|
||||
@@ -104,7 +169,7 @@ fn int(v: &Value) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option<usize>) -> TextRun {
|
||||
let s = r.s;
|
||||
let doubles = s.field(attrs, b"Doub");
|
||||
let ints = s.field(attrs, b"Ints");
|
||||
@@ -112,24 +177,67 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
|
||||
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
|
||||
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
|
||||
let objects = match s.field(attrs, b"Objs") {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
|
||||
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
|
||||
let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) {
|
||||
Some(fill) => fill,
|
||||
None => {
|
||||
r.warn("text without readable fill attributes (default black used)");
|
||||
Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })
|
||||
}
|
||||
};
|
||||
// A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data
|
||||
// before keeping it; subsequent runs skip attribute decoding once the budget is exhausted.
|
||||
if let Paint::Gradient(gradient) = &fill {
|
||||
let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>());
|
||||
if !charge_attributes(r, attribute_budget, bytes) {
|
||||
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
||||
}
|
||||
}
|
||||
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
|
||||
{
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
let font = s.obj(attrs, b"DFnt");
|
||||
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
|
||||
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
||||
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
||||
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
||||
let requested = s.obj(attrs, b"DFnt");
|
||||
let resolved =
|
||||
s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty());
|
||||
let font = resolved.or(requested);
|
||||
if let Some(resolved) = resolved {
|
||||
let differs = [b"Famy", b"Post"]
|
||||
.iter()
|
||||
.any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default());
|
||||
if differs {
|
||||
r.warn("text uses a stored fallback font (imported with that font)");
|
||||
}
|
||||
}
|
||||
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
|
||||
r.warn("font width variants (exact face depends on the installed PostScript font)");
|
||||
}
|
||||
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
|
||||
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
|
||||
let weight = if (1..=1000).contains(&weight) {
|
||||
weight
|
||||
} else {
|
||||
r.warn("invalid font weight (imported as regular)");
|
||||
400
|
||||
};
|
||||
TextRun {
|
||||
text,
|
||||
postscript: get(b"Post"),
|
||||
family: get(b"Famy"),
|
||||
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
|
||||
postscript,
|
||||
family,
|
||||
weight,
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
features,
|
||||
all_caps,
|
||||
size,
|
||||
tracking,
|
||||
leading,
|
||||
@@ -137,7 +245,70 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -> bool {
|
||||
let Some(remaining) = *budget else { return false };
|
||||
match remaining.checked_sub(bytes) {
|
||||
Some(remaining) => {
|
||||
*budget = Some(remaining);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
*budget = None;
|
||||
r.warn("text attribute budget exceeded (excess font names, features and gradients left out)");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
|
||||
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
|
||||
}
|
||||
|
||||
/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in
|
||||
/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other
|
||||
/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags
|
||||
fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, bool) {
|
||||
let mut features = Vec::new();
|
||||
let mut all_caps = false;
|
||||
if attribute_budget.is_none() {
|
||||
return (features, all_caps);
|
||||
}
|
||||
let s = r.s;
|
||||
let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) };
|
||||
for (index, setting) in settings.iter().take(257).enumerate() {
|
||||
if index == 256 {
|
||||
r.warn("more than 256 text feature settings (truncated)");
|
||||
break;
|
||||
}
|
||||
let Value::Obj(Some(setting)) = setting else {
|
||||
r.warn("unreadable text feature settings (imported with defaults)");
|
||||
continue;
|
||||
};
|
||||
let setting = *setting;
|
||||
let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes);
|
||||
let value = r.s.int(setting, b"Valu");
|
||||
match (tag, value) {
|
||||
(Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1),
|
||||
(Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => {
|
||||
if !charge_attributes(r, attribute_budget, std::mem::size_of::<String>() + if value == 0 { 5 } else { 4 }) {
|
||||
break;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&tag);
|
||||
features.push(if value == 0 { format!("-{name}") } else { name.into_owned() });
|
||||
}
|
||||
_ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"),
|
||||
}
|
||||
}
|
||||
(features, all_caps)
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
|
||||
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
|
||||
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("paragraph indents or spacing (imported with defaults)");
|
||||
}
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! 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,
|
||||
model::{Kind, Path, Point},
|
||||
synth::{self, F, Method, tag},
|
||||
};
|
||||
|
||||
fn compound(id: u32, children: Vec<F>) -> F {
|
||||
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
|
||||
}
|
||||
|
||||
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream::Tag, F)>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
|
||||
)
|
||||
}
|
||||
|
||||
fn read(definitions: Vec<F>, top: u32) -> Document {
|
||||
let data = synth::stream(&[
|
||||
(tag(b"Defs"), F::Shared(definitions)),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
vectorcraft_affinity::read(&bytes, Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn path(document: &Document) -> &Path {
|
||||
match &document.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { path, .. } => path,
|
||||
other => panic!("expected shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(document: &Document, warning: &str) {
|
||||
assert!(matches!(document.spreads[0].nodes[0].kind, Kind::Unsupported));
|
||||
assert!(document.warnings.iter().any(|value| value.contains(warning)), "{:?}", document.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_cycle_and_mutual_cycle_discard_the_entire_compound() {
|
||||
// A valid first operand ensures a malformed later operand cannot leave a plausible partial path.
|
||||
let rectangle = shape(100, b"ShNR", vec![]);
|
||||
let self_cycle = compound(101, vec![F::Ref(100), F::Ref(101)]);
|
||||
rejected(&read(vec![rectangle.clone(), self_cycle], 101), "compound outline that contains itself");
|
||||
let mutual_cycle = compound(101, vec![F::Ref(100), compound(102, vec![F::Ref(101)])]);
|
||||
rejected(&read(vec![rectangle, mutual_cycle], 101), "compound outline that contains itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_reference_chain_is_bounded_independently_of_stream_nesting() {
|
||||
let mut definitions = vec![shape(100, b"ShNR", vec![])];
|
||||
for id in 101..=250 {
|
||||
definitions.push(compound(id, vec![F::Ref(id - 1)]));
|
||||
}
|
||||
rejected(&read(definitions, 250), "compound outlines nested deeper than 128 levels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_at_the_depth_limit_is_still_imported() {
|
||||
let mut definitions = vec![shape(100, b"ShNR", vec![])];
|
||||
for id in 101..=227 {
|
||||
definitions.push(compound(id, vec![F::Ref(id - 1)]));
|
||||
}
|
||||
let document = read(definitions, 227);
|
||||
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
|
||||
assert_eq!(path(&document).subpaths.len(), 1);
|
||||
assert_eq!(path(&document).subpaths[0].segments.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_declared_curve_count_is_rejected_before_expansion() {
|
||||
let curve = F::Def(
|
||||
100,
|
||||
vec![tag(b"PCrv")],
|
||||
vec![(tag(b"Crvs"), F::Def(101, vec![tag(b"PCvD")], vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(u32::MAX)]))]))],
|
||||
);
|
||||
rejected(&read(vec![curve], 100), "compound outlines exceeding the geometry work limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_shared_fanout_cannot_bypass_the_operand_visit_limit() {
|
||||
// Only 21 on-disk objects, but an unbounded traversal would visit over two million operands.
|
||||
let mut definitions = vec![compound(100, vec![])];
|
||||
for id in 101..=120 {
|
||||
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
|
||||
}
|
||||
rejected(&read(definitions, 120), "compound outlines exceeding the operand visit limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_large_operands_are_charged_for_cumulative_geometry_work() {
|
||||
// A small reference graph expands a 20,000-segment star. Count work across every reuse and
|
||||
// transform, rather than permitting each individual operand's allocation limit repeatedly.
|
||||
let mut definitions = vec![shape(100, b"ShSt", vec![(tag(b"Pnts"), F::I32(10_000))])];
|
||||
for id in 101..=112 {
|
||||
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
|
||||
}
|
||||
rejected(&read(definitions, 112), "compound outlines exceeding the geometry work limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_nested_compound_preserves_repeated_operands_and_transforms() {
|
||||
let mut rectangle = shape(100, b"ShNR", vec![]);
|
||||
if let F::Def(_, _, fields) = &mut rectangle {
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 3.0, 0.0, 1.0, 7.0])));
|
||||
}
|
||||
let inner = compound(101, vec![F::Ref(100), F::Ref(100)]);
|
||||
let mut outer = compound(102, vec![F::Ref(101)]);
|
||||
if let F::Def(_, _, fields) = &mut outer {
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 200.0])));
|
||||
}
|
||||
let document = read(vec![rectangle, inner, outer], 102);
|
||||
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
|
||||
let path = path(&document);
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
assert_eq!(path.subpaths[0], path.subpaths[1]);
|
||||
assert_eq!(path.subpaths[0].start, Point { x: 103.0, y: 207.0 });
|
||||
assert_eq!(path.subpaths[0].segments.last().unwrap()[2], Point { x: 103.0, y: 227.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_compound_transform_is_reported_before_processing_geometry() {
|
||||
let mut child = shape(100, b"ShNR", vec![]);
|
||||
if let F::Def(_, _, fields) = &mut child {
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
|
||||
}
|
||||
let mut outer = compound(101, vec![F::Ref(100)]);
|
||||
if let F::Def(_, _, fields) = &mut outer {
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
|
||||
}
|
||||
rejected(&read(vec![child, outer], 101), "compound operand with an invalid transform");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
|
||||
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
|
||||
for subpath in &path(&document).subpaths {
|
||||
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
|
||||
assert!(subpath.segments.len() <= 20);
|
||||
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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,
|
||||
model::{Affine, Document, Image, Kind, Node, Pixels},
|
||||
stream,
|
||||
};
|
||||
|
||||
#[path = "support/corpus.rs"]
|
||||
mod corpus;
|
||||
|
||||
fn images(nodes: &[Node], out: &mut Vec<Image>) {
|
||||
for node in nodes {
|
||||
if let Kind::Image(image) = &node.kind {
|
||||
out.push(image.clone());
|
||||
}
|
||||
images(&node.children, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_fixture(name: &str) -> Option<(Vec<u8>, Document, Vec<Image>)> {
|
||||
let dir = corpus::pinned()?;
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
|
||||
assert!(doc.saved_by.as_deref().unwrap().starts_with("Affinity 3."));
|
||||
let mut out = Vec::new();
|
||||
for spread in &doc.spreads {
|
||||
images(&spread.nodes, &mut out);
|
||||
}
|
||||
Some((bytes, doc, out))
|
||||
}
|
||||
|
||||
fn source(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut archive = Archive::open(bytes, Limits::default()).unwrap();
|
||||
let doc = stream::parse(&archive.read("doc.dat").unwrap()).unwrap();
|
||||
let bitmap =
|
||||
doc.objects.iter().enumerate().find(|(_, o)| o.class == stream::Tag::of(b"DyBm") && o.get(stream::Tag::of(b"Bckg")).is_some()).unwrap().0;
|
||||
let name = doc.entry(bitmap, b"Bckg").unwrap();
|
||||
let block = stream::parse(&archive.read(name).unwrap()).unwrap();
|
||||
block.bytes(block.root, b"Data").unwrap().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_backed_tiles_retain_the_embedded_jpeg_pixels() {
|
||||
let Some((bytes, doc, images)) = read_fixture("patchy-embedded-jpeg.af") else { return };
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
assert_eq!(images.len(), 1);
|
||||
let image = &images[0];
|
||||
assert_eq!((image.width, image.height), (400, 300));
|
||||
assert_eq!(image.transform, Affine::IDENTITY);
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!("expected retained level-zero pixels") };
|
||||
let expected = image::load_from_memory(&source(&bytes)).unwrap().into_rgba8();
|
||||
assert_eq!(pixels.as_slice(), expected.as_raw());
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_placed_images_keep_the_original_encoded_bytes() {
|
||||
let Some((bytes, doc, images)) = read_fixture("patchy-lazy-placed.af") else { return };
|
||||
assert!(doc.warnings.is_empty());
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!((images[0].width, images[0].height), (400, 300));
|
||||
assert_eq!(images[0].transform, Affine::IDENTITY);
|
||||
assert_eq!(images[0].pixels, Pixels::Encoded(source(&bytes)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_and_sixteen_bit_channels_retain_the_same_pixel_pattern() {
|
||||
let Some((_, doc8, images8)) = read_fixture("patchy-rgba8.af") else { return };
|
||||
let (_, doc16, images16) = read_fixture("patchy-rgba16.af").unwrap();
|
||||
assert!(doc8.warnings.is_empty());
|
||||
assert!(doc16.warnings.is_empty());
|
||||
assert_eq!(images8.len(), 1);
|
||||
assert_eq!(images16, images8);
|
||||
assert_eq!((images8[0].width, images8[0].height), (64, 48));
|
||||
let Pixels::Rgba8(pixels) = &images8[0].pixels else { panic!() };
|
||||
assert_eq!(pixels.len(), 64 * 48 * 4);
|
||||
assert_eq!(&pixels[..4], &[0, 0, 0, 128]);
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] < 255));
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
|
||||
// Frozen pixel-retention baseline, including every alpha sample. It is not a render oracle.
|
||||
assert_eq!(crc32fast::hash(pixels), 0xc8ee4dc1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_scaled_pixels_keep_the_full_affine_transform() {
|
||||
let Some((_, doc, images)) = read_fixture("patchy-transform.af") else { return };
|
||||
assert!(doc.warnings.is_empty());
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!((images[0].width, images[0].height), (80, 60));
|
||||
let expected = [1.1742158910592235, 0.4286222593193142, -0.4286222593193142, 1.1742158910592235, 60.0, 40.0];
|
||||
for (actual, expected) in images[0].transform.0.into_iter().zip(expected) {
|
||||
assert!((actual - expected).abs() < 1e-12);
|
||||
}
|
||||
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
|
||||
assert_eq!(pixels.len(), 80 * 60 * 4);
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
|
||||
assert_eq!(crc32fast::hash(pixels), 0x3720a442);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_and_lab_losses_are_reported() {
|
||||
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!((images[0].width, images[0].height), (64, 48));
|
||||
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
|
||||
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
|
||||
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
|
||||
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
|
||||
assert!(images.is_empty());
|
||||
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
|
||||
}
|
||||
@@ -1,28 +1,32 @@
|
||||
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
|
||||
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
|
||||
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
|
||||
//! `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;
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
files(&p, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
|
||||
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(vectorcraft_affinity::MAGIC))
|
||||
{
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn samples() -> Option<PathBuf> {
|
||||
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
|
||||
let pinned = corpus::pinned();
|
||||
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or(pinned)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,6 +56,7 @@ fn public_documents_map_to_the_model() {
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(&dir, &mut paths);
|
||||
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
|
||||
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
|
||||
let mut failures = Vec::new();
|
||||
let (mut nodes, mut docs) = (0usize, 0usize);
|
||||
@@ -60,9 +65,6 @@ fn public_documents_map_to_the_model() {
|
||||
}
|
||||
for p in &paths {
|
||||
let bytes = std::fs::read(p).unwrap();
|
||||
if !bytes.starts_with(vectorcraft_affinity::MAGIC) {
|
||||
continue;
|
||||
}
|
||||
match vectorcraft_affinity::read(&bytes, Limits::default()) {
|
||||
Ok(d) => {
|
||||
docs += 1;
|
||||
@@ -81,3 +83,33 @@ fn public_documents_map_to_the_model() {
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Measured structural baselines for actual Affinity 3 files. Unlike a parse-success test,
|
||||
/// this detects disappearing nodes, curves, runs, font names, artboards and loss warnings.
|
||||
/// It does not claim that an unsupported feature is faithfully rendered.
|
||||
#[test]
|
||||
fn affinity3_feature_retention_matches_pinned_signatures() {
|
||||
let Some(dir) = corpus::pinned() else { return };
|
||||
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");
|
||||
assert!(checked.insert(name), "duplicate fixture {name}");
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
|
||||
assert_eq!(
|
||||
features::signature(&doc),
|
||||
signature,
|
||||
"{name}: imported feature retention changed; review the pinned input and warning changes before updating this baseline"
|
||||
);
|
||||
assert!(
|
||||
doc.saved_by.as_deref().is_some_and(|v| v.starts_with("Affinity 3.")),
|
||||
"{name}: expected an actual Affinity 3 document, got {:?}",
|
||||
doc.saved_by
|
||||
);
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
|
||||
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
|
||||
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
|
||||
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
|
||||
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
|
||||
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
|
||||
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
|
||||
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
|
||||
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
|
||||
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
|
||||
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
|
||||
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
|
||||
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Shared pinned corpus gate: an installed corpus must be complete and unmodified.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "../../../../xtask/src/sha256.rs"]
|
||||
mod sha256;
|
||||
|
||||
pub fn pinned() -> Option<PathBuf> {
|
||||
// This helper is compiled in both affinity and engine integration tests.
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
let required = std::env::var_os("AFFINITY_CORPUS_REQUIRED").is_some_and(|v| v != "0");
|
||||
if !dir.exists() {
|
||||
assert!(!required, "required Affinity corpus absent: run `cargo xtask corpus --affinity`");
|
||||
eprintln!("Affinity corpus absent: run `cargo xtask corpus --affinity` (CI sets AFFINITY_CORPUS_REQUIRED=1)");
|
||||
return None;
|
||||
}
|
||||
assert!(dir.join("SOURCES.md").is_file(), "incomplete Affinity corpus: missing SOURCES.md; run `cargo xtask corpus --affinity`");
|
||||
let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
|
||||
let mut count = 0;
|
||||
for line in manifest.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let (digest, name) = line.split_once(" ").expect("malformed pinned Affinity SHA256 manifest");
|
||||
assert_eq!(digest.len(), 64, "invalid SHA256 for {name}");
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("missing pinned Affinity file {name}: {e}"));
|
||||
assert_eq!(sha256::hex(&bytes), digest, "{name}: pinned corpus modified; run `cargo xtask corpus --affinity`");
|
||||
count += 1;
|
||||
}
|
||||
assert!(count >= 4, "pinned Affinity manifest must include the four Affinity 3 .af fixtures");
|
||||
Some(dir)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Structural regression signatures, measured from the pinned files through our own reader.
|
||||
//! These freeze feature retention; they are not independent visual or compatibility oracles.
|
||||
//! The model CRC covers every decoded field via its Debug representation (including paths,
|
||||
//! paint, masks, image pixels and text runs). A deliberate reader/Debug change needs review;
|
||||
//! input authenticity is independently checked with SHA256 by the corpus gate.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use vectorcraft_affinity::model::{Document, Kind, Node};
|
||||
use vectorcraft_affinity::paint::Paint;
|
||||
|
||||
pub fn signature(doc: &Document) -> String {
|
||||
fn visit(nodes: &[Node], counts: &mut BTreeMap<&'static str, usize>, fonts: &mut BTreeSet<String>, boards: &mut Vec<String>, depth: usize) {
|
||||
for n in nodes {
|
||||
*counts.entry("nodes").or_default() += 1;
|
||||
counts.entry("depth").and_modify(|v| *v = (*v).max(depth)).or_insert(depth);
|
||||
let key = match &n.kind {
|
||||
Kind::Layer => "layers",
|
||||
Kind::Group => "groups",
|
||||
Kind::Artboard { rect, .. } => {
|
||||
boards.push(format!("{}:{:.4},{:.4},{:.4},{:.4}", n.name, rect.x0, rect.y0, rect.x1, rect.y1));
|
||||
"artboards"
|
||||
}
|
||||
Kind::Shape { path, fills, strokes, .. } => {
|
||||
*counts.entry("subpaths").or_default() += path.subpaths.len();
|
||||
*counts.entry("segments").or_default() += path.subpaths.iter().map(|p| p.segments.len()).sum::<usize>();
|
||||
*counts.entry("fills").or_default() += fills.len();
|
||||
*counts.entry("strokes").or_default() += strokes.len();
|
||||
*counts.entry("gradients").or_default() += fills.iter().filter(|p| matches!(p, Paint::Gradient(_))).count();
|
||||
"shapes"
|
||||
}
|
||||
Kind::Text(t) => {
|
||||
*counts.entry("text_runs").or_default() += t.runs.len();
|
||||
*counts.entry("text_chars").or_default() += t.runs.iter().map(|r| r.text.chars().count()).sum::<usize>();
|
||||
for r in &t.runs {
|
||||
fonts.insert(format!("{}:{}:{}:{}", r.family, r.postscript, r.weight, r.italic));
|
||||
}
|
||||
"texts"
|
||||
}
|
||||
Kind::Image(_) => "images",
|
||||
Kind::Unsupported => "unsupported",
|
||||
};
|
||||
*counts.entry(key).or_default() += 1;
|
||||
for (key, yes) in
|
||||
[("hidden", !n.visible), ("vector_masks", n.mask.is_some()), ("pixel_masks", n.pixel_mask.is_some()), ("opacity", n.opacity < 1.0)]
|
||||
{
|
||||
if yes {
|
||||
*counts.entry(key).or_default() += 1;
|
||||
}
|
||||
}
|
||||
visit(&n.children, counts, fonts, boards, depth + 1);
|
||||
}
|
||||
}
|
||||
let (mut counts, mut fonts, mut boards) = (BTreeMap::new(), BTreeSet::new(), Vec::new());
|
||||
for spread in &doc.spreads {
|
||||
visit(&spread.nodes, &mut counts, &mut fonts, &mut boards, 1);
|
||||
}
|
||||
let model_crc32 = crc32fast::hash(format!("{:?}", doc.spreads).as_bytes());
|
||||
format!(
|
||||
"dpi={};spreads={};model_crc32={model_crc32:08x};counts={counts:?};boards={boards:?};fonts={fonts:?};warnings={:?}",
|
||||
doc.dpi,
|
||||
doc.spreads.len(),
|
||||
doc.warnings
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Original synthetic reader fixtures, shared by native and engine regression tests.
|
||||
//! These describe the reader's existing schema; no Affinity software or third-party parser was
|
||||
//! used to create them. They prove recovery/mapping behavior, not Affinity's layout or exporter.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
|
||||
pub fn array_f64(field: &[u8; 4], values: &[f64]) -> F {
|
||||
let mut bytes = vec![0x8a];
|
||||
bytes.extend(tag(field).0.to_le_bytes());
|
||||
bytes.extend((values.len() as u32).to_le_bytes());
|
||||
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
|
||||
F::Raw(bytes)
|
||||
}
|
||||
|
||||
pub fn array_i32(field: &[u8; 4], values: &[i32]) -> F {
|
||||
let mut bytes = vec![0x87];
|
||||
bytes.extend(tag(field).0.to_le_bytes());
|
||||
bytes.extend((values.len() as u32).to_le_bytes());
|
||||
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
|
||||
F::Raw(bytes)
|
||||
}
|
||||
|
||||
pub fn solid(alpha: f32) -> F {
|
||||
F::Obj(
|
||||
tag(b"FilS"),
|
||||
vec![(
|
||||
tag(b"Colr"),
|
||||
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, alpha].iter().flat_map(|v| v.to_le_bytes()).collect()))]),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn attrs(family: &str, postscript: &str, weight: i32, italic: bool, size: f64) -> F {
|
||||
let mut doubles = vec![0.0; 14];
|
||||
doubles[0] = size;
|
||||
doubles[4] = 0.025;
|
||||
doubles[10] = 1.0;
|
||||
doubles[13] = 30.0;
|
||||
F::Obj(
|
||||
tag(b"GAtt"),
|
||||
vec![
|
||||
(
|
||||
tag(b"DFnt"),
|
||||
F::Obj(
|
||||
tag(b"Font"),
|
||||
vec![
|
||||
(tag(b"Famy"), F::Str(family.into())),
|
||||
(tag(b"Post"), F::Str(postscript.into())),
|
||||
(tag(b"Wegt"), F::I32(weight)),
|
||||
(tag(b"Ital"), F::Bool(italic)),
|
||||
],
|
||||
),
|
||||
),
|
||||
(tag(b"Doub"), array_f64(b"Doub", &doubles)),
|
||||
(tag(b"Ints"), array_i32(b"Ints", &[0, 0, 0, 0, 1])),
|
||||
(tag(b"Objs"), F::Shared(vec![solid(1.0)])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run(end: i32, attrs: Option<F>) -> Vec<(vectorcraft_affinity::stream::Tag, F)> {
|
||||
let mut fields = vec![(tag(b"Indx"), F::I32(end))];
|
||||
if let Some(attrs) = attrs {
|
||||
fields.push((tag(b"Item"), attrs));
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub fn block(glyphs: F, runs: Vec<Vec<(vectorcraft_affinity::stream::Tag, F)>>, alignments: &[i32]) -> F {
|
||||
F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), glyphs),
|
||||
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), runs))])),
|
||||
(
|
||||
tag(b"PAtt"),
|
||||
F::Obj(
|
||||
tag(b"PaAS"),
|
||||
vec![(
|
||||
tag(b"Runs"),
|
||||
F::Objs(
|
||||
tag(b"PaAR"),
|
||||
alignments
|
||||
.iter()
|
||||
.map(|a| vec![(tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[*a]))]))])
|
||||
.collect(),
|
||||
),
|
||||
)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn utf8(text: &str) -> F {
|
||||
F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str(text.into()))])
|
||||
}
|
||||
|
||||
pub fn document(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec<u8> {
|
||||
let text = F::Obj(
|
||||
tag(if frame { b"TxtF" } else { b"TxtA" }),
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
|
||||
(
|
||||
tag(b"TxtH"),
|
||||
F::Obj(
|
||||
tag(if frame { b"FrFr" } else { b"ArFr" }),
|
||||
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
|
||||
),
|
||||
),
|
||||
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
|
||||
],
|
||||
);
|
||||
let mut root = vec![
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Obj(
|
||||
tag(b"DocN"),
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Obj(
|
||||
tag(b"Sprd"),
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![text]))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
];
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
let stream = synth::stream(&root);
|
||||
synth::container(&[("doc.dat", &stream, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
||||
|
||||
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
|
||||
// fixture objects independent shared IDs without making the fixture descriptions noisy.
|
||||
fn shared_objects(value: &mut F, next_id: &mut u32) {
|
||||
match value {
|
||||
F::Shared(items) => {
|
||||
for item in items {
|
||||
if let F::Obj(class, fields) = item {
|
||||
let class = *class;
|
||||
let fields = std::mem::take(fields);
|
||||
*next_id += 1;
|
||||
*item = F::Def(*next_id, vec![class], fields);
|
||||
}
|
||||
shared_objects(item, next_id);
|
||||
}
|
||||
}
|
||||
F::Obj(_, fields) | F::Def(_, _, fields) => {
|
||||
for (_, value) in fields {
|
||||
shared_objects(value, next_id);
|
||||
}
|
||||
}
|
||||
F::Objs(_, items) => {
|
||||
for fields in items {
|
||||
for (_, value) in fields {
|
||||
shared_objects(value, next_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! 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 vectorcraft_affinity::{
|
||||
Document, Limits,
|
||||
model::{Align, Kind, Text},
|
||||
paint::{Color, Paint},
|
||||
synth::{F, tag},
|
||||
};
|
||||
|
||||
fn read(blocks: Vec<F>) -> Document {
|
||||
vectorcraft_affinity::read(&document(blocks, false, 96.0, IDENTITY), Limits::default()).unwrap()
|
||||
}
|
||||
fn text(d: &Document) -> &Text {
|
||||
match &d.spreads[0].nodes[0].kind {
|
||||
Kind::Text(t) => t,
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
fn content(t: &Text) -> String {
|
||||
t.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
fn warning(d: &Document, needle: &str) -> bool {
|
||||
d.warnings.iter().any(|w| w.contains(needle))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_fonts_keep_unicode_and_attribute_boundaries() {
|
||||
let d = read(vec![block(
|
||||
utf8("Aé😀e\u{301}אב日本\u{2028}x\u{2029}\0"),
|
||||
vec![
|
||||
run(3, Some(attrs("Family One", "FamilyOne-Regular", 400, false, 16.0))),
|
||||
run(9, Some(attrs("Family Two", "FamilyTwo-BoldItalic", 700, true, 24.0))),
|
||||
run(13, Some(attrs("Family Three", "FamilyThree-Light", 300, false, 20.0))),
|
||||
],
|
||||
&[1],
|
||||
)]);
|
||||
let t = text(&d);
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["Aé😀", "e\u{301}אב日本", "\u{2028}x\u{2029}"]);
|
||||
assert_eq!(content(t), "Aé😀e\u{301}אב日本\u{2028}x\u{2029}");
|
||||
assert_eq!(
|
||||
(t.runs[1].family.as_str(), t.runs[1].postscript.as_str(), t.runs[1].weight, t.runs[1].italic),
|
||||
("Family Two", "FamilyTwo-BoldItalic", 700, true)
|
||||
);
|
||||
assert_eq!((t.runs[1].size, t.runs[1].tracking, t.runs[1].leading), (24.0, 0.025, Some(30.0)));
|
||||
assert!(matches!(t.runs[1].fill, Paint::Solid(Color::Rgb { a: 1.0, .. })));
|
||||
assert_eq!(t.align, Align::Center);
|
||||
assert_eq!((t.anchor.x, t.anchor.y), (110.0, 38.0));
|
||||
assert!(d.warnings.is_empty(), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_attributes_and_uncovered_tail_preserve_characters() {
|
||||
for runs in [vec![], vec![run(2, None)], vec![run(2, Some(attrs("First", "", 400, false, 14.0)))]] {
|
||||
let d = read(vec![block(utf8("é😀 tail\0"), runs, &[])]);
|
||||
assert_eq!(content(text(&d)), "é😀 tail");
|
||||
assert!(warning(&d, "text without character attributes"));
|
||||
assert!(matches!(text(&d).runs.last().unwrap().fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
|
||||
assert_eq!(text(&d).runs.last().unwrap().size, 16.0); // Recovery default: 12 pt at 96 dpi.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ranges_do_not_discard_the_story() {
|
||||
let mut missing = run(1, None);
|
||||
missing.clear();
|
||||
let d = read(vec![block(
|
||||
utf8("abcdef"),
|
||||
vec![missing, run(-1, None), run(3, Some(attrs("First", "", 400, false, 10.0))), run(2, None), run(i32::MAX, None)],
|
||||
&[],
|
||||
)]);
|
||||
assert_eq!(content(text(&d)), "abcdef");
|
||||
assert_eq!(text(&d).runs[0].text, "abc");
|
||||
assert!(warning(&d, "invalid text attribute range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_fields_count_for_ranges_but_are_reported_and_omitted() {
|
||||
let glyphs = F::Obj(
|
||||
tag(b"GStr"),
|
||||
vec![(
|
||||
tag(b"Mixd"),
|
||||
F::Objs(
|
||||
tag(b"MixS"),
|
||||
vec![
|
||||
vec![(tag(b"Glys"), F::Shared(vec![F::Obj(tag(b"FldN"), vec![])])), (tag(b"Utf8"), F::Str("é😀".into()))],
|
||||
vec![(tag(b"Utf8"), F::Str("尾\0".into()))],
|
||||
],
|
||||
),
|
||||
)],
|
||||
);
|
||||
let d =
|
||||
read(vec![block(glyphs, vec![run(3, Some(attrs("First", "", 400, false, 10.0))), run(5, Some(attrs("Second", "", 700, false, 15.0)))], &[])]);
|
||||
assert_eq!(text(&d).runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "尾"]);
|
||||
assert!(warning(&d, "text fields such as page numbers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_paragraph_alignments_are_explicitly_reported() {
|
||||
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
|
||||
assert_eq!(content(text(&d)), "one\u{2029}two");
|
||||
assert_eq!(text(&d).align, Align::Right);
|
||||
assert!(warning(&d, "mixed paragraph alignments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_alignment_and_invalid_font_weight_have_honest_fallbacks() {
|
||||
let d = read(vec![block(utf8("test"), vec![run(4, Some(attrs("", "OnlyPostScript", -50, true, f64::NAN)))], &[99])]);
|
||||
assert_eq!((text(&d).runs[0].weight, text(&d).runs[0].size), (400, 16.0));
|
||||
assert_eq!(text(&d).align, Align::Left);
|
||||
assert!(warning(&d, "invalid font weight"));
|
||||
assert!(warning(&d, "unknown paragraph alignment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_block_does_not_remove_later_readable_text() {
|
||||
let d = read(vec![F::Obj(tag(b"StBl"), vec![]), block(utf8("still here"), vec![], &[])]);
|
||||
assert_eq!(content(text(&d)), "still here");
|
||||
assert!(warning(&d, "a text block without readable characters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_budget_preserves_prefix_and_reports_truncation() {
|
||||
let d = read(vec![block(utf8(&"é".repeat((1 << 20) + 1)), vec![], &[]), block(utf8("later"), vec![], &[])]);
|
||||
assert_eq!(content(text(&d)).chars().count(), 1 << 20);
|
||||
assert!(content(text(&d)).chars().all(|c| c == 'é'));
|
||||
assert!(warning(&d, "text longer than a million characters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_opentype_and_observed_all_caps_selector_are_kept() {
|
||||
let mut a = attrs("Family", "", 400, false, 12.0);
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
vec![(
|
||||
tag(b"Setn"),
|
||||
F::Objs(
|
||||
tag(b"OTFS"),
|
||||
vec![
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"salt"))), (tag(b"Valu"), F::I32(2))],
|
||||
],
|
||||
),
|
||||
)],
|
||||
);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let objects = fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap();
|
||||
objects.1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
|
||||
}
|
||||
let d = read(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])]);
|
||||
assert_eq!(text(&d).runs[0].features, ["smcp", "-liga"]);
|
||||
assert!(text(&d).runs[0].all_caps);
|
||||
assert_eq!(content(text(&d)), "Mixed");
|
||||
assert!(warning(&d, "non-boolean OpenType settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_fallback_font_is_used_only_when_it_names_a_font() {
|
||||
for (family, expected) in [("", "Requested"), ("Emoji Family", "Emoji Family")] {
|
||||
let mut a = attrs("Requested", "RequestedPS", 700, true, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
fields.push((tag(b"RFnt"), F::Obj(tag(b"Font"), vec![(tag(b"Famy"), F::Str(family.into())), (tag(b"Wegt"), F::I32(400))])));
|
||||
}
|
||||
let d = read(vec![block(utf8("😀"), vec![run(1, Some(a))], &[])]);
|
||||
assert_eq!(text(&d).runs[0].family, expected);
|
||||
assert_eq!(warning(&d, "stored fallback font"), !family.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
|
||||
let Some(corpus) = support::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();
|
||||
let d = load("patchy-text-runs.af");
|
||||
let t = text(&d);
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["AB", "cd", "É", "😀", "X"]);
|
||||
assert_eq!(t.runs[3].family, "Segoe UI Emoji");
|
||||
assert_eq!(t.runs[4].family, "Courier New");
|
||||
assert!(warning(&d, "stored fallback font"));
|
||||
let d = load("patchy-text-caps.af");
|
||||
let texts: Vec<_> = d.spreads[0].nodes.iter().filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }).collect();
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert!(texts[0].runs[0].all_caps);
|
||||
assert_eq!(texts[1].runs[0].features, ["smcp"]);
|
||||
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
|
||||
let d = load(name);
|
||||
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
|
||||
#[test]
|
||||
fn arbitrary_unicode_survives_damaged_attribute_ranges(
|
||||
chars in proptest::collection::vec(proptest::prelude::any::<char>(), 0..128),
|
||||
ends in proptest::collection::vec(-10i32..200, 0..32),
|
||||
) {
|
||||
let original: String = chars.into_iter().collect();
|
||||
let expected: String = original.chars().filter(|c| *c != '\0' && *c != '\u{FFFC}').collect();
|
||||
let d = read(vec![block(utf8(&original), ends.into_iter().map(|end| run(end, None)).collect(), &[])]);
|
||||
proptest::prop_assert_eq!(content(text(&d)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_missing_attributes_use_visible_fill_but_explicit_no_fill_is_kept() {
|
||||
let empty = F::Obj(tag(b"GAtt"), vec![]);
|
||||
let none = F::Obj(tag(b"GAtt"), vec![(tag(b"Objs"), F::Shared(vec![F::Obj(tag(b"FilN"), vec![])]))]);
|
||||
let d = read(vec![block(utf8("ab"), vec![run(1, Some(empty)), run(2, Some(none))], &[])]);
|
||||
assert!(matches!(text(&d).runs[0].fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
|
||||
assert_eq!(text(&d).runs[1].fill, Paint::None);
|
||||
assert_eq!(content(text(&d)), "ab");
|
||||
assert!(warning(&d, "without readable fill attributes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_feature_settings_are_bounded_and_reported() {
|
||||
let mut a = attrs("Family", "", 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
vec![(
|
||||
tag(b"Setn"),
|
||||
F::Objs(
|
||||
tag(b"OTFS"),
|
||||
(0..257).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
|
||||
),
|
||||
)],
|
||||
);
|
||||
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
|
||||
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
|
||||
}
|
||||
let d = read(vec![block(utf8("x"), vec![run(1, Some(a))], &[])]);
|
||||
assert_eq!(text(&d).runs[0].features.len(), 256);
|
||||
assert!(warning(&d, "more than 256 text feature settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
vec![(
|
||||
tag(b"Setn"),
|
||||
F::Objs(
|
||||
tag(b"OTFS"),
|
||||
(0..256).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
|
||||
),
|
||||
)],
|
||||
);
|
||||
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
|
||||
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
|
||||
}
|
||||
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
||||
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
let metadata: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
.flat_map(|r| [&r.family, &r.postscript].into_iter().chain(r.features.iter()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.len() + std::mem::size_of::<String>())
|
||||
.sum();
|
||||
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
||||
assert_eq!(t.runs[0].family.len(), 16 * 1024);
|
||||
assert_eq!(t.runs[0].features.len(), 256);
|
||||
assert!(t.runs.last().unwrap().family.is_empty());
|
||||
assert!(t.runs.last().unwrap().features.is_empty());
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_gradient_attributes_are_bounded_without_losing_characters() {
|
||||
let color =
|
||||
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
|
||||
let mut colors = vec![color];
|
||||
colors.extend((1..1024).map(|_| F::Ref(43)));
|
||||
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
|
||||
let gradient = F::Obj(
|
||||
tag(b"FilG"),
|
||||
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
|
||||
);
|
||||
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![gradient]))]);
|
||||
let mut runs = vec![run(1, Some(attrs))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
let stop_count: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| match &r.fill {
|
||||
Paint::Gradient(g) => g.stops.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
assert!(stop_count > 0);
|
||||
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
|
||||
let Paint::Gradient(first) = &t.runs[0].fill else { panic!("first gradient must fit") };
|
||||
assert_eq!(first.stops.len(), 1024);
|
||||
assert!(matches!(t.runs.last().unwrap().fill, Paint::Solid(_)));
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
|
||||
}
|
||||
+312
-12
@@ -1,7 +1,7 @@
|
||||
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
|
||||
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
|
||||
//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read
|
||||
//! only ([`read_bytes`]).
|
||||
//! headers), swatch exchange `.ase` files (binary: solid colors in their own model, global, spot
|
||||
//! and process colors, color groups) and CSS custom properties (written only).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -16,18 +16,22 @@ pub enum PaletteFormat {
|
||||
Native,
|
||||
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
|
||||
Gpl,
|
||||
/// `.ase`: a swatch exchange file (binary): solid colors in their own model, global, spot or
|
||||
/// process, and color groups.
|
||||
Ase,
|
||||
/// `.css`: custom properties on `:root` (colours and gradients; written only).
|
||||
Css,
|
||||
}
|
||||
|
||||
impl PaletteFormat {
|
||||
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
|
||||
pub const ALL: [PaletteFormat; 4] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase, PaletteFormat::Css];
|
||||
|
||||
/// The format's id, which is also its extension.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PaletteFormat::Native => "vcswatches",
|
||||
PaletteFormat::Gpl => "gpl",
|
||||
PaletteFormat::Ase => "ase",
|
||||
PaletteFormat::Css => "css",
|
||||
}
|
||||
}
|
||||
@@ -35,6 +39,7 @@ impl PaletteFormat {
|
||||
match self {
|
||||
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
|
||||
PaletteFormat::Gpl => "GPL Palette (.gpl)",
|
||||
PaletteFormat::Ase => "Swatch Exchange (.ase)",
|
||||
PaletteFormat::Css => "CSS Custom Properties (.css)",
|
||||
}
|
||||
}
|
||||
@@ -47,6 +52,10 @@ impl PaletteFormat {
|
||||
pub fn readable(self) -> bool {
|
||||
self != PaletteFormat::Css
|
||||
}
|
||||
/// Are files of this format binary rather than text?
|
||||
pub fn binary(self) -> bool {
|
||||
self == PaletteFormat::Ase
|
||||
}
|
||||
}
|
||||
|
||||
/// The native file: a header around the library.
|
||||
@@ -71,11 +80,13 @@ const ASE_MAX_ENTRIES: usize = 100_000;
|
||||
const ASE_MAX_NAME: usize = 1024;
|
||||
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
|
||||
|
||||
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
|
||||
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
|
||||
/// Write `lib` in `format` → the file's bytes. Pattern swatches (their tiles live in a document)
|
||||
/// and None are left out; `.gpl` keeps solid colours only, as 8-bit RGB, and `.ase` keeps solid
|
||||
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
|
||||
/// [`read_bytes`] reads from one file.
|
||||
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
|
||||
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
|
||||
match format {
|
||||
let text = match format {
|
||||
PaletteFormat::Native => {
|
||||
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
|
||||
let library = SwatchLibrary {
|
||||
@@ -87,8 +98,10 @@ pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
|
||||
serde_json::to_string_pretty(&file).unwrap_or_default()
|
||||
}
|
||||
PaletteFormat::Gpl => write_gpl(lib),
|
||||
PaletteFormat::Ase => return write_ase(lib),
|
||||
PaletteFormat::Css => write_css(lib, keep),
|
||||
}
|
||||
};
|
||||
Ok(text.into_bytes())
|
||||
}
|
||||
|
||||
/// One line of text: no line breaks or tabs.
|
||||
@@ -184,6 +197,102 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `.ase`, as [`read_ase`] reads it: version 1.0, the ungrouped colors, then each color group as a
|
||||
/// group start (its name), its colors and a group end, also for a group without colors. A color is
|
||||
/// written in its own model: RGB, CMYK and gray components from 0 to 1 (gray as a level, 1 is
|
||||
/// white), Lab lightness as a fraction of 100 and a and b from −128 to 127. Components are clamped
|
||||
/// to those ranges, and a component that isn't a finite number is written as 0. The color type is
|
||||
/// 1 for a spot color, 0 for another global color and 2 for a process color.
|
||||
///
|
||||
/// A swatch exchange file holds solid colors only: gradients, patterns and None are left out, and
|
||||
/// a tint swatch is written as the color it shows, a process color without its link to its base.
|
||||
/// The file has no library name ([`read_ase`] names a library after its file). More than
|
||||
/// [`ASE_MAX_ENTRIES`] colors and groups is an error.
|
||||
fn write_ase(lib: &SwatchLibrary) -> Result<Vec<u8>, String> {
|
||||
let solid = |list: &[Swatch]| list.iter().filter(|w| w.paint.color().is_some()).count();
|
||||
let entries = solid(&lib.swatches) + lib.groups.iter().map(|g| 1 + solid(&g.swatches)).sum::<usize>();
|
||||
if entries > ASE_MAX_ENTRIES {
|
||||
return Err(format!(
|
||||
"VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries}"
|
||||
));
|
||||
}
|
||||
let mut body = vec![];
|
||||
let mut blocks = put_ase_colors(&mut body, &lib.swatches)?;
|
||||
for g in &lib.groups {
|
||||
let mut name = vec![];
|
||||
put_ase_name(&mut name, &g.name)?;
|
||||
put_ase_block(&mut body, ASE_GROUP_START, &name)?;
|
||||
blocks += 2 + put_ase_colors(&mut body, &g.swatches)?;
|
||||
put_ase_block(&mut body, ASE_GROUP_END, &[])?;
|
||||
}
|
||||
let blocks = u32::try_from(blocks).map_err(|_| "too many swatch exchange blocks".to_string())?;
|
||||
let mut out = ASE_SIGNATURE.to_vec();
|
||||
out.extend(1u16.to_be_bytes());
|
||||
out.extend(0u16.to_be_bytes());
|
||||
out.extend(blocks.to_be_bytes());
|
||||
out.extend(body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The color blocks of the solid colors in `list` → how many were written.
|
||||
fn put_ase_colors(out: &mut Vec<u8>, list: &[Swatch]) -> Result<usize, String> {
|
||||
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let ab = |v: f32| if v.is_finite() { v.clamp(-128.0, 127.0) } else { 0.0 };
|
||||
let mut n = 0;
|
||||
for (w, color) in list.iter().filter_map(|w| Some((w, w.paint.color()?))) {
|
||||
let mut body = vec![];
|
||||
put_ase_name(&mut body, &w.name)?;
|
||||
let (model, values): (&[u8; 4], Vec<f32>) = match color {
|
||||
Color::Rgb { r, g, b } => (b"RGB ", vec![unit(r), unit(g), unit(b)]),
|
||||
Color::Cmyk { c, m, y, k } => (b"CMYK", vec![unit(c), unit(m), unit(y), unit(k)]),
|
||||
Color::Lab { l, a, b } => (b"LAB ", vec![unit(l / 100.0), ab(a), ab(b)]),
|
||||
Color::Gray { k } => (b"Gray", vec![1.0 - unit(k)]),
|
||||
};
|
||||
body.extend(model);
|
||||
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
|
||||
let kind: u16 = if w.spot {
|
||||
1
|
||||
} else if w.global {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
};
|
||||
body.extend(kind.to_be_bytes());
|
||||
put_ase_block(out, ASE_COLOR, &body)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// A block: its type, the length of its body and the body.
|
||||
fn put_ase_block(out: &mut Vec<u8>, kind: u16, body: &[u8]) -> Result<(), String> {
|
||||
let len = u32::try_from(body.len()).map_err(|_| "a swatch exchange block is too long".to_string())?;
|
||||
out.extend(kind.to_be_bytes());
|
||||
out.extend(len.to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name as `.ase` stores it: a `u16` count of UTF-16 code units, the terminating zero included,
|
||||
/// then the units. NUL characters are left out, and a name longer than [`ASE_MAX_NAME`] code units
|
||||
/// is cut before the character that would pass that limit.
|
||||
fn put_ase_name(out: &mut Vec<u8>, name: &str) -> Result<(), String> {
|
||||
let mut units: Vec<u16> = vec![];
|
||||
for c in name.chars().filter(|&c| c != '\0') {
|
||||
let mut buf = [0; 2];
|
||||
let encoded = c.encode_utf16(&mut buf);
|
||||
if units.len() + encoded.len() > ASE_MAX_NAME {
|
||||
break;
|
||||
}
|
||||
units.extend_from_slice(encoded);
|
||||
}
|
||||
units.push(0);
|
||||
let count = u16::try_from(units.len()).map_err(|_| "a swatch exchange name is too long".to_string())?;
|
||||
out.extend(count.to_be_bytes());
|
||||
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
|
||||
/// file doesn't.
|
||||
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
|
||||
@@ -442,6 +551,7 @@ impl UniqueNames {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Gradient, GradientPaint, GradientStop};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn sample() -> SwatchLibrary {
|
||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
@@ -468,10 +578,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `lib` written as text format `f`.
|
||||
fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String {
|
||||
String::from_utf8(write(lib, f).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_round_trip_keeps_cmyk_spot_and_groups() {
|
||||
let lib = sample();
|
||||
let text = write(&lib, PaletteFormat::Native);
|
||||
let text = written(&lib, PaletteFormat::Native);
|
||||
assert!(sniff(&text));
|
||||
let back = read(&text, "fallback").unwrap();
|
||||
let mut expected = lib.clone();
|
||||
@@ -484,7 +599,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
|
||||
let text = write(&sample(), PaletteFormat::Gpl);
|
||||
let text = written(&sample(), PaletteFormat::Gpl);
|
||||
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
|
||||
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
|
||||
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
|
||||
@@ -514,7 +629,7 @@ mod tests {
|
||||
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
|
||||
assert_eq!(css_property("Café 50%"), "--café-50\\%");
|
||||
assert_eq!(css_property(" "), "--swatch");
|
||||
let text = write(&sample(), PaletteFormat::Css);
|
||||
let text = written(&sample(), PaletteFormat::Css);
|
||||
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
|
||||
assert!(text.contains(" --ink: #"), "{text}");
|
||||
assert!(text.contains("; /* spot */"));
|
||||
@@ -528,7 +643,7 @@ mod tests {
|
||||
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
|
||||
groups: vec![],
|
||||
};
|
||||
let text = write(&lib, PaletteFormat::Css);
|
||||
let text = written(&lib, PaletteFormat::Css);
|
||||
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
|
||||
assert!(read(&text, "x").is_err(), "CSS is written only");
|
||||
}
|
||||
@@ -645,4 +760,189 @@ mod tests {
|
||||
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
|
||||
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_writes_each_model_kind_and_group() {
|
||||
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
|
||||
let tint = Swatch {
|
||||
name: "Ink 40%".into(),
|
||||
paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 },
|
||||
global: false,
|
||||
spot: false,
|
||||
};
|
||||
// `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals.
|
||||
let mut lib = sample();
|
||||
lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false));
|
||||
lib.swatches.push(tint);
|
||||
lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)];
|
||||
lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] });
|
||||
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
|
||||
let expected = ase(&[
|
||||
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
|
||||
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
|
||||
ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2),
|
||||
(ASE_GROUP_START, ase_name("Neutrals")),
|
||||
ase_color("Mist", b"Gray", &[0.75], 2),
|
||||
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
|
||||
(ASE_GROUP_END, vec![]),
|
||||
(ASE_GROUP_START, ase_name("Empty")),
|
||||
(ASE_GROUP_END, vec![]),
|
||||
]);
|
||||
assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows");
|
||||
let back = read_bytes(&bytes, "Brand").unwrap();
|
||||
assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays");
|
||||
assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase));
|
||||
assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_names_lose_nul_and_stop_at_the_reader_limit() {
|
||||
let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))];
|
||||
let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect();
|
||||
let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] };
|
||||
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
|
||||
let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect();
|
||||
// At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji.
|
||||
assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_clamps_components_and_writes_non_finite_ones_as_zero() {
|
||||
let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false };
|
||||
let swatches = vec![
|
||||
sw("Lab", Color::lab(100.5, 300.0, -300.0)),
|
||||
sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)),
|
||||
sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)),
|
||||
sw("Gray", Color::gray(1.5)),
|
||||
];
|
||||
let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap();
|
||||
let expected = ase(&[
|
||||
ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2),
|
||||
ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2),
|
||||
ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2),
|
||||
ase_color("Gray", b"Gray", &[0.0], 2),
|
||||
]);
|
||||
assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0");
|
||||
// Lightness 100.5 is written as 1, which reads back as L* 100.
|
||||
let back = read_bytes(&bytes, "x").unwrap();
|
||||
assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_write_refuses_more_entries_than_the_reader_takes() {
|
||||
let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false };
|
||||
// The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it.
|
||||
let mut lib = SwatchLibrary {
|
||||
name: "x".into(),
|
||||
swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(),
|
||||
groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }],
|
||||
};
|
||||
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
|
||||
assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1));
|
||||
lib.swatches.push(gray(0));
|
||||
let e = write(&lib, PaletteFormat::Ase).unwrap_err();
|
||||
assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}");
|
||||
// A gradient is left out and is not counted.
|
||||
lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone();
|
||||
assert!(write(&lib, PaletteFormat::Ase).is_ok());
|
||||
}
|
||||
|
||||
/// A color in one of the four models, in the ranges `.ase` holds.
|
||||
fn arb_color() -> impl Strategy<Value = Color> {
|
||||
let unit = || 0.0f32..=1.0;
|
||||
prop_oneof![
|
||||
(unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)),
|
||||
(unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)),
|
||||
unit().prop_map(Color::gray),
|
||||
(0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)),
|
||||
]
|
||||
}
|
||||
|
||||
/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch
|
||||
/// (both flags off) or a gradient.
|
||||
fn arb_paint() -> impl Strategy<Value = (Paint, bool, bool)> {
|
||||
prop_oneof![
|
||||
4 => (arb_color(), any::<bool>(), any::<bool>()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)),
|
||||
1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)),
|
||||
1 => Just((sample().swatches[1].paint.clone(), false, false)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script
|
||||
/// but NUL, trimmed, so swatches and groups have unique names, as in a document.
|
||||
fn arb_library() -> impl Strategy<Value = SwatchLibrary> {
|
||||
let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12);
|
||||
(swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| {
|
||||
let mut n = 0;
|
||||
let mut name = |prefix: &str, base: &str| {
|
||||
n += 1;
|
||||
format!("{prefix}{n} {base}").trim().to_string()
|
||||
};
|
||||
let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() };
|
||||
for ((paint, global, spot), base) in loose {
|
||||
lib.swatches.push(Swatch { name: name("", &base), paint, global, spot });
|
||||
}
|
||||
for (base, list) in groups {
|
||||
let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] };
|
||||
for ((paint, global, spot), base) in list {
|
||||
g.swatches.push(Swatch { name: name("", &base), paint, global, spot });
|
||||
}
|
||||
lib.groups.push(g);
|
||||
}
|
||||
lib
|
||||
})
|
||||
}
|
||||
|
||||
/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and
|
||||
/// gray read back within `f32` rounding.
|
||||
fn close(got: Color, want: Color) -> bool {
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
match (got, want) {
|
||||
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2),
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2),
|
||||
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot
|
||||
/// colors global.
|
||||
fn ase_expected(list: &[Swatch]) -> Vec<Swatch> {
|
||||
list.iter()
|
||||
.filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`?
|
||||
fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool {
|
||||
got.len() == want.len()
|
||||
&& got.iter().zip(want).all(|(g, w)| {
|
||||
let color = match (&g.paint, w.paint.color()) {
|
||||
(Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c),
|
||||
_ => false,
|
||||
};
|
||||
g.name == w.name && g.global == w.global && g.spot == w.spot && color
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
/// A written `.ase` file reads back as the library's solid colors in their own model, with
|
||||
/// their names, kinds and color groups.
|
||||
#[test]
|
||||
fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) {
|
||||
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
|
||||
prop_assert!(sniff_bytes(&bytes));
|
||||
let back = read_bytes(&bytes, &lib.name).unwrap();
|
||||
prop_assert_eq!(&back.name, &lib.name);
|
||||
prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches);
|
||||
prop_assert_eq!(back.groups.len(), lib.groups.len());
|
||||
for (g, want) in back.groups.iter().zip(&lib.groups) {
|
||||
prop_assert_eq!(&g.name, &want.name);
|
||||
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -96,7 +96,7 @@ pub use vectorcraft_geom as geom;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
@@ -741,7 +741,8 @@ impl Document {
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||||
self.next_id = self.next_id.max(max + 1);
|
||||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||||
}
|
||||
|
||||
/// Find a node anywhere in the tree.
|
||||
@@ -1006,6 +1007,13 @@ impl Document {
|
||||
*g = g.moved(d);
|
||||
}
|
||||
}
|
||||
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
|
||||
/// with Artboard).
|
||||
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
|
||||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||||
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
|
||||
}
|
||||
}
|
||||
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
|
||||
/// artboard).
|
||||
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
|
||||
|
||||
@@ -255,6 +255,34 @@ impl Align {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Rewrite linked file locations in both raster images and placed VectorCraft documents,
|
||||
/// including those inside symbols, patterns and opacity masks. Used to make packages portable.
|
||||
pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) {
|
||||
fn walk(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut LinkInfo)) {
|
||||
let n = Arc::make_mut(n);
|
||||
match &mut n.kind {
|
||||
NodeKind::Image(image) => {
|
||||
if let Some(link) = &mut image.link {
|
||||
f(link);
|
||||
}
|
||||
}
|
||||
NodeKind::PlacedDocument(placed) => f(&mut placed.link),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(mask) = &mut n.mask {
|
||||
walk(&mut mask.art, f);
|
||||
}
|
||||
for child in n.children_mut().into_iter().flatten() {
|
||||
walk(child, f);
|
||||
}
|
||||
}
|
||||
let roots =
|
||||
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||||
for root in roots {
|
||||
walk(root, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
|
||||
/// art included) for which `pick` holds; only the subtrees holding one are copied.
|
||||
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
|
||||
|
||||
@@ -146,7 +146,7 @@ impl LiveShape {
|
||||
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
|
||||
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
|
||||
}
|
||||
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
|
||||
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
|
||||
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct CharStyle {
|
||||
pub font_family: String,
|
||||
#[serde(default = "regular")]
|
||||
pub font_style: String,
|
||||
/// Which installed version of the font (its version string) when several versions of the
|
||||
/// family and style are installed and the text was set in one the family and style alone
|
||||
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_version: Option<String>,
|
||||
/// Size in points.
|
||||
pub size: f64,
|
||||
/// Leading in points; None = Auto (120% of size).
|
||||
@@ -175,6 +180,7 @@ impl Default for CharStyle {
|
||||
Self {
|
||||
font_family: "Source Sans 3".into(),
|
||||
font_style: "Regular".into(),
|
||||
font_version: None,
|
||||
size: 12.0,
|
||||
leading: None,
|
||||
tracking: 0.0,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
@@ -125,6 +125,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -290,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
STYLIZE,
|
||||
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black",
|
||||
json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}),
|
||||
),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Stylize › Glowing Edges.
|
||||
|
||||
use super::blur_plane;
|
||||
|
||||
/// Find Sobel edges, smooth and expand them, then draw coloured outlines on black. Samples outside
|
||||
/// the raster are transparent.
|
||||
pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightness: f64, smoothness: f64) {
|
||||
if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) {
|
||||
return;
|
||||
}
|
||||
let src = px.to_vec();
|
||||
let pixel = |x: isize, y: isize| -> ([f32; 3], f32) {
|
||||
// Beyond the raster's edges is transparency (not the next row's first pixel).
|
||||
let (Ok(x), Ok(y)) = (usize::try_from(x), usize::try_from(y)) else { return ([0.0; 3], 0.0) };
|
||||
if x >= w || y >= h {
|
||||
return ([0.0; 3], 0.0);
|
||||
}
|
||||
let Some(p) = src.get(y * w + x) else { return ([0.0; 3], 0.0) };
|
||||
let alpha = f32::from(p[3]) / 255.0;
|
||||
let straight =
|
||||
if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] };
|
||||
(straight, alpha)
|
||||
};
|
||||
let signal = |x: isize, y: isize| {
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
|
||||
};
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
let mut colours: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0.0; px.len()]);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let x = x as isize;
|
||||
let y = y as isize;
|
||||
let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1)
|
||||
+ signal(x - 1, y + 1)
|
||||
+ 2.0 * signal(x, y + 1)
|
||||
+ signal(x + 1, y + 1);
|
||||
let alpha_signal = |x, y| pixel(x, y).1;
|
||||
let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y)
|
||||
- alpha_signal(x - 1, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1)
|
||||
+ alpha_signal(x - 1, y + 1)
|
||||
+ 2.0 * alpha_signal(x, y + 1)
|
||||
+ alpha_signal(x + 1, y + 1);
|
||||
let index = y as usize * w + x as usize;
|
||||
let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0);
|
||||
if let Some(value) = edges.get_mut(index) {
|
||||
*value = edge;
|
||||
let (rgb, alpha) = pixel(x, y);
|
||||
for channel in 0..3 {
|
||||
colours[channel][index] = rgb[channel] * alpha * edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
blur_plane(&mut edges, w, h, smoothness.max(0.0));
|
||||
if width > 0.5 {
|
||||
blur_plane(&mut edges, w, h, width * 0.5);
|
||||
}
|
||||
let peak = edges.iter().copied().fold(0.0f32, f32::max);
|
||||
if peak > 0.0 {
|
||||
for colour in &mut colours {
|
||||
blur_plane(colour, w, h, smoothness.max(0.0));
|
||||
if width > 0.5 {
|
||||
blur_plane(colour, w, h, width * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, (out, (original, edge))) in px.iter_mut().zip(src.iter().zip(edges)).enumerate() {
|
||||
let alpha = f32::from(original[3]) / 255.0;
|
||||
let glow = if peak > 0.0 { (edge / peak * brightness as f32 / 6.0).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let colour_weight = colours[0][index].max(colours[1][index]).max(colours[2][index]);
|
||||
let straight = if colour_weight > 1e-6 { std::array::from_fn(|channel| colours[channel][index] / colour_weight) } else { [0.0; 3] };
|
||||
// Premultiplied: the glow is light of the edge's colour, so outside the shape it keeps that
|
||||
// colour at the glow's opacity instead of darkening as it fades.
|
||||
let a = alpha.max(glow);
|
||||
*out = [
|
||||
(straight[0] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(straight[1] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(straight[2] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||
(a * 255.0).round() as u8,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
|
||||
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
|
||||
//! Mask, and Stylize › Glowing Edges): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
@@ -9,6 +9,7 @@
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod blur;
|
||||
mod edges;
|
||||
mod sharpen;
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -17,7 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use crate::util::{num, text};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
|
||||
pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.glowingEdges"];
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
@@ -33,6 +34,8 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie in the document.
|
||||
@@ -69,6 +72,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -85,6 +93,7 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +104,7 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +117,7 @@ impl PixelFx {
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ fn params_take_defaults_and_stay_in_range() {
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
|
||||
);
|
||||
assert_eq!(fx("stylize.glowingEdges", json!({})), PixelFx::GlowingEdges { width: 2.0, brightness: 6.0, smoothness: 5.0 });
|
||||
for id in PIXEL_EFFECTS {
|
||||
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
|
||||
}
|
||||
@@ -116,10 +117,49 @@ fn every_effect_is_deterministic() {
|
||||
assert_eq!(a, b, "{id}");
|
||||
assert_ne!(a, src, "{id} changes the image");
|
||||
// Premultiplied stays premultiplied.
|
||||
assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||||
assert!(a.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glowing_edges_draw_a_bright_outline_and_preserve_premultiplication() {
|
||||
let (w, h) = (9, 9);
|
||||
let src = image(w, h, |x, _| if x < 4 { [0, 0, 0, 200] } else { [200, 200, 200, 200] });
|
||||
let mut out = src.clone();
|
||||
fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0);
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
|
||||
}
|
||||
|
||||
/// Glowing Edges sees transparency beyond every side of the raster: a solid square glows alike at
|
||||
/// its left and right edges (the right one doesn't read the next row's first pixel).
|
||||
#[test]
|
||||
fn glowing_edges_glow_alike_on_every_side() {
|
||||
let (w, h) = (9, 9);
|
||||
let mut out = image(w, h, |_, _| [180, 180, 180, 255]);
|
||||
fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h));
|
||||
let (left, right, top, bottom) = (at(&out, w, 0, 4), at(&out, w, 8, 4), at(&out, w, 4, 0), at(&out, w, 4, 8));
|
||||
assert!(left[0] > 0 && left == right && top == bottom, "{left:?} {right:?} {top:?} {bottom:?}");
|
||||
assert!(at(&out, w, 4, 4)[0] < left[0], "the inside stays darker than the edges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glowing_edges_defaults_keep_the_outline_bright_and_use_source_colour() {
|
||||
let (w, h) = (17, 17);
|
||||
let src = image(w, h, |x, y| if (4..13).contains(&x) && (4..13).contains(&y) { [180, 0, 0, 255] } else { [0; 4] });
|
||||
let mut out = src.clone();
|
||||
fx("stylize.glowingEdges", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
|
||||
let outline = at(&out, w, 4, 8);
|
||||
assert!(outline[0] >= 170, "default outline should retain a near-full-color peak: {outline:?}");
|
||||
let outside = at(&out, w, 3, 8);
|
||||
assert_eq!(outside[1], 0, "transparent halo must not be grey: {outside:?}");
|
||||
assert_eq!(outside[2], 0, "transparent halo must retain source hue: {outside:?}");
|
||||
// The glow outside keeps the edge's colour as it fades, rather than darkening towards grey.
|
||||
assert!(outside[3] > 0 && u32::from(outside[0]) * 255 >= 240 * u32::from(outside[3]), "{outside:?}");
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
|
||||
let (w, h) = (41, 41);
|
||||
@@ -196,9 +236,9 @@ fn lengths_are_document_units() {
|
||||
fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
let (w, h) = (30, 30);
|
||||
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
|
||||
let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
let before: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum();
|
||||
gaussian_rgba(&mut d, w, h, 2.0);
|
||||
let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
let after: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum();
|
||||
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
|
||||
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
|
||||
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
|
||||
|
||||
@@ -154,11 +154,18 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
// The layer's id is above every copied object's, so the document saves and reopens as
|
||||
// a native file.
|
||||
let mut top = 0;
|
||||
for n in &self.nodes {
|
||||
n.walk(&mut |c| top = top.max(c.id.0));
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(top.saturating_add(1)), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
d.fix_next_id();
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_text::embed::Embedding;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -77,13 +78,17 @@ pub struct Section {
|
||||
pub rows: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// What a font's `fsType` lets a copy of it do.
|
||||
/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the
|
||||
/// most permissive usage bit wins, and bitmap-only fonts may not be embedded.
|
||||
pub fn embedding_label(fs_type: u16) -> &'static str {
|
||||
match fs_type & 0x000f {
|
||||
0x0002 => "embedding not allowed",
|
||||
0x0004 => "embedding for preview and print",
|
||||
0x0008 => "embedding for editing",
|
||||
_ => "embedding allowed",
|
||||
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
|
||||
"embedding not allowed"
|
||||
} else if fs_type & 0x0008 != 0 {
|
||||
"embedding for editing"
|
||||
} else if fs_type & 0x0004 != 0 {
|
||||
"embedding for preview and print"
|
||||
} else {
|
||||
"embedding allowed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +97,7 @@ type ImageRow = (String, u32, u32, Option<String>);
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
let names = |k: &str| -> Vec<(String, String)> {
|
||||
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
|
||||
};
|
||||
@@ -130,22 +135,32 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
|
||||
"patterns" => names("patternNames"),
|
||||
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
|
||||
"symbols" => names("symbols"),
|
||||
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
|
||||
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
|
||||
"fontDetails" => {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
fonts
|
||||
.iter()
|
||||
.map(|(family, style)| {
|
||||
let detail = match db.face(family, style) {
|
||||
Some(f) if f.family.eq_ignore_ascii_case(family) => {
|
||||
.map(|used| {
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names (in the version the type names); a style the
|
||||
// family lacks shows in another.
|
||||
let resolved =
|
||||
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
|
||||
let detail = match resolved {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
let file =
|
||||
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
|
||||
format!("{file}; {}", embedding_label(f.fs_type()))
|
||||
let found = format!("{file}; {}", embedding_label(f.fs_type()));
|
||||
if m == vectorcraft_text::FontMatch::Style {
|
||||
format!("substituted: shown in {} {} ({found})", f.family, f.style)
|
||||
} else {
|
||||
found
|
||||
}
|
||||
}
|
||||
Some(f) => format!("missing: shown in {} {}", f.family, f.style),
|
||||
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
|
||||
None => "missing".into(),
|
||||
};
|
||||
(format!("{family} {style}"), detail)
|
||||
(super::fonts::font_label(used), detail)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -232,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Compound { .. } => Some("compoundPaths"),
|
||||
NodeKind::Text(t) => {
|
||||
for run in &t.runs {
|
||||
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
|
||||
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone()));
|
||||
}
|
||||
Some("textObjects")
|
||||
}
|
||||
@@ -289,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
@@ -396,6 +411,12 @@ mod tests {
|
||||
assert_eq!(embedding_label(0x0002), "embedding not allowed");
|
||||
assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print");
|
||||
assert_eq!(embedding_label(0x0008), "embedding for editing");
|
||||
// As exports read it: bitmap embedding only, the restricted bit with reserved bit 0, the
|
||||
// most permissive of several usage bits.
|
||||
assert_eq!(embedding_label(0x0200), "embedding not allowed");
|
||||
assert_eq!(embedding_label(0x0003), "embedding not allowed");
|
||||
assert_eq!(embedding_label(0x0006), "embedding for preview and print");
|
||||
assert_eq!(embedding_label(0x000c), "embedding for editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! File (document-level) and Edit commands, plus document queries.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -250,29 +250,39 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &json!({}), true);
|
||||
}
|
||||
// Copy promotes a compound member to its compound. Cut must remove the same objects it
|
||||
// copied, while Clear on its own deletes exactly the selected members.
|
||||
let ids = selected_roots(s)?;
|
||||
copy(s, p)?;
|
||||
clear(s, &json!({}))
|
||||
if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) }
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
|
||||
// Direct-selected anchors: delete those anchors instead of whole objects.
|
||||
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
return super::path::delete_anchors(s, p);
|
||||
}
|
||||
// Selected ruler guides (selected on their own).
|
||||
if ids_param(p, "ids").is_none() && s.doc()?.selection.is_empty() {
|
||||
if explicit.is_none() && s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_remove(s, &json!({}));
|
||||
}
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
|
||||
// Keep the selected ids themselves, without promoting compound members. Delete a selected
|
||||
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
|
||||
let targets: BTreeSet<_> = targets.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())).collect();
|
||||
let ids: Vec<_> = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !st.doc.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor)))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Clear", |d, sel| {
|
||||
for id in &ids {
|
||||
if d.node(*id).is_some_and(|n| n.is_layer()) {
|
||||
continue;
|
||||
}
|
||||
let _ = d.remove(*id);
|
||||
d.remove(*id)?;
|
||||
}
|
||||
sel.clear();
|
||||
Ok(())
|
||||
@@ -287,6 +297,8 @@ enum PasteMode {
|
||||
Back,
|
||||
InPlace,
|
||||
AllArtboards,
|
||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||
Library,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
@@ -297,6 +309,7 @@ impl PasteMode {
|
||||
PasteMode::InPlace => "Paste in Place",
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
PasteMode::Library => "Place from Library",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +323,14 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Place the objects of `clip` (a library graphic) into the active document centred on `center`,
|
||||
/// as one undo step: their resources come along as a paste's do, a swatch whose name the document
|
||||
/// gives another colour merged into the document's.
|
||||
pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_geom::Point) -> Result<Value> {
|
||||
let choices = SwatchChoices::parse(PasteMode::Library.label(), &json!({}))?;
|
||||
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
@@ -348,7 +369,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
|
||||
@@ -206,6 +206,13 @@ impl Builder {
|
||||
/// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to
|
||||
/// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer.
|
||||
fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option<Arc<Node>> {
|
||||
// Register before descending so nested artboards keep their document order.
|
||||
if let Kind::Artboard { rect, .. } = &n.kind {
|
||||
boards.push((
|
||||
if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() },
|
||||
to_doc.transform_rect_bbox(rect_of(*rect)),
|
||||
));
|
||||
}
|
||||
let child_level = matches!(n.kind, Kind::Layer) && layer_level;
|
||||
let children: Vec<Arc<Node>> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect();
|
||||
let mut out = match &n.kind {
|
||||
@@ -241,14 +248,18 @@ impl Builder {
|
||||
self.base(n, NodeKind::Group { children: kids, clip: true })
|
||||
}
|
||||
}
|
||||
Kind::Artboard { rect, background } => {
|
||||
let r = to_doc.transform_rect_bbox(rect_of(*rect));
|
||||
boards.push((if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, r));
|
||||
Kind::Artboard { path, even_odd, background, strokes, .. } => {
|
||||
let mut clip = Node::new(
|
||||
self.doc.alloc_id(),
|
||||
NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
|
||||
NodeKind::Path {
|
||||
path: self.path(path, to_doc),
|
||||
rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero },
|
||||
live: None,
|
||||
clipping: true,
|
||||
guide: false,
|
||||
},
|
||||
);
|
||||
clip.appearance = self.appearance(background, &[], to_doc);
|
||||
clip.appearance = self.appearance(background, strokes, to_doc);
|
||||
let mut kids = vec![Arc::new(clip)];
|
||||
kids.extend(children);
|
||||
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
|
||||
@@ -432,9 +443,27 @@ impl Builder {
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut style =
|
||||
CharStyle { font_family: if r.family.is_empty() { r.postscript.clone() } else { r.family.clone() }, ..CharStyle::default() };
|
||||
style.font_style = font_style(r.weight, r.italic);
|
||||
let mut style = CharStyle::default();
|
||||
if !r.family.is_empty() {
|
||||
style.font_family = r.family.clone();
|
||||
} else if !r.postscript.is_empty() {
|
||||
style.font_family = r.postscript.clone();
|
||||
} else {
|
||||
self.warn("text without a font name uses the default font");
|
||||
}
|
||||
// A family/weight pair cannot identify width variants such as Arial Narrow.
|
||||
// Resolve a known PostScript identity to the exact installed family and style.
|
||||
if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) {
|
||||
style.font_family = family;
|
||||
style.font_style = face_style;
|
||||
} else {
|
||||
style.font_style = font_style(r.weight, r.italic);
|
||||
}
|
||||
style.features = r.features.clone();
|
||||
if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) {
|
||||
self.warn("unsupported text OpenType features (retained but not rendered)");
|
||||
}
|
||||
style.all_caps = r.all_caps;
|
||||
style.size = r.size * k;
|
||||
style.leading = r.leading.map(|l| l * k);
|
||||
style.tracking = r.tracking * 1000.0;
|
||||
|
||||
@@ -137,11 +137,12 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
|
||||
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
||||
if opts.pages.is_some() || !opts.layers {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return (doc, warnings);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings);
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
|
||||
// The note that the art outside the artboard is lost is the reason the layers weren't read.
|
||||
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
|
||||
return (doc, warnings);
|
||||
@@ -150,6 +151,20 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
|
||||
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers or the
|
||||
/// editing data are off), an error when it can't be read.
|
||||
fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(Document, Vec<String>)>> {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return None;
|
||||
}
|
||||
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
|
||||
Some(
|
||||
vectorcraft_eps::ai_alone(&private)
|
||||
.map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -164,7 +179,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import(bytes).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
Ok((r.document, r.warnings))
|
||||
})?
|
||||
}
|
||||
@@ -188,9 +203,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
|
||||
.flatten()
|
||||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || {
|
||||
let (doc, warnings) = super::pdfimport::import(bytes, opts)?;
|
||||
Ok(through_editing_data(bytes, opts, doc, warnings))
|
||||
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
|
||||
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
}
|
||||
"dxf" => {
|
||||
@@ -209,6 +228,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// Threaded type read from a file flows through its frames.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
}
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
@@ -81,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
@@ -724,6 +725,22 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never
|
||||
/// replaced. When the write fails part way, the file it started is removed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write as _;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
|
||||
let written = f.write_all(bytes).and_then(|()| f.sync_all());
|
||||
drop(f);
|
||||
written.map_err(|e| {
|
||||
// Best effort: the partly written file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(path);
|
||||
err(e)
|
||||
})
|
||||
}
|
||||
|
||||
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
|
||||
/// keeps one); `None` when there is no file at `path`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -785,6 +802,11 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
|
||||
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
|
||||
/// case: `Icon` and `icon` are one file on most desktop file systems).
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
|
||||
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
||||
/// to show).
|
||||
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
||||
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
||||
if vectorcraft_render::raster_size(r, scale).is_err() {
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options ac
Line truncated
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null k
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
||||
@@ -32,6 +32,10 @@ pub struct LoadOptions {
|
||||
pub layers: bool,
|
||||
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
|
||||
pub dxf: vectorcraft_cad::ImportOptions,
|
||||
/// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its
|
||||
/// layers, hidden objects and the art outside its artboards); else only what its page or PDF
|
||||
/// part prints comes in.
|
||||
pub editing_data: bool,
|
||||
}
|
||||
|
||||
impl Default for LoadOptions {
|
||||
@@ -45,6 +49,7 @@ impl Default for LoadOptions {
|
||||
text_as: TextAs::default(),
|
||||
layers: true,
|
||||
dxf: Default::default(),
|
||||
editing_data: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +57,8 @@ impl Default for LoadOptions {
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false) and `dxf`
|
||||
/// (the DXF import options).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
|
||||
None => None,
|
||||
@@ -75,8 +81,12 @@ impl LoadOptions {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
let editing_data = match p.get("editingData").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true or false"))?,
|
||||
};
|
||||
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf })
|
||||
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
|
||||
@@ -396,3 +396,15 @@ fn oversized_raster_exports_are_refused() {
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_never_replace_a_file() {
|
||||
let d = tmp_dir("new-file");
|
||||
let path = d.join("Brand.ase").to_string_lossy().to_string();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
write_new_file(&path, b"first").unwrap();
|
||||
let e = write_new_file(&path, b"second").unwrap_err().to_string();
|
||||
assert!(e.starts_with(&format!("{path}: ")), "{e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
@@ -91,3 +91,69 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
}
|
||||
|
||||
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
|
||||
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
check_sample(&s);
|
||||
// Its editing data damaged: it says so (the placeholder isn't the art).
|
||||
let e = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib", PLACEHOLDER), json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_data_false_opens_only_what_the_page_prints() {
|
||||
let mut s = Session::new();
|
||||
for (name, file) in [("art.eps", ai::eps(&ai::sample_data(), ai::page_ps())), ("art.ai", ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()))]
|
||||
{
|
||||
let r = open(&mut s, name, &file, json!({"editingData": false})).unwrap();
|
||||
let layers = &s.doc().unwrap().doc.layers;
|
||||
assert_eq!(layers.len(), 1, "{name}: {r}");
|
||||
assert_ne!(names(layers), ["Art"], "{name}: the page's layer");
|
||||
}
|
||||
let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string();
|
||||
assert!(e.contains("editingData must be true or false"), "{e}");
|
||||
// A file that is only its editing data can't open without it.
|
||||
let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() {
|
||||
// A shown square; a hidden one and a long guide far from it, which the page doesn't draw.
|
||||
let art =
|
||||
format!("0 g\n{}f\n1 Xw\n{}f\n0 Xw\n-500 20 m\n700 20 L\n(N) *\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(150.0, 60.0, 190.0, 95.0));
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "fit.eps", &ai::eps(data.as_bytes(), "0 setgray 10 10 40 40 rectfill\n"), json!({})).unwrap();
|
||||
let layer = &s.doc().unwrap().doc.layers[0];
|
||||
let kids = layer.children().unwrap();
|
||||
assert_eq!(kids.len(), 3, "{r}");
|
||||
assert!(!kids[1].visible && matches!(kids[2].kind, NodeKind::Path { guide: true, .. }));
|
||||
s.execute("artboard.fitToArt", &json!({})).unwrap();
|
||||
// The square, 50 to 90 down the 100-point page.
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 50.0, 50.0, 90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part() {
|
||||
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{text}", ai::rect(10.0, 10.0, 50.0, 50.0))));
|
||||
let file = ai::ai(&compressed(data.as_bytes()), "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET");
|
||||
let mut s = Session::new();
|
||||
// As type, the page's type goes into the text object of the layers.
|
||||
let r = open(&mut s, "type.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
// As outlines, only the PDF part has them.
|
||||
let r = open(&mut s, "type.ai", &file, json!({"textAs": "outlines"})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Looking through a folder the user picks, and its subfolders, for files of some kind, on separate
|
||||
//! threads: up to 8 walker threads share one stack of folders and list each folder once. They
|
||||
//! follow no links and don't enter other apps' or the system's folders, hidden folders or folders
|
||||
//! whose contents aren't on the disk ([`Rules`]); the time, the entries listed, the folders queued,
|
||||
//! the depth and the files read are capped ([`Limits`]). [`start`] returns at once,
|
||||
//! [`Search::progress`] reports where a search stands and [`Search::stop`] ends it. A search reads
|
||||
//! the files [`Visitor::wants`] accepts by their names and [`Visitor::worth_reading`] by the files
|
||||
//! themselves, and keeps those [`Visitor::items`] gives wanted items for.
|
||||
//! The web has no file system: [`start`] fails there.
|
||||
|
||||
mod rules;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod walk;
|
||||
|
||||
pub use rules::{Rules, plain};
|
||||
pub use walk::{End, Limits, Progress, Search, Visitor, start, threads};
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Where a folder search goes: not into other apps' or the system's folders. They are told by
|
||||
//! their names and places (an app package, a home folder's `Library` or `AppData`, the system
|
||||
//! folders at the root of a volume), never by the name of an app. Names and paths compare in
|
||||
//! lowercase.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Extensions of the packages apps and media libraries keep their files in.
|
||||
const PACKAGE_EXTENSIONS: &[&str] = &[
|
||||
"app",
|
||||
"appex",
|
||||
"bundle",
|
||||
"component",
|
||||
"fcpbundle",
|
||||
"framework",
|
||||
"imovielibrary",
|
||||
"kext",
|
||||
"musiclibrary",
|
||||
"photolibrary",
|
||||
"photoslibrary",
|
||||
"plugin",
|
||||
"tvlibrary",
|
||||
"xpc",
|
||||
];
|
||||
|
||||
/// The folders Windows installs programs and keeps their data and the system's in, wherever they
|
||||
/// are.
|
||||
const INSTALL_FOLDERS: &[&str] =
|
||||
&["program files", "program files (x86)", "program files (arm)", "programdata", "$recycle.bin", "system volume information"];
|
||||
|
||||
/// The folders a home folder keeps apps and their data in (besides its hidden ones).
|
||||
const HOME_APP_FOLDERS: &[&str] = &["library", "appdata", "applications", "snap"];
|
||||
|
||||
/// The folders a `Library` folder keeps apps' data in (macOS).
|
||||
const LIBRARY_APP_FOLDERS: &[&str] = &["application support", "containers", "group containers", "caches", "preferences"];
|
||||
|
||||
/// The folders beside the home folders that apps share (macOS `Shared`, Windows `Public`).
|
||||
const SHARED_FOLDERS: &[&str] = &["shared", "public"];
|
||||
|
||||
/// The system's folders at the root of a macOS volume (or a copy of one).
|
||||
const MAC_SYSTEM: &[&str] =
|
||||
&["applications", "bin", "cores", "dev", "etc", "home", "library", "net", "network", "opt", "private", "sbin", "system", "tmp", "usr", "var"];
|
||||
|
||||
/// The system's folders at the root of a Linux or BSD system (or a copy of one).
|
||||
const UNIX_SYSTEM: &[&str] = &[
|
||||
"bin",
|
||||
"boot",
|
||||
"dev",
|
||||
"efi",
|
||||
"etc",
|
||||
"gnu",
|
||||
"lib",
|
||||
"lib32",
|
||||
"lib64",
|
||||
"libx32",
|
||||
"lost+found",
|
||||
"nix",
|
||||
"opt",
|
||||
"proc",
|
||||
"root",
|
||||
"run",
|
||||
"sbin",
|
||||
"snap",
|
||||
"sys",
|
||||
"usr",
|
||||
"var",
|
||||
];
|
||||
|
||||
/// The system's folders at the root of a Windows drive (or a copy of one), besides the install
|
||||
/// folders.
|
||||
const WINDOWS_SYSTEM: &[&str] =
|
||||
&["windows", "windows.old", "recovery", "perflogs", "boot", "config.msi", "msocache", "$windows.~bt", "$windows.~ws", "$winreagent", "$sysreset"];
|
||||
|
||||
/// The most entries of a folder read to tell what kind of folder it is.
|
||||
const MAX_SIBLINGS: usize = 100_000;
|
||||
|
||||
/// The names of the folders that show a folder is the root of a system ([`system_folders`]).
|
||||
const SYSTEM_SIGNS: &[&str] = &["users", "system", "library", "applications", "windows", "program files", "etc", "usr", "bin", "sbin"];
|
||||
|
||||
/// Which of [`SYSTEM_SIGNS`] are among the names of a folder's folders and links.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Siblings(u16);
|
||||
|
||||
impl Siblings {
|
||||
/// The folders and links named `names` (in any case).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn of(names: &[&str]) -> Self {
|
||||
let mut s = Self::default();
|
||||
names.iter().for_each(|n| s.note(n));
|
||||
s
|
||||
}
|
||||
|
||||
/// Note a folder or link named `name` (in any case).
|
||||
pub(crate) fn note(&mut self, name: &str) {
|
||||
if let Some(i) = SYSTEM_SIGNS.iter().position(|s| s.eq_ignore_ascii_case(name)) {
|
||||
self.0 |= 1 << i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder or link named `name`, one of [`SYSTEM_SIGNS`], was noted.
|
||||
fn has(self, name: &str) -> bool {
|
||||
SYSTEM_SIGNS.iter().position(|s| *s == name).is_some_and(|i| self.0 & (1 << i) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a search may not start in a folder ([`Rules::refusal`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refusal {
|
||||
/// The folder is in other apps' or the system's folders, or in an app package.
|
||||
Apps,
|
||||
/// The folder is in the Shared or Public folder of a `Users` folder.
|
||||
Shared,
|
||||
}
|
||||
|
||||
/// A folder a walk lists, as the rules see it ([`Rules::listed`]).
|
||||
pub(crate) struct Listed {
|
||||
/// The folder, in lowercase.
|
||||
dir: PathBuf,
|
||||
/// Its name, in lowercase.
|
||||
name: String,
|
||||
/// The system folders in it, when it is the root of a system.
|
||||
system: Option<&'static [&'static str]>,
|
||||
/// It is a home folder.
|
||||
home: bool,
|
||||
}
|
||||
|
||||
/// Where a search may go.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rules {
|
||||
/// The home folder (canonical).
|
||||
pub home: Option<PathBuf>,
|
||||
/// Folders searched when picked, whatever holds them (canonical): the temporary folders, and
|
||||
/// tests' folders.
|
||||
pub user_content: Vec<PathBuf>,
|
||||
/// Other apps' and the system's folders the environment names (canonical): never entered, and a
|
||||
/// search of a folder inside one fails.
|
||||
pub refused: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// `path` in lowercase and, on Windows, without its `\\?\` prefix ([`unprefixed`]), for comparing
|
||||
/// paths.
|
||||
fn lower(path: &Path) -> PathBuf {
|
||||
let text = path.to_string_lossy();
|
||||
let plain = if cfg!(windows) { unprefixed(&text) } else { None };
|
||||
PathBuf::from(plain.as_deref().unwrap_or(&text).to_lowercase())
|
||||
}
|
||||
|
||||
/// The system's folders a volume's root holds, when its folders (`siblings`) show it is the root of
|
||||
/// an operating system.
|
||||
fn system_folders(siblings: Siblings) -> Option<&'static [&'static str]> {
|
||||
let has = |name: &str| siblings.has(name);
|
||||
if has("users") && (has("system") || has("library") || has("applications")) {
|
||||
Some(MAC_SYSTEM)
|
||||
} else if has("windows") && (has("users") || has("program files")) {
|
||||
Some(WINDOWS_SYSTEM)
|
||||
} else if has("etc") && has("usr") && (has("bin") || has("sbin")) {
|
||||
Some(UNIX_SYSTEM)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What the names of the folders and links in `dir` show ([`Siblings`]).
|
||||
fn folder_siblings(dir: &Path) -> Siblings {
|
||||
let mut siblings = Siblings::default();
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().take(MAX_SIBLINGS).flatten() {
|
||||
let name = e.file_name();
|
||||
if let Some(name) = name.to_str()
|
||||
&& e.file_type().is_ok_and(|t| t.is_dir() || t.is_symlink())
|
||||
{
|
||||
siblings.note(name);
|
||||
}
|
||||
}
|
||||
siblings
|
||||
}
|
||||
|
||||
/// The first `n` components of `path`.
|
||||
fn prefix(path: &Path, n: usize) -> PathBuf {
|
||||
path.components().take(n).collect()
|
||||
}
|
||||
|
||||
/// `path` without the `\\?\` prefix that `std::fs::canonicalize` gives paths on Windows: `C:\…`
|
||||
/// for `\\?\C:\…` and `\\server\share\…` for `\\?\UNC\server\share\…`, when every name in it can
|
||||
/// be written without the prefix ([`without_verbatim`]). Other paths, and paths on other systems,
|
||||
/// are returned as they are.
|
||||
pub fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
match path.to_str().and_then(without_verbatim) {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows path `p` without its `\\?\` prefix, when it has one for a drive or a share: `C:\…`
|
||||
/// for `\\?\C:\…` and `\\server\…` for `\\?\UNC\server\…`.
|
||||
pub(crate) fn unprefixed(p: &str) -> Option<String> {
|
||||
if let Some(unc) = p.strip_prefix(r"\\?\UNC\") {
|
||||
return Some(format!(r"\\{unc}"));
|
||||
}
|
||||
let disk = p.strip_prefix(r"\\?\")?;
|
||||
matches!(disk.as_bytes(), [d, b':', b'\\', ..] if d.is_ascii_alphabetic()).then(|| disk.to_string())
|
||||
}
|
||||
|
||||
/// [`unprefixed`], when each name in the path can be written without the prefix ([`portable`]):
|
||||
/// a share's server and share names, and the names below a drive's root or a share.
|
||||
pub(crate) fn without_verbatim(p: &str) -> Option<String> {
|
||||
let plain = unprefixed(p)?;
|
||||
let (names, at_least) = match plain.strip_prefix(r"\\") {
|
||||
Some(unc) => (unc, 2),
|
||||
None => (plain.get(3..)?, 0),
|
||||
};
|
||||
let mut names: Vec<&str> = if names.is_empty() { vec![] } else { names.split('\\').collect() };
|
||||
// A trailing separator.
|
||||
if names.len() > at_least && names.last() == Some(&"") {
|
||||
names.pop();
|
||||
}
|
||||
let ok = names.len() >= at_least && names.iter().all(|n| portable(n));
|
||||
ok.then_some(plain)
|
||||
}
|
||||
|
||||
/// Whether `name` can be a name in a Windows path without the `\\?\` prefix: not `.` or `..`, not
|
||||
/// ending in a dot or a space, without `<>:"/\|?*` or control characters, and not a device name
|
||||
/// (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, these with a superscript digit,
|
||||
/// `CONIN$`, `CONOUT$`), with an extension or not.
|
||||
fn portable(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or(name).trim_end().to_ascii_lowercase();
|
||||
let numbered = |device: &str| {
|
||||
stem.strip_prefix(device).is_some_and(|n| {
|
||||
let mut n = n.chars();
|
||||
n.next().is_some_and(|c| c.is_ascii_digit() || "¹²³".contains(c)) && n.next().is_none()
|
||||
})
|
||||
};
|
||||
let device = ["con", "prn", "aux", "nul", "conin$", "conout$"].contains(&stem.as_str()) || numbered("com") || numbered("lpt");
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.ends_with(['.', ' '])
|
||||
&& !name.chars().any(|c| c < ' ' || "<>:\"/\\|?*".contains(c))
|
||||
&& !device
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
/// This computer's: the home folder, the temporary folders, and the app and system folders the
|
||||
/// environment names.
|
||||
pub fn system() -> Self {
|
||||
let canonical = |p: PathBuf| p.is_absolute().then(|| std::fs::canonicalize(&p).ok()).flatten();
|
||||
let var = |name: &str| std::env::var_os(name).map(PathBuf::from).and_then(canonical);
|
||||
let home = var(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
|
||||
let user_content = [std::env::temp_dir(), PathBuf::from("/tmp")].into_iter().filter_map(canonical).collect();
|
||||
let names: &[&str] = if cfg!(windows) {
|
||||
&["APPDATA", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "SystemRoot"]
|
||||
} else {
|
||||
&[
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"HOST_XDG_CONFIG_HOME",
|
||||
"HOST_XDG_DATA_HOME",
|
||||
"HOST_XDG_CACHE_HOME",
|
||||
"HOST_XDG_STATE_HOME",
|
||||
]
|
||||
};
|
||||
let refused = names.iter().filter_map(|n| var(n)).collect();
|
||||
Rules { home, user_content, refused }
|
||||
}
|
||||
|
||||
/// Whether `dir` is a home folder: the home folder, a folder in a `Users` or `home` folder, or
|
||||
/// a folder the Flatpak document portal exports (`/run/user/<uid>/doc/<id>/<name>`).
|
||||
fn is_home(&self, dir: &Path) -> bool {
|
||||
if self.home.as_ref().is_some_and(|h| lower(h) == lower(dir)) {
|
||||
return true;
|
||||
}
|
||||
let above = dir.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().to_lowercase());
|
||||
if matches!(above.as_deref(), Some("users" | "home")) {
|
||||
return true;
|
||||
}
|
||||
let parts: Vec<String> = dir.components().map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect();
|
||||
matches!(parts.as_slice(), [_, run, user, uid, doc, _, _] if run == "run" && user == "user" && is_number(uid) && doc == "doc")
|
||||
}
|
||||
|
||||
/// The folder `dir`, whose folders show `siblings`, as the rules see it when a walk lists it:
|
||||
/// told once for all the folders in it.
|
||||
pub(crate) fn listed(&self, dir: &Path, siblings: Siblings) -> Listed {
|
||||
Listed {
|
||||
dir: lower(dir),
|
||||
name: dir.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(),
|
||||
system: system_folders(siblings),
|
||||
home: self.is_home(dir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a search never enters the folder `name` in the listed folder `parent`: an app's
|
||||
/// package, a Windows install folder, a system folder at the root of a volume, the folders apps
|
||||
/// share beside the home folders, a `Library` folder's app data, or a home folder's hidden
|
||||
/// folders and app folders.
|
||||
pub(crate) fn never_in(&self, parent: &Listed, name: &str) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
let name = name.as_str();
|
||||
if Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(|e| PACKAGE_EXTENSIONS.contains(&e)) || INSTALL_FOLDERS.contains(&name) {
|
||||
return true;
|
||||
}
|
||||
if parent.system.is_some_and(|system| system.contains(&name)) {
|
||||
return true;
|
||||
}
|
||||
match parent.name.as_str() {
|
||||
"users" if SHARED_FOLDERS.contains(&name) => return true,
|
||||
"library" if LIBRARY_APP_FOLDERS.contains(&name) => return true,
|
||||
_ => {}
|
||||
}
|
||||
parent.home && (name.starts_with('.') || HOME_APP_FOLDERS.contains(&name))
|
||||
}
|
||||
|
||||
/// [`Self::never_in`] the folder `parent`, whose folders show `siblings`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn never(&self, parent: &Path, siblings: Siblings, name: &str) -> bool {
|
||||
self.never_in(&self.listed(parent, siblings), name)
|
||||
}
|
||||
|
||||
/// The home folder, when the listed folder `parent` is the root of a system whose rules skip
|
||||
/// the folder the home folder is in (`/usr/home/me` on FreeBSD, `/var/home/me`): a walk of the
|
||||
/// root reaches it this way.
|
||||
pub(crate) fn home_below(&self, parent: &Listed) -> Option<PathBuf> {
|
||||
let home = self.home.as_ref()?;
|
||||
let system = parent.system?;
|
||||
let below = lower(home).strip_prefix(&parent.dir).ok()?.components().next()?.as_os_str().to_string_lossy().into_owned();
|
||||
system.contains(&below.as_str()).then(|| home.clone())
|
||||
}
|
||||
|
||||
/// Whether `path` is one of the refused folders.
|
||||
pub(crate) fn is_refused(&self, path: &Path) -> bool {
|
||||
let path = lower(path);
|
||||
self.refused.iter().any(|r| lower(r) == path)
|
||||
}
|
||||
|
||||
/// The folders that hold user content whatever holds them, as far as they hold `folder`: the
|
||||
/// home folder, [`Self::user_content`], the cloud storage folders in the home folder's
|
||||
/// `Library` (an app's own iCloud `Documents` folder too), removable media and a user's runtime
|
||||
/// folder on Linux (network shares, the document portal).
|
||||
fn bases(&self, folder: &Path) -> Vec<PathBuf> {
|
||||
let mut v = self.user_content.clone();
|
||||
let lowered = lower(folder);
|
||||
if let Some(home) = &self.home {
|
||||
v.push(home.clone());
|
||||
let library = home.join("Library");
|
||||
v.push(library.join("CloudStorage"));
|
||||
let mobile = library.join("Mobile Documents");
|
||||
v.push(mobile.join("com~apple~CloudDocs"));
|
||||
let depth = mobile.components().count();
|
||||
if lowered.starts_with(lower(&mobile)) && lowered.components().nth(depth + 1).is_some_and(|c| c.as_os_str() == "documents") {
|
||||
v.push(prefix(folder, depth + 2));
|
||||
}
|
||||
}
|
||||
v.push(PathBuf::from("/run/media"));
|
||||
let parts: Vec<String> = folder.components().take(4).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect();
|
||||
if matches!(parts.as_slice(), [_, run, user, uid] if run == "run" && user == "user" && is_number(uid)) {
|
||||
v.push(prefix(folder, 4));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Why a search may not start in `folder` (canonical), or `None` when it may: the deepest base
|
||||
/// ([`Self::bases`]) or refused folder that holds it is a refused one, or a folder below that
|
||||
/// base (or below the root, without one) is one a walk doesn't enter.
|
||||
pub(crate) fn refusal(&self, folder: &Path) -> Option<Refusal> {
|
||||
self.refusal_with(folder, folder_siblings)
|
||||
}
|
||||
|
||||
/// [`Self::refusal`] without listing a folder: the root of a system, told by the folders it
|
||||
/// holds, isn't recognized.
|
||||
pub(crate) fn refusal_unlisted(&self, folder: &Path) -> Option<Refusal> {
|
||||
self.refusal_with(folder, |_| Siblings::default())
|
||||
}
|
||||
|
||||
/// [`Self::refusal`], with `siblings` telling what a folder's folders show.
|
||||
fn refusal_with(&self, folder: &Path, siblings: impl Fn(&Path) -> Siblings) -> Option<Refusal> {
|
||||
let lowered = lower(folder);
|
||||
let held = |p: &PathBuf| lowered.starts_with(lower(p)).then(|| p.components().count());
|
||||
let base = self.bases(folder).iter().filter_map(held).max();
|
||||
let refused = self.refused.iter().filter_map(held).max();
|
||||
let start = match (base, refused) {
|
||||
(_, Some(r)) if base.is_none_or(|b| r >= b) => return Some(Refusal::Apps),
|
||||
(Some(b), _) => b,
|
||||
// From the root of the folder's volume.
|
||||
(None, _) => folder.components().take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)).count(),
|
||||
};
|
||||
let mut parent = prefix(folder, start);
|
||||
for c in folder.components().skip(start) {
|
||||
let child = parent.join(c);
|
||||
let listed = self.listed(&parent, siblings(&parent));
|
||||
let name = c.as_os_str().to_string_lossy().to_lowercase();
|
||||
if listed.name == "users" && SHARED_FOLDERS.contains(&name.as_str()) {
|
||||
return Some(Refusal::Shared);
|
||||
}
|
||||
if self.never_in(&listed, &name) || self.is_refused(&child) {
|
||||
return Some(Refusal::Apps);
|
||||
}
|
||||
parent = child;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` is a number (a user id).
|
||||
fn is_number(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! The folder search: where it goes and where it doesn't, its limits, its threads.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{Siblings, unprefixed, without_verbatim};
|
||||
use super::walk::{Pool, start_in};
|
||||
use super::*;
|
||||
|
||||
/// The walker threads of these tests' searches, apart from the searches of other tests.
|
||||
static TESTS: Pool = Pool::new(32);
|
||||
|
||||
/// [`start`] on the threads of [`TESTS`].
|
||||
fn begin(folder: PathBuf, rules: Rules, limits: Limits, threads: usize, items: usize, visitor: Arc<dyn Visitor>) -> Search {
|
||||
start_in(&TESTS, folder, Some(rules), limits, threads, items, visitor).unwrap()
|
||||
}
|
||||
|
||||
/// Reads `.ttf` files; each is the item its name is in the list.
|
||||
struct Names(&'static [&'static str]);
|
||||
|
||||
impl Visitor for Names {
|
||||
fn wants(&self, name: &str) -> bool {
|
||||
name.ends_with(".ttf")
|
||||
}
|
||||
fn items(&self, path: &Path) -> Vec<usize> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
self.0.iter().position(|n| *n == name).into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads every file, and waits on reading one until its channel sends or closes.
|
||||
struct Blocked(Mutex<mpsc::Receiver<()>>);
|
||||
|
||||
impl Visitor for Blocked {
|
||||
fn wants(&self, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
fn items(&self, _: &Path) -> Vec<usize> {
|
||||
let _ = self.0.lock().unwrap().recv();
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where
|
||||
/// they end in `/`.
|
||||
fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
for p in paths {
|
||||
let path = root.join(p);
|
||||
if p.ends_with('/') {
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
} else {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"font").unwrap();
|
||||
}
|
||||
}
|
||||
plain(std::fs::canonicalize(&root).unwrap())
|
||||
}
|
||||
|
||||
/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder.
|
||||
fn rules(root: &Path) -> Rules {
|
||||
Rules { home: Some(root.join("Users/me")), user_content: vec![root.to_path_buf()], refused: vec![root.join("refused")] }
|
||||
}
|
||||
|
||||
/// The search's progress once it ended (or after 10 seconds).
|
||||
fn wait(s: &Search) -> Progress {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let p = s.progress();
|
||||
if p.end.is_some() || Instant::now() > until {
|
||||
return p;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
/// Search `from` in `root` for the files named `names`, to the end.
|
||||
fn search(root: &Path, from: &str, limits: Limits, threads: usize, names: &'static [&'static str]) -> Progress {
|
||||
let s = begin(root.join(from), rules(root), limits, threads, names.len(), Arc::new(Names(names)));
|
||||
wait(&s)
|
||||
}
|
||||
|
||||
/// The files a search found, relative to `root`, sorted.
|
||||
fn found(root: &Path, p: &Progress) -> Vec<String> {
|
||||
let mut v: Vec<String> = p.hits.iter().map(|(f, _)| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_walk_stays_out_of_other_apps_and_the_systems_folders() {
|
||||
let root = tree(
|
||||
"roles",
|
||||
&[
|
||||
"a/b/c/one.ttf",
|
||||
"Users/me/Documents/two.ttf",
|
||||
"Disk/Fonts/four.ttf",
|
||||
"Disk/Users/you/Documents/four.ttf",
|
||||
// Never entered: each of these folders counts as skipped once.
|
||||
"Program Files/x/three.ttf",
|
||||
"Program Files (Arm)/x/three.ttf",
|
||||
"Users/me/Library/Fonts/three.ttf",
|
||||
"Users/me/.cache/three.ttf",
|
||||
"Users/me/AppData/Roaming/three.ttf",
|
||||
"Users/you/snap/three.ttf",
|
||||
"Users/Shared/x/three.ttf",
|
||||
"Pictures/P.photoslibrary/three.ttf",
|
||||
"Tools/Foo.app/Contents/three.ttf",
|
||||
"Copy/Library/Application Support/x/three.ttf",
|
||||
".hidden/three.ttf",
|
||||
"refused/three.ttf",
|
||||
"Disk/System/Library/Fonts/three.ttf",
|
||||
"Disk/Library/Fonts/three.ttf",
|
||||
"Disk/Applications/three.ttf",
|
||||
"Disk/Users/you/Library/three.ttf",
|
||||
],
|
||||
);
|
||||
let p = search(&root, "", Limits::default(), 3, &["one.ttf", "two.ttf", "three.ttf", "four.ttf"]);
|
||||
assert_eq!(p.end, Some(End::Done));
|
||||
assert_eq!(found(&root, &p), ["Disk/Fonts/four.ttf", "Disk/Users/you/Documents/four.ttf", "Users/me/Documents/two.ttf", "a/b/c/one.ttf"]);
|
||||
assert_eq!(p.skipped, 16);
|
||||
assert_eq!((p.read, p.unreadable), (4, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_folder_inside_other_apps_folders_is_refused() {
|
||||
let root = tree(
|
||||
"picks",
|
||||
&[
|
||||
"Users/me/Library/Fonts/",
|
||||
"Users/me/Library/CloudStorage/Box/F/",
|
||||
"Users/me/Library/Mobile Documents/x~y/Documents/",
|
||||
"Users/me/Library/Mobile Documents/x~y/Other/",
|
||||
"Users/me/.config/fonts/",
|
||||
"Users/Shared/x/",
|
||||
"Disk/Users/",
|
||||
"Disk/System/",
|
||||
"Disk/Library/Fonts/",
|
||||
"Tools/Foo.app/Contents/",
|
||||
"refused/sub/",
|
||||
"Program Files/x/",
|
||||
"Program Files (Arm)/x/",
|
||||
"Users/Public/x/",
|
||||
"a/.b/",
|
||||
"file.ttf",
|
||||
],
|
||||
);
|
||||
for picked in [
|
||||
"Users/me/Library/Fonts",
|
||||
"Users/me/Library/Mobile Documents/x~y/Other",
|
||||
"Users/me/.config/fonts",
|
||||
"Disk/Library/Fonts",
|
||||
"Tools/Foo.app/Contents",
|
||||
"refused",
|
||||
"refused/sub",
|
||||
"Program Files/x",
|
||||
"Program Files (Arm)/x",
|
||||
] {
|
||||
let p = search(&root, picked, Limits::default(), 1, &[]);
|
||||
assert_eq!(p.state(), "failed", "{picked}");
|
||||
assert!(p.error().is_some_and(|e| e.contains("doesn't search other apps' or the system's folders")), "{picked}: {p:?}");
|
||||
}
|
||||
for picked in ["Users/Shared/x", "Users/Public/x"] {
|
||||
let p = search(&root, picked, Limits::default(), 1, &[]);
|
||||
assert!(p.error().is_some_and(|e| e.contains("doesn't search the Shared or Public folder in Users")), "{picked}: {p:?}");
|
||||
}
|
||||
// Cloud folders, a hidden folder of the user's and another system's root are searched when
|
||||
// picked.
|
||||
for picked in ["Users/me/Library/CloudStorage/Box/F", "Users/me/Library/Mobile Documents/x~y/Documents", "a/.b", "Disk", "Disk/Users", ""] {
|
||||
let p = search(&root, picked, Limits::default(), 1, &[]);
|
||||
assert_eq!((p.state(), p.error()), ("done", None), "{picked}");
|
||||
}
|
||||
let p = search(&root, "file.ttf", Limits::default(), 1, &[]);
|
||||
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
|
||||
let p = search(&root, "missing", Limits::default(), 1, &[]);
|
||||
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_ends_at_its_limits_and_when_stopped() {
|
||||
let root = tree("limits", &["one.ttf", "two.ttf", "a/b/c/three.ttf", "x/y.txt"]);
|
||||
let names = &["one.ttf", "two.ttf", "three.ttf", "nowhere.ttf"];
|
||||
// The picked folder and the folders in it are listed, not the ones below them.
|
||||
let p = search(&root, "", Limits { depth: 1, ..Limits::default() }, 2, names);
|
||||
assert_eq!(p.end, Some(End::Done));
|
||||
assert_eq!(found(&root, &p), ["one.ttf", "two.ttf"]);
|
||||
assert_eq!((p.folders, p.skipped), (3, 1));
|
||||
let p = search(&root, "", Limits { entries: 3, ..Limits::default() }, 1, names);
|
||||
assert_eq!((p.state(), p.stopped()), ("stopped", Some("limit")));
|
||||
let p = search(&root, "", Limits { files: 1, ..Limits::default() }, 1, names);
|
||||
assert_eq!((p.stopped(), p.read), (Some("limit"), 1));
|
||||
let p = search(&root, "", Limits { hits: 1, ..Limits::default() }, 1, names);
|
||||
assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 1));
|
||||
// Each item keeps at most `per_item` files.
|
||||
let root = tree("per-item", &["a/one.ttf", "b/one.ttf", "c/one.ttf"]);
|
||||
let p = search(&root, "", Limits { per_item: 2, ..Limits::default() }, 1, &["one.ttf", "nowhere.ttf"]);
|
||||
assert_eq!((p.end, p.hits.len(), p.read), (Some(End::Done), 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_answers_at_its_deadline_while_a_walker_is_blocked() {
|
||||
let root = tree("blocked", &["f.ttf"]);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let s = begin(root.clone(), rules(&root), Limits { seconds: 0.2, ..Limits::default() }, 1, 1, Arc::new(Blocked(Mutex::new(rx))));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let p = s.progress();
|
||||
assert_eq!((p.state(), p.stopped()), ("stopped", Some("time")), "{p:?}");
|
||||
assert!(p.seconds >= 0.2 && p.seconds < 5.0, "{p:?}");
|
||||
let (tx2, rx2) = mpsc::channel();
|
||||
let s2 = begin(root.clone(), rules(&root), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx2))));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while s2.progress().read == 0 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(s2.progress().state(), "searching");
|
||||
s2.stop();
|
||||
assert_eq!(s2.progress().stopped(), Some("stop"));
|
||||
drop((tx, tx2));
|
||||
}
|
||||
|
||||
/// Every file is item 0; reading the second file waits until the channel closes.
|
||||
struct SecondBlocks(Mutex<(usize, mpsc::Receiver<()>)>);
|
||||
|
||||
impl Visitor for SecondBlocks {
|
||||
fn wants(&self, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
fn items(&self, _: &Path) -> Vec<usize> {
|
||||
let mut read = self.0.lock().unwrap();
|
||||
read.0 += 1;
|
||||
if read.0 == 2 {
|
||||
let _ = read.1.recv();
|
||||
}
|
||||
vec![0]
|
||||
}
|
||||
}
|
||||
|
||||
/// A file is kept as soon as it is read: progress shows it while the rest of its folder is read,
|
||||
/// and a search that stops or reaches a limit keeps it.
|
||||
#[test]
|
||||
fn files_found_are_kept_as_they_are_read() {
|
||||
let root = tree("kept", &["a.ttf", "b.ttf", "c.ttf"]);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let s = begin(root.clone(), rules(&root), Limits::default(), 1, 2, Arc::new(SecondBlocks(Mutex::new((0, rx)))));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while s.progress().read < 2 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
let p = s.progress();
|
||||
assert_eq!((p.state(), p.hits.len()), ("searching", 1), "{p:?}");
|
||||
s.stop();
|
||||
drop(tx);
|
||||
let p = wait(&s);
|
||||
assert_eq!(p.stopped(), Some("stop"));
|
||||
assert!(!p.hits.is_empty(), "{p:?}");
|
||||
let p = search(&root, "", Limits { files: 2, ..Limits::default() }, 1, &["a.ttf", "b.ttf", "c.ttf", "nowhere.ttf"]);
|
||||
assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 2), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_ends_once_every_item_has_a_file() {
|
||||
let root = tree("found", &["a/one.ttf", "b/two.ttf", "c/d/e/f/g/other.ttf"]);
|
||||
let p = search(&root, "", Limits::default(), 2, &["one.ttf", "two.ttf"]);
|
||||
assert_eq!((p.end.clone(), found(&root, &p)), (Some(End::Found), vec!["a/one.ttf".to_string(), "b/two.ttf".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_walker_and_eight_find_the_same_files_and_every_search_ends() {
|
||||
let mut paths = vec![];
|
||||
for a in 0..6 {
|
||||
for b in 0..4 {
|
||||
paths.push(format!("d{a}/e{b}/one.ttf"));
|
||||
paths.push(format!("d{a}/e{b}/f/two.ttf"));
|
||||
paths.push(format!("d{a}/e{b}/f/g/skip.txt"));
|
||||
}
|
||||
}
|
||||
let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
|
||||
let root = tree("threads", &paths);
|
||||
let limits = Limits { per_item: 100, ..Limits::default() };
|
||||
let one = search(&root, "", limits, 1, &["one.ttf", "two.ttf", "nowhere.ttf"]);
|
||||
assert_eq!((one.end.clone(), one.hits.len(), one.folders), (Some(End::Done), 48, 1 + 6 + 24 + 24 + 24));
|
||||
for round in 0..25 {
|
||||
for threads in 1..=8 {
|
||||
let p = search(&root, "", limits, threads, &["one.ttf", "two.ttf", "nowhere.ttf"]);
|
||||
assert_eq!(p.end, Some(End::Done), "round {round}, {threads} threads");
|
||||
assert_eq!(found(&root, &p), found(&root, &one), "round {round}, {threads} threads");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fails on `boom.ttf` with a panic: a bug in reading one file.
|
||||
struct Boom;
|
||||
|
||||
impl Visitor for Boom {
|
||||
fn wants(&self, name: &str) -> bool {
|
||||
name.ends_with(".ttf")
|
||||
}
|
||||
fn items(&self, path: &Path) -> Vec<usize> {
|
||||
match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some("boom.ttf") => panic!("a bug"),
|
||||
Some("ok.ttf") => vec![0],
|
||||
_ => vec![1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visitor_that_panics_on_one_file_does_not_end_the_search() {
|
||||
// The file beside the one that trips the bug is found too.
|
||||
let root = tree("boom", &["a/boom.ttf", "a/ok.ttf", "b/c/fine.ttf"]);
|
||||
let s = begin(root.clone(), rules(&root), Limits::default(), 2, 3, Arc::new(Boom));
|
||||
let p = wait(&s);
|
||||
assert_eq!((p.end.clone(), found(&root, &p), p.unreadable), (Some(End::Done), vec!["a/ok.ttf".to_string(), "b/c/fine.ttf".into()], 1));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_are_not_followed_and_unreadable_folders_count() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let root = tree("links", &["outside/x.ttf", "inner/locked/y.ttf"]);
|
||||
let inner = root.join("inner");
|
||||
symlink(root.join("outside"), inner.join("folder")).unwrap();
|
||||
symlink(&inner, inner.join("loop")).unwrap();
|
||||
symlink(root.join("outside/x.ttf"), inner.join("x.ttf")).unwrap();
|
||||
let locked = inner.join("locked");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// Where permissions don't hold (root), the folder reads as any other.
|
||||
let enforced = std::fs::read_dir(&locked).is_err();
|
||||
let p = search(&root, "inner", Limits::default(), 2, &["x.ttf", "y.ttf"]);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(p.end, Some(End::Done));
|
||||
if enforced {
|
||||
assert!(p.hits.is_empty() && p.unreadable == 1, "{p:?}");
|
||||
} else {
|
||||
assert_eq!(found(&root, &p), ["inner/locked/y.ttf"]);
|
||||
}
|
||||
// The linked file isn't a file of the folder; y.ttf is one where the locked folder reads.
|
||||
assert_eq!(p.files, u64::from(!enforced), "{p:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_picked_folder_that_cant_be_listed_fails_the_search() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = tree("unlistable", &["locked/y.ttf"]);
|
||||
let locked = root.join("locked");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// Where permissions don't hold (root), the folder reads as any other.
|
||||
let enforced = std::fs::read_dir(&locked).is_err();
|
||||
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
if enforced {
|
||||
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
|
||||
} else {
|
||||
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_roots_and_homes_are_told_by_what_they_hold() {
|
||||
let r = Rules { home: Some(PathBuf::from("/home/me")), ..Rules::default() };
|
||||
let never = |parent: &str, siblings: Siblings, name: &str| r.never(Path::new(parent), siblings, name);
|
||||
let none = Siblings::default();
|
||||
let mac = Siblings::of(&["Users", "Library", "System", "Applications", "Volumes", "opt"]);
|
||||
for name in ["Library", "System", "Applications", "opt", "private"] {
|
||||
assert!(never("/", mac, name), "{name}");
|
||||
}
|
||||
for name in ["Users", "Volumes", "Fonts"] {
|
||||
assert!(!never("/", mac, name), "{name}");
|
||||
}
|
||||
let unix = Siblings::of(&["usr", "etc", "bin", "home", "nix", "srv", "data", "mnt"]);
|
||||
for name in ["usr", "etc", "bin", "nix", "var", "proc"] {
|
||||
assert!(never("/", unix, name), "{name}");
|
||||
}
|
||||
for name in ["home", "srv", "data", "mnt", "media"] {
|
||||
assert!(!never("/", unix, name), "{name}");
|
||||
}
|
||||
let windows = Siblings::of(&["Windows", "Users", "Program Files", "Data"]);
|
||||
for name in ["Windows", "Program Files", "Recovery"] {
|
||||
assert!(never("C:\\", windows, name), "{name}");
|
||||
}
|
||||
for name in ["Data", "Users"] {
|
||||
assert!(!never("C:\\", windows, name), "{name}");
|
||||
}
|
||||
// A project that holds a bin and an etc folder isn't the root of a system.
|
||||
let project = Siblings::of(&["bin", "etc", "src"]);
|
||||
assert!(!never("/work/p", project, "bin") && !never("/work/p", project, "etc"));
|
||||
// Home folders: the home folder, and every folder in a Users or home folder.
|
||||
for name in ["Library", ".config", "snap", "AppData", "Applications"] {
|
||||
assert!(never("/home/me", none, name), "{name}");
|
||||
assert!(never("/Users/you", none, name), "{name}");
|
||||
}
|
||||
assert!(!never("/home/me", none, "Documents") && !never("/home/me/Documents", none, "Library"));
|
||||
assert!(never("/run/user/1000/doc/a1b2/me", none, "snap"), "a folder the document portal exports");
|
||||
assert!(never("/Users", none, "Shared") && never("/Users", none, "Public"));
|
||||
assert!(never("/Volumes/Copy/Library", none, "Application Support") && !never("/Volumes/Copy/Library", none, "Fonts"));
|
||||
assert!(never("/x", none, "Foo.app") && never("/x", none, "Program Files (x86)") && !never("/x", none, "Fonts.d"));
|
||||
assert!(never("/x", none, "Program Files (Arm)"));
|
||||
// A system root's walk reaches a home folder below a folder it skips (FreeBSD's /usr/home).
|
||||
let bsd = Rules { home: Some(PathBuf::from("/usr/home/me")), ..Rules::default() };
|
||||
let root = Path::new("/");
|
||||
assert_eq!(bsd.home_below(&bsd.listed(root, unix)), Some(PathBuf::from("/usr/home/me")));
|
||||
assert_eq!(r.home_below(&r.listed(root, unix)), None);
|
||||
assert_eq!(bsd.home_below(&bsd.listed(root, project)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_starts_with_the_threads_left_and_fails_when_none_is() {
|
||||
static POOL: Pool = Pool::new(3);
|
||||
let root = tree("pool", &["f.ttf"]);
|
||||
let blocked = || {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
(tx, Arc::new(Blocked(Mutex::new(rx))) as Arc<dyn Visitor>)
|
||||
};
|
||||
let (tx_a, a) = blocked();
|
||||
let (tx_b, b) = blocked();
|
||||
let a = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 2, 1, a).unwrap();
|
||||
let b = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 4, 1, b).unwrap();
|
||||
assert_eq!(POOL.live(), 3);
|
||||
let (_, c) = blocked();
|
||||
let c = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 1, 1, c);
|
||||
assert_eq!(c.err().as_deref(), Some("the search couldn't start: try again in a moment"));
|
||||
drop((tx_a, tx_b));
|
||||
assert_eq!((wait(&a).end, wait(&b).end), (Some(End::Done), Some(End::Done)));
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while POOL.live() > 0 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(POOL.live(), 0, "every walker thread ended");
|
||||
let s = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 8, 1, Arc::new(Names(&[]))).unwrap();
|
||||
assert_eq!(wait(&s).end, Some(End::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_takes_the_cpus_the_renderer_leaves_from_two_to_eight() {
|
||||
use super::walk::threads_for;
|
||||
assert_eq!([threads_for(64, 4), threads_for(12, 4), threads_for(4, 4), threads_for(1, 0)], [8, 7, 2, 2]);
|
||||
assert!((2..=8).contains(&threads()));
|
||||
}
|
||||
|
||||
/// A folder holding more folders than a search queues ends the search while it is being listed.
|
||||
#[test]
|
||||
fn a_folder_with_more_folders_than_the_search_queues_ends_it_as_it_is_listed() {
|
||||
// Folders and files made in turns: whatever order a file system lists them in, about half of a
|
||||
// folder's first entries are folders.
|
||||
let names: Vec<String> = (0..2048).flat_map(|i| [format!("d{i}/"), format!("f{i}.txt")]).collect();
|
||||
let names: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
let root = tree("many-folders", &names);
|
||||
let p = search(&root, "", Limits { folders: 10, ..Limits::default() }, 1, &[]);
|
||||
assert_eq!(p.end, Some(End::Limit));
|
||||
assert!(p.files < 2048, "the folder was listed to its end: {p:?}");
|
||||
}
|
||||
|
||||
/// Wants every file, counting the files it is asked about.
|
||||
struct Counted(AtomicUsize);
|
||||
|
||||
impl Visitor for Counted {
|
||||
fn wants(&self, _: &str) -> bool {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
fn items(&self, _: &Path) -> Vec<usize> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder's files past those the search reads aren't kept to be read.
|
||||
#[test]
|
||||
fn files_past_those_the_search_reads_are_not_kept() {
|
||||
let names: Vec<String> = (0..50).map(|i| format!("f{i}.ttf")).collect();
|
||||
let names: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
let root = tree("many-files", &names);
|
||||
let counted = Arc::new(Counted(AtomicUsize::new(0)));
|
||||
let p = wait(&begin(root.clone(), rules(&root), Limits { files: 3, ..Limits::default() }, 1, 1, counted.clone()));
|
||||
assert_eq!((p.stopped(), p.read, p.files), (Some("limit"), 3, 50), "{p:?}");
|
||||
assert_eq!(counted.0.load(Ordering::Relaxed), 4, "one more than the files read");
|
||||
}
|
||||
|
||||
/// Wants `.ttf` files by their names, turns down those whose names start with `skip` by the file,
|
||||
/// and finds `font.ttf`.
|
||||
struct Picky;
|
||||
|
||||
impl Visitor for Picky {
|
||||
fn wants(&self, name: &str) -> bool {
|
||||
name.ends_with(".ttf")
|
||||
}
|
||||
fn worth_reading(&self, path: &Path) -> bool {
|
||||
!path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("skip"))
|
||||
}
|
||||
fn items(&self, path: &Path) -> Vec<usize> {
|
||||
if path.ends_with("font.ttf") { vec![0] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Files a visitor turns down by the file itself aren't read and don't count among the files
|
||||
/// read: with room to read one file, the search gets past them to the font in the folder below.
|
||||
#[test]
|
||||
fn files_turned_down_by_the_file_are_not_read() {
|
||||
let root = tree("turned-down", &["skip1.ttf", "skip2.ttf", "skip3.ttf", "sub/font.ttf"]);
|
||||
let p = wait(&begin(root.clone(), rules(&root), Limits { files: 1, ..Limits::default() }, 1, 1, Arc::new(Picky)));
|
||||
assert_eq!((p.end.clone(), p.read, found(&root, &p)), (Some(End::Found), 1, vec!["sub/font.ttf".to_string()]), "{p:?}");
|
||||
}
|
||||
|
||||
/// Windows paths lose the `\\?\` prefix `canonicalize` gives them where they read the same
|
||||
/// without it. Paths compare the same with the prefix and without it.
|
||||
#[test]
|
||||
fn windows_paths_lose_the_verbatim_prefix_where_they_read_the_same() {
|
||||
for (verbatim, without) in [
|
||||
(r"\\?\C:\Users\me\Fonts", r"C:\Users\me\Fonts"),
|
||||
(r"\\?\d:\", r"d:\"),
|
||||
(r"\\?\UNC\server\share\a b\c.otf", r"\\server\share\a b\c.otf"),
|
||||
(r"\\?\UNC\server\share", r"\\server\share"),
|
||||
(r"\\?\UNC\server\share\", r"\\server\share\"),
|
||||
] {
|
||||
assert_eq!(without_verbatim(verbatim).as_deref(), Some(without), "{verbatim}");
|
||||
}
|
||||
// For comparing, any name goes.
|
||||
assert_eq!(unprefixed(r"\\?\C:\a.\b").as_deref(), Some(r"C:\a.\b"));
|
||||
assert_eq!(unprefixed(r"\\?\UNC\server").as_deref(), Some(r"\\server"));
|
||||
assert_eq!((unprefixed(r"\\?\C:"), unprefixed(r"C:\a"), unprefixed("/home/me")), (None, None, None));
|
||||
for kept in [
|
||||
r"\\?\C:",
|
||||
r"\\?\C:\a.\b",
|
||||
r"\\?\C:\a \b",
|
||||
r"\\?\C:\CON\b",
|
||||
r"\\?\C:\a\nul.txt",
|
||||
r"\\?\C:\a\COM¹",
|
||||
r"\\?\C:\a/b",
|
||||
r"\\?\C:\a\..\b",
|
||||
r"\\?\C:\a\\b",
|
||||
r"\\?\Volume{00000000-0000-0000-0000-000000000000}\a",
|
||||
r"\\?\UNC\server",
|
||||
r"C:\a",
|
||||
"/home/me",
|
||||
] {
|
||||
assert_eq!(without_verbatim(kept), None, "{kept}");
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
assert_eq!(plain(PathBuf::from(r"\\?\C:\a")), PathBuf::from(r"\\?\C:\a"), "other systems' paths stay as they are");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
//! The walker threads of a folder search and what they share.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{Refusal, Rules, Siblings, plain};
|
||||
|
||||
/// The most walker threads one search starts.
|
||||
const MAX_THREADS: usize = 8;
|
||||
|
||||
/// The most walker threads all searches together have running.
|
||||
const MAX_LIVE: usize = 32;
|
||||
|
||||
/// How often a walker with nothing to do looks again (the deadline, the end).
|
||||
const IDLE: Duration = Duration::from_millis(50);
|
||||
|
||||
/// How many entries of a folder a walker lists between looks at the end, the deadline and the
|
||||
/// folders waiting to be listed.
|
||||
const CHECK_EVERY: u64 = 1024;
|
||||
|
||||
/// How a search ends when every walker thread stopped on a bug.
|
||||
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
|
||||
|
||||
/// What a search looks for.
|
||||
pub trait Visitor: Send + Sync {
|
||||
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
|
||||
/// it has one more of them to read than [`Limits::files`].
|
||||
fn wants(&self, name: &str) -> bool;
|
||||
/// Whether the file at `path`, which [`Self::wants`] took by its name, is to be read after all,
|
||||
/// by what the file itself is. A file turned down here doesn't count among the files read.
|
||||
fn worth_reading(&self, _path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
/// The wanted items (indexes below the search's item count) the file at `path` is.
|
||||
fn items(&self, path: &Path) -> Vec<usize>;
|
||||
}
|
||||
|
||||
/// The caps on a search.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Limits {
|
||||
/// Seconds before it ends (0 to 3600; 60 when not finite).
|
||||
pub seconds: f64,
|
||||
/// Entries listed, in every folder.
|
||||
pub entries: u64,
|
||||
/// Folders waiting to be listed.
|
||||
pub folders: usize,
|
||||
/// Folders below the picked one.
|
||||
pub depth: u32,
|
||||
/// Files read.
|
||||
pub files: u64,
|
||||
/// Files kept for each item.
|
||||
pub per_item: usize,
|
||||
/// Files kept in all.
|
||||
pub hits: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { seconds: 60.0, entries: 10_000_000, folders: 1_000_000, depth: 64, files: 10_000, per_item: 10, hits: 1_000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a search ended.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum End {
|
||||
/// Every folder it could enter was searched.
|
||||
Done,
|
||||
/// Every item has a file.
|
||||
Found,
|
||||
/// [`Search::stop`].
|
||||
Stopped,
|
||||
/// [`Limits::seconds`] passed.
|
||||
Time,
|
||||
/// The entries listed, the folders waiting to be listed, the files read or the files kept
|
||||
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
|
||||
/// keeps at most [`Limits::per_item`] files, without ending the search.
|
||||
Limit,
|
||||
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Where a search stands ([`Search::progress`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Progress {
|
||||
/// `None` while it runs.
|
||||
pub end: Option<End>,
|
||||
/// The folder searched (canonical, through [`plain`]), once it was checked. The walk reads the
|
||||
/// folder and the files below it by the paths with the `\\?\` prefix that Windows gives them.
|
||||
pub folder: PathBuf,
|
||||
/// The files found, each with the items it is, in the order found (through [`plain`]).
|
||||
pub hits: Vec<(PathBuf, Vec<usize>)>,
|
||||
/// Folders listed.
|
||||
pub folders: u64,
|
||||
/// Files listed.
|
||||
pub files: u64,
|
||||
/// Files read.
|
||||
pub read: u64,
|
||||
/// Folders not entered (other apps', the system's, hidden ones, ones too deep, ones whose
|
||||
/// contents aren't on the disk, ones whose names can't be read).
|
||||
pub skipped: u64,
|
||||
/// Folders and files that couldn't be read.
|
||||
pub unreadable: u64,
|
||||
/// How long it ran, or has been running.
|
||||
pub seconds: f64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// `searching`, `done` ([`End::Done`], [`End::Found`]), `stopped` ([`End::Stopped`],
|
||||
/// [`End::Time`], [`End::Limit`]) or `failed`.
|
||||
pub fn state(&self) -> &'static str {
|
||||
match &self.end {
|
||||
None => "searching",
|
||||
Some(End::Done | End::Found) => "done",
|
||||
Some(End::Stopped | End::Time | End::Limit) => "stopped",
|
||||
Some(End::Failed(_)) => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a stopped search stopped: `stop`, `time` or `limit`.
|
||||
pub fn stopped(&self) -> Option<&'static str> {
|
||||
match &self.end {
|
||||
Some(End::Stopped) => Some("stop"),
|
||||
Some(End::Time) => Some("time"),
|
||||
Some(End::Limit) => Some("limit"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a failed search failed.
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
match &self.end {
|
||||
Some(End::Failed(e)) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
|
||||
pub(crate) struct Pool {
|
||||
live: AtomicUsize,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub(crate) const fn new(cap: usize) -> Self {
|
||||
Self { live: AtomicUsize::new(0), cap }
|
||||
}
|
||||
|
||||
/// Reserve up to `want` threads: as many as are free, none when none is.
|
||||
fn reserve(&self, want: usize) -> usize {
|
||||
let mut live = self.live.load(Ordering::SeqCst);
|
||||
loop {
|
||||
let n = want.min(self.cap.saturating_sub(live));
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
match self.live.compare_exchange(live, live + n, Ordering::SeqCst, Ordering::SeqCst) {
|
||||
Ok(_) => return n,
|
||||
Err(now) => live = now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&self, n: usize) {
|
||||
self.live.fetch_sub(n, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The threads running now.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn live(&self) -> usize {
|
||||
self.live.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads of every search.
|
||||
static POOL: Pool = Pool::new(MAX_LIVE);
|
||||
|
||||
/// The walker threads for a search: the logical CPUs less one and less the renderer's threads,
|
||||
/// from 2 to 8.
|
||||
pub fn threads() -> usize {
|
||||
let cpus = std::thread::available_parallelism().map_or(2, std::num::NonZero::get);
|
||||
threads_for(cpus, usize::from(vectorcraft_render::default_threads()))
|
||||
}
|
||||
|
||||
/// [`threads`] on `cpus` logical CPUs with `render` renderer threads.
|
||||
pub(crate) fn threads_for(cpus: usize, render: usize) -> usize {
|
||||
cpus.saturating_sub(1 + render).clamp(2, MAX_THREADS)
|
||||
}
|
||||
|
||||
/// A running (or ended) search. Dropping it stops it.
|
||||
pub struct Search {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl Search {
|
||||
/// End the search ([`End::Stopped`]) unless it has ended. Walkers busy reading a folder or a
|
||||
/// file stop after it.
|
||||
pub fn stop(&self) {
|
||||
let mut w = self.shared.lock();
|
||||
self.shared.finish(&mut w, End::Stopped);
|
||||
}
|
||||
|
||||
/// Where the search stands. Past its deadline it has ended ([`End::Time`]), even while a walker
|
||||
/// is still waiting for a slow folder or file.
|
||||
pub fn progress(&self) -> Progress {
|
||||
let s = &self.shared;
|
||||
let mut w = s.lock();
|
||||
if w.end.is_none() && Instant::now() >= s.deadline {
|
||||
s.finish(&mut w, End::Time);
|
||||
}
|
||||
Progress {
|
||||
end: w.end.clone(),
|
||||
folder: plain(w.folder.clone()),
|
||||
hits: w.hits.iter().map(|(path, items)| (plain(path.clone()), items.clone())).collect(),
|
||||
folders: w.folders,
|
||||
files: w.files,
|
||||
read: s.read.load(Ordering::Relaxed).min(s.limits.files),
|
||||
skipped: w.skipped,
|
||||
unreadable: w.unreadable,
|
||||
seconds: if w.end.is_some() { w.seconds } else { s.started.elapsed().as_secs_f64() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Search {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Search `folder` and its subfolders on up to `threads` walker threads (at most 8, as many as the
|
||||
/// process-wide cap leaves) for the `items` a `visitor` looks for, where `rules` allow (this
|
||||
/// computer's when `None`). Returns at once; the folder is checked on a walker thread.
|
||||
pub fn start(
|
||||
folder: PathBuf,
|
||||
rules: Option<Rules>,
|
||||
limits: Limits,
|
||||
threads: usize,
|
||||
items: usize,
|
||||
visitor: Arc<dyn Visitor>,
|
||||
) -> Result<Search, String> {
|
||||
start_in(&POOL, folder, rules, limits, threads, items, visitor)
|
||||
}
|
||||
|
||||
/// [`start`] with the threads of `pool`.
|
||||
pub(crate) fn start_in(
|
||||
pool: &'static Pool,
|
||||
folder: PathBuf,
|
||||
rules: Option<Rules>,
|
||||
limits: Limits,
|
||||
threads: usize,
|
||||
items: usize,
|
||||
visitor: Arc<dyn Visitor>,
|
||||
) -> Result<Search, String> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err("searching a folder isn't available on the web".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 {
|
||||
return Err("the search couldn't start: try again in a moment".into());
|
||||
}
|
||||
let started = Instant::now();
|
||||
let shared = Arc::new(Shared {
|
||||
stop: AtomicBool::new(false),
|
||||
entries: AtomicU64::new(0),
|
||||
read: AtomicU64::new(0),
|
||||
started,
|
||||
deadline: started + Duration::from_secs_f64(seconds),
|
||||
limits,
|
||||
items,
|
||||
given: rules,
|
||||
rules: OnceLock::new(),
|
||||
visitor,
|
||||
walk: Mutex::new(Walk { root: Some(folder), alive: n, found: vec![0; items], ..Walk::default() }),
|
||||
wake: Condvar::new(),
|
||||
pool,
|
||||
});
|
||||
let mut spawned = 0;
|
||||
for _ in 0..n {
|
||||
let s = shared.clone();
|
||||
let thread = std::thread::Builder::new().name("vectorcraft-find".into()).spawn(move || {
|
||||
let _alive = Alive(s.clone());
|
||||
// A bug in the walk ends this walker and the others go on; when every walker ends this
|
||||
// way, the search fails ([`Alive`]).
|
||||
let _ = crate::guard::catch_panic(|| walker(&s));
|
||||
});
|
||||
if thread.is_ok() {
|
||||
spawned += 1;
|
||||
}
|
||||
}
|
||||
if spawned < n {
|
||||
pool.release(n - spawned);
|
||||
let mut w = shared.lock();
|
||||
w.alive = w.alive.saturating_sub(n - spawned);
|
||||
if spawned == 0 {
|
||||
return Err("the search couldn't start: try again in a moment".into());
|
||||
}
|
||||
if w.alive == 0 && w.end.is_none() {
|
||||
shared.finish(&mut w, End::Failed(INTERNAL.into()));
|
||||
}
|
||||
}
|
||||
Ok(Search { shared })
|
||||
}
|
||||
|
||||
/// What a search's walkers share.
|
||||
struct Shared {
|
||||
/// Set once the search ended: walkers stop listing and reading.
|
||||
stop: AtomicBool,
|
||||
entries: AtomicU64,
|
||||
read: AtomicU64,
|
||||
started: Instant,
|
||||
deadline: Instant,
|
||||
limits: Limits,
|
||||
items: usize,
|
||||
/// The rules given to [`start`], else this computer's, read by the first walker.
|
||||
given: Option<Rules>,
|
||||
rules: OnceLock<Rules>,
|
||||
visitor: Arc<dyn Visitor>,
|
||||
walk: Mutex<Walk>,
|
||||
/// Woken when folders are queued, a walker finishes a folder, or the search ends.
|
||||
wake: Condvar,
|
||||
pool: &'static Pool,
|
||||
}
|
||||
|
||||
/// The state walkers change under the lock.
|
||||
#[derive(Default)]
|
||||
struct Walk {
|
||||
/// The picked folder, until a walker takes it to check it.
|
||||
root: Option<PathBuf>,
|
||||
/// A walker is checking the picked folder.
|
||||
preparing: bool,
|
||||
folder: PathBuf,
|
||||
/// Folders waiting to be listed, with their depth below the picked one.
|
||||
stack: Vec<(PathBuf, u32)>,
|
||||
/// Folders being listed (and their candidates read) now.
|
||||
listing: usize,
|
||||
/// Walker threads still running.
|
||||
alive: usize,
|
||||
end: Option<End>,
|
||||
seconds: f64,
|
||||
hits: Vec<(PathBuf, Vec<usize>)>,
|
||||
/// Files kept for each item.
|
||||
found: Vec<usize>,
|
||||
folders: u64,
|
||||
files: u64,
|
||||
skipped: u64,
|
||||
unreadable: u64,
|
||||
/// The home folder was queued from a system's root ([`Rules::home_below`]).
|
||||
home_queued: bool,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn lock(&self) -> MutexGuard<'_, Walk> {
|
||||
self.walk.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// End the search as `end` unless it has ended, and wake every walker to see it.
|
||||
fn finish(&self, w: &mut Walk, end: End) {
|
||||
if w.end.is_none() {
|
||||
w.end = Some(end);
|
||||
w.seconds = self.started.elapsed().as_secs_f64();
|
||||
}
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
fn end_with(&self, end: End) {
|
||||
let mut w = self.lock();
|
||||
self.finish(&mut w, end);
|
||||
}
|
||||
|
||||
/// Whether to stop listing and reading: the search ended or its time is up.
|
||||
fn should_stop(&self) -> bool {
|
||||
self.stop.load(Ordering::Relaxed) || Instant::now() >= self.deadline
|
||||
}
|
||||
|
||||
/// Whether the folders waiting to be listed and `more` folders found in the folder being listed
|
||||
/// come to more than [`Limits::folders`], which ends the search ([`End::Limit`]).
|
||||
fn too_many_folders(&self, more: usize) -> bool {
|
||||
let mut w = self.lock();
|
||||
let over = w.stack.len().saturating_add(more) > self.limits.folders;
|
||||
if over {
|
||||
self.finish(&mut w, End::Limit);
|
||||
}
|
||||
over
|
||||
}
|
||||
|
||||
/// Check the picked folder, then queue it.
|
||||
fn prepare(&self, root: &Path) {
|
||||
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
|
||||
let path = root.display();
|
||||
let checked = match std::fs::canonicalize(root) {
|
||||
Err(e) => Err(format!("{path}: {e}")),
|
||||
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
|
||||
Ok(folder) => match rules.refusal(&folder) {
|
||||
None => Ok(folder),
|
||||
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
|
||||
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
|
||||
},
|
||||
};
|
||||
let mut w = self.lock();
|
||||
match checked {
|
||||
Ok(folder) => {
|
||||
w.folder = folder.clone();
|
||||
w.stack.push((folder, 0));
|
||||
}
|
||||
Err(e) => self.finish(&mut w, End::Failed(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// List one folder: queue its subfolders, then read the files [`Visitor::wants`] accepts.
|
||||
fn list(&self, dir: &Path, depth: u32) {
|
||||
let Some(rules) = self.rules.get() else { return };
|
||||
let (mut skipped, mut unreadable, mut files) = (0, 0, 0);
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let mut w = self.lock();
|
||||
// The picked folder itself: the search can't run, and says why.
|
||||
if depth == 0 {
|
||||
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
|
||||
} else {
|
||||
w.unreadable += 1;
|
||||
w.folders += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut folders, mut siblings, mut candidates) = (vec![], Siblings::default(), vec![]);
|
||||
for (n, entry) in entries.enumerate() {
|
||||
if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && (self.should_stop() || self.too_many_folders(folders.len())) {
|
||||
break;
|
||||
}
|
||||
if self.entries.fetch_add(1, Ordering::Relaxed) >= self.limits.entries {
|
||||
self.end_with(End::Limit);
|
||||
break;
|
||||
}
|
||||
let Ok(entry) = entry else {
|
||||
unreadable += 1;
|
||||
break;
|
||||
};
|
||||
let Ok(kind) = entry.file_type() else {
|
||||
unreadable += 1;
|
||||
continue;
|
||||
};
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
// A name that isn't Unicode: its folder is left out, its file can't be wanted.
|
||||
if kind.is_dir() {
|
||||
skipped += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if kind.is_dir() || kind.is_symlink() {
|
||||
siblings.note(name);
|
||||
}
|
||||
// Links are neither followed nor read.
|
||||
if kind.is_dir() {
|
||||
folders.push((entry, name.to_string()));
|
||||
} else if kind.is_file() {
|
||||
files += 1;
|
||||
// At most one file more than the search reads is kept to be read.
|
||||
if (candidates.len() as u64) <= self.limits.files && self.visitor.wants(name) && self.visitor.worth_reading(&entry.path()) {
|
||||
if hidden_and_offline(&entry).1 {
|
||||
// Its contents are in the cloud: counted as unreadable, never read.
|
||||
unreadable += 1;
|
||||
} else {
|
||||
candidates.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The folder is told once for all its folders.
|
||||
let listed = rules.listed(dir, siblings);
|
||||
let mut queue = vec![];
|
||||
for (n, (entry, name)) in folders.into_iter().enumerate() {
|
||||
if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && self.should_stop() {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let (hidden, offline) = hidden_and_offline(&entry);
|
||||
if depth >= self.limits.depth || name.starts_with('.') || hidden || offline || rules.never_in(&listed, &name) || rules.is_refused(&path) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
queue.push((path, depth + 1));
|
||||
}
|
||||
let home = rules.home_below(&listed);
|
||||
{
|
||||
let mut w = self.lock();
|
||||
w.files += files;
|
||||
w.skipped += skipped;
|
||||
w.unreadable += unreadable;
|
||||
if w.end.is_none() {
|
||||
if w.stack.len() + queue.len() > self.limits.folders {
|
||||
self.finish(&mut w, End::Limit);
|
||||
} else {
|
||||
w.stack.extend(queue);
|
||||
if let Some(home) = home.filter(|_| !w.home_queued) {
|
||||
w.home_queued = true;
|
||||
w.stack.push((home, 1));
|
||||
}
|
||||
self.wake.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut unreadable = 0;
|
||||
for entry in candidates {
|
||||
if self.should_stop() {
|
||||
break;
|
||||
}
|
||||
if self.read.fetch_add(1, Ordering::Relaxed) >= self.limits.files {
|
||||
self.end_with(End::Limit);
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
match crate::guard::catch_panic(|| self.visitor.items(&path)) {
|
||||
Ok(items) if !items.is_empty() => self.keep(path, items),
|
||||
Ok(_) => {}
|
||||
// A file that trips a bug: as one that can't be read, and the search goes on.
|
||||
Err(_) => unreadable += 1,
|
||||
}
|
||||
}
|
||||
let mut w = self.lock();
|
||||
w.folders += 1;
|
||||
w.unreadable += unreadable;
|
||||
}
|
||||
|
||||
/// Keep a file the visitor gave `items` for, for those of its items that take more files, as
|
||||
/// soon as it is read: a file read as the search ends is kept too. The search ends at the most
|
||||
/// files it keeps, and once every item has a file.
|
||||
fn keep(&self, path: PathBuf, mut items: Vec<usize>) {
|
||||
let mut w = self.lock();
|
||||
items.sort_unstable();
|
||||
items.dedup();
|
||||
items.retain(|i| w.found.get(*i).is_some_and(|n| *n < self.limits.per_item));
|
||||
if items.is_empty() || w.hits.len() >= self.limits.hits {
|
||||
return;
|
||||
}
|
||||
for i in &items {
|
||||
if let Some(n) = w.found.get_mut(*i) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
w.hits.push((path, items));
|
||||
if w.hits.len() >= self.limits.hits {
|
||||
self.finish(&mut w, End::Limit);
|
||||
}
|
||||
if self.items > 0 && w.found.iter().all(|n| *n > 0) {
|
||||
self.finish(&mut w, End::Found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a walker does next.
|
||||
enum Job {
|
||||
Prepare(PathBuf),
|
||||
List(PathBuf, u32),
|
||||
}
|
||||
|
||||
/// A walker thread: check the picked folder, or list the next folder, until the search ends.
|
||||
fn walker(s: &Shared) {
|
||||
loop {
|
||||
let job = {
|
||||
let mut w = s.lock();
|
||||
loop {
|
||||
if w.end.is_some() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= s.deadline {
|
||||
s.finish(&mut w, End::Time);
|
||||
return;
|
||||
}
|
||||
if let Some(root) = w.root.take() {
|
||||
w.preparing = true;
|
||||
break Job::Prepare(root);
|
||||
}
|
||||
if let Some((dir, depth)) = w.stack.pop() {
|
||||
w.listing += 1;
|
||||
break Job::List(dir, depth);
|
||||
}
|
||||
if !w.preparing && w.listing == 0 {
|
||||
s.finish(&mut w, End::Done);
|
||||
return;
|
||||
}
|
||||
w = s.wake.wait_timeout(w, IDLE).map_or_else(|e| e.into_inner().0, |(w, _)| w);
|
||||
}
|
||||
};
|
||||
// The guard lowers `preparing` or `listing` when the job ends, on a bug too.
|
||||
let _busy = Busy(s, matches!(job, Job::Prepare(_)));
|
||||
let done = crate::guard::catch_panic(|| match &job {
|
||||
Job::Prepare(root) => s.prepare(root),
|
||||
Job::List(dir, depth) => s.list(dir, *depth),
|
||||
});
|
||||
if done.is_err() {
|
||||
let mut w = s.lock();
|
||||
match job {
|
||||
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
|
||||
Job::List(..) => w.unreadable += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A walker's job in progress: preparing (`true`) or listing a folder.
|
||||
struct Busy<'a>(&'a Shared, bool);
|
||||
|
||||
impl Drop for Busy<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut w = self.0.lock();
|
||||
if self.1 {
|
||||
w.preparing = false;
|
||||
} else {
|
||||
w.listing = w.listing.saturating_sub(1);
|
||||
}
|
||||
self.0.wake.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// A running walker thread, counted out of the search and the pool when it ends.
|
||||
struct Alive(Arc<Shared>);
|
||||
|
||||
impl Drop for Alive {
|
||||
fn drop(&mut self) {
|
||||
let mut w = self.0.lock();
|
||||
w.alive = w.alive.saturating_sub(1);
|
||||
if w.alive == 0 && w.end.is_none() {
|
||||
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
|
||||
}
|
||||
drop(w);
|
||||
self.0.pool.release(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder or file is hidden by its flags (macOS `UF_HIDDEN`; Windows hidden or system),
|
||||
/// and whether its contents are in the cloud rather than on the disk (macOS `SF_DATALESS`; Windows
|
||||
/// offline or recalled on access).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) {
|
||||
use std::os::macos::fs::MetadataExt;
|
||||
const UF_HIDDEN: u32 = 0x8000;
|
||||
const SF_DATALESS: u32 = 0x4000_0000;
|
||||
entry.metadata().map_or((false, false), |m| (m.st_flags() & UF_HIDDEN != 0, m.st_flags() & SF_DATALESS != 0))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const HIDDEN: u32 = 0x2;
|
||||
const SYSTEM: u32 = 0x4;
|
||||
const OFFLINE: u32 = 0x1000;
|
||||
const RECALL_ON_OPEN: u32 = 0x4_0000;
|
||||
const RECALL_ON_DATA_ACCESS: u32 = 0x40_0000;
|
||||
entry.metadata().map_or((false, false), |m| {
|
||||
let a = m.file_attributes();
|
||||
(a & (HIDDEN | SYSTEM) != 0, a & (OFFLINE | RECALL_ON_OPEN | RECALL_ON_DATA_ACCESS) != 0)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
fn hidden_and_offline(_: &std::fs::DirEntry) -> (bool, bool) {
|
||||
(false, false)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! The fonts a document uses that aren't available (`text.missingFonts`), the files that provide
|
||||
//! them in a folder the user picks (`text.findFontFiles`, searched on separate threads:
|
||||
//! [`super::findfiles`]), and copying the files the user chooses into VectorCraft's own Fonts
|
||||
//! folder, which the font scan reads (`text.addFontFiles`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_text::{FontDb, FontMatch, WantedFont, WantedFonts};
|
||||
|
||||
use super::findfiles::{self, Limits, Visitor};
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
|
||||
/// The most fonts listed, or looked for at once.
|
||||
const MAX_FONTS: usize = 1_000;
|
||||
/// The longest family or style name listed or given (bytes).
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The CSS generic font families (CSS Fonts Module Level 4), which SVG and HTML files name in place
|
||||
/// of a font and no font file provides; compared in any ASCII case. The list leaves out
|
||||
/// `fangsong`, which is also the family name of a Chinese font (FangSong).
|
||||
const GENERIC_FAMILIES: &[&str] = &[
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-serif",
|
||||
"ui-sans-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"math",
|
||||
"emoji",
|
||||
];
|
||||
/// The longest folder path given (bytes).
|
||||
const MAX_PATH: usize = 4096;
|
||||
/// The most files one `text.addFontFiles` copies: as many as a search keeps.
|
||||
const MAX_ADD_FILES: usize = 1_000;
|
||||
/// The largest font file a search reads and `text.addFontFiles` copies, and the most bytes one
|
||||
/// `text.addFontFiles` copies.
|
||||
pub(crate) const MAX_FONT_FILE: u64 = 256 << 20;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_ADD_BYTES: u64 = 1 << 30;
|
||||
/// The highest number a copy's name gets (`Name 99.otf`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_NUMBER: u32 = 99;
|
||||
/// The extensions of the font files `text.addFontFiles` copies (in any case), besides suitcase
|
||||
/// fonts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
|
||||
/// Why a file wasn't copied.
|
||||
const NOT_A_FILE: &str = "not a file";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const NOT_A_FONT: &str = "not a font file";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TOO_LARGE: &str = "larger than 256 MB";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TOO_MUCH: &str = "the files come to more than 1 GB: add fewer at a time";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const NO_NAME: &str = "every numbered name for it is taken in the Fonts folder";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "text.missingFonts",
|
||||
"Missing Fonts",
|
||||
[],
|
||||
None,
|
||||
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
|
||||
has_doc,
|
||||
missing
|
||||
),
|
||||
cmd!(
|
||||
query "text.findFontFiles",
|
||||
"Find Font Files",
|
||||
[],
|
||||
None,
|
||||
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
|
||||
always,
|
||||
find
|
||||
),
|
||||
cmd!(
|
||||
query "text.addFontFiles",
|
||||
"Add Font Files",
|
||||
[],
|
||||
None,
|
||||
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf); then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
|
||||
always,
|
||||
add
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A font a document names that isn't available as named.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MissingFont {
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
/// [`FontMatch::Missing`] or [`FontMatch::Style`].
|
||||
pub status: FontMatch,
|
||||
/// The family and style of the face shown instead.
|
||||
pub resolved: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named
|
||||
/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
|
||||
/// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out.
|
||||
pub fn missing_fonts(d: &Document) -> Vec<MissingFont> {
|
||||
let db = FontDb::global();
|
||||
let generic = |family: &str| GENERIC_FAMILIES.iter().any(|g| g.eq_ignore_ascii_case(family.trim()));
|
||||
let mut used: Vec<(String, String)> = super::fonts::used_fonts(d).into_iter().map(|(family, style, _)| (family, style)).collect();
|
||||
// Sorted by family and style: the versions type names of one face are neighbors.
|
||||
used.dedup();
|
||||
used.into_iter()
|
||||
.filter(|(family, style)| !family.trim().is_empty() && !generic(family) && family.len() <= MAX_NAME && style.len() <= MAX_NAME)
|
||||
.filter_map(|(family, style)| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
let status = resolved.as_ref().map_or(FontMatch::Missing, |(_, m)| *m);
|
||||
(status != FontMatch::Exact).then(|| MissingFont {
|
||||
family,
|
||||
style,
|
||||
status,
|
||||
resolved: resolved.map(|(f, _)| (f.family.clone(), f.style.clone())),
|
||||
})
|
||||
})
|
||||
.take(MAX_FONTS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `text.missingFonts`'s answer for the open document `st`, where searches go by `rules` (this
|
||||
/// computer's when `None`).
|
||||
pub fn missing_fonts_of(st: &DocState, rules: Option<&findfiles::Rules>) -> Value {
|
||||
let fonts: Vec<Value> = missing_fonts(&st.doc)
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"family": m.family, "style": m.style, "status": m.status.as_str(),
|
||||
"resolved": m.resolved.map(|(family, style)| json!({ "family": family, "style": style })),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({ "count": fonts.len(), "fonts": fonts });
|
||||
// File › Package puts the fonts in a `Fonts` folder next to the packaged document. The folder
|
||||
// is made absolute against the working folder when the document's path is relative. It is
|
||||
// offered when a search may start there, as far as the rules tell without listing a folder.
|
||||
if let Some(dir) = st.path.as_deref().and_then(|p| Path::new(p).parent()).map(|d| d.join("Fonts")).filter(|d| d.is_dir()) {
|
||||
let dir = super::fileio::absolute_path(&dir.to_string_lossy());
|
||||
let system;
|
||||
let rules = match rules {
|
||||
Some(rules) => rules,
|
||||
None => {
|
||||
system = findfiles::Rules::system();
|
||||
&system
|
||||
}
|
||||
};
|
||||
if std::fs::canonicalize(&dir).is_ok_and(|c| rules.refusal_unlisted(&c).is_none()) {
|
||||
out["fontsNextToDocument"] = json!(dir);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn missing(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(missing_fonts_of(s.doc()?, s.search_rules.as_ref()))
|
||||
}
|
||||
|
||||
/// A font a search looks for.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Sought {
|
||||
pub(crate) family: String,
|
||||
pub(crate) style: String,
|
||||
pub(crate) status: FontMatch,
|
||||
}
|
||||
|
||||
/// The session's last search for font files (`text.findFontFiles`).
|
||||
pub(crate) struct FontSearch {
|
||||
/// The search's number in the session; [`stop`] takes it.
|
||||
pub(crate) id: u64,
|
||||
/// The folder as given.
|
||||
pub(crate) folder: String,
|
||||
pub(crate) fonts: Vec<Sought>,
|
||||
pub(crate) search: findfiles::Search,
|
||||
}
|
||||
|
||||
/// The files a search for fonts reads: font files, by their names ([`WantedFonts`]).
|
||||
struct FontFiles(WantedFonts);
|
||||
|
||||
impl Visitor for FontFiles {
|
||||
fn wants(&self, name: &str) -> bool {
|
||||
let path = Path::new(name);
|
||||
// A suitcase font (macOS) has no extension, or `.suit`.
|
||||
vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none_or(|e| e.eq_ignore_ascii_case("suit"))
|
||||
}
|
||||
fn worth_reading(&self, path: &Path) -> bool {
|
||||
// A search reads font files and suitcase fonts of at most [`MAX_FONT_FILE`] bytes, the files
|
||||
// `text.addFontFiles` copies.
|
||||
(vectorcraft_text::is_font_file(path) || vectorcraft_text::is_suitcase(path))
|
||||
&& std::fs::metadata(path).is_ok_and(|m| m.len() <= MAX_FONT_FILE)
|
||||
}
|
||||
fn items(&self, path: &Path) -> Vec<usize> {
|
||||
self.0.provided_by(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[{family, style?}]`: 1 to 1000 fonts with a family of 1 to 256 bytes and a style of at most 256
|
||||
/// (Regular when left out).
|
||||
fn font_list(v: &Value) -> Option<Vec<(String, String)>> {
|
||||
let list = v.as_array().filter(|a| (1..=MAX_FONTS).contains(&a.len()))?;
|
||||
list.iter()
|
||||
.map(|f| {
|
||||
let family = f.get("family")?.as_str().filter(|s| !s.trim().is_empty() && s.len() <= MAX_NAME)?;
|
||||
let style = match f.get("style") {
|
||||
None | Some(Value::Null) => "Regular",
|
||||
Some(s) => s.as_str().filter(|s| s.len() <= MAX_NAME)?,
|
||||
};
|
||||
Some((family.to_string(), style.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.findFontFiles";
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
if bool_or(p, "stop", false) {
|
||||
if let Some(f) = &s.font_search {
|
||||
f.search.stop();
|
||||
}
|
||||
return Ok(state(s));
|
||||
}
|
||||
let Some(folder) = p.get("folder").filter(|f| !f.is_null()) else { return Ok(state(s)) };
|
||||
let given = match p.get("fonts") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(font_list(v).ok_or_else(|| bad(C, "each of `fonts` is {family, style?}; 1 to 1000 of them"))?),
|
||||
};
|
||||
let seconds = p.get("maxSeconds").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(60.0).clamp(1.0, 600.0);
|
||||
// Only checked here: the folder is read on a walker thread.
|
||||
let folder = folder
|
||||
.as_str()
|
||||
.filter(|f| !f.is_empty() && f.len() <= MAX_PATH && !f.contains(['\0', '\u{FFFD}']) && Path::new(f).is_absolute())
|
||||
.ok_or_else(|| bad(C, "`folder` must be an absolute folder path"))?
|
||||
.to_string();
|
||||
let fonts = match given {
|
||||
Some(fonts) => fonts,
|
||||
None => missing_fonts(&s.active().ok_or_else(|| bad(C, "give `fonts`, or open a document"))?.doc)
|
||||
.into_iter()
|
||||
.map(|m| (m.family, m.style))
|
||||
.collect(),
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let (mut sought, mut wanted) = (vec![], vec![]);
|
||||
for (family, style) in fonts {
|
||||
let (status, installed) = match db.resolve(&family, &style) {
|
||||
Some((_, FontMatch::Exact)) => continue,
|
||||
Some((f, FontMatch::Style)) => (FontMatch::Style, Some(f.family.clone())),
|
||||
_ => (FontMatch::Missing, None),
|
||||
};
|
||||
wanted.push(WantedFont { family: family.clone(), style: style.clone(), installed });
|
||||
sought.push(Sought { family, style, status });
|
||||
}
|
||||
if sought.is_empty() {
|
||||
return Err(bad(C, "no fonts to look for: they are all available"));
|
||||
}
|
||||
// A new search stops the last one.
|
||||
s.font_search = None;
|
||||
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
|
||||
let limits = Limits { seconds, ..Limits::default() };
|
||||
let visitor = Arc::new(FontFiles(WantedFonts::new(&wanted)));
|
||||
let search =
|
||||
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, sought.len(), visitor).map_err(EngineError::Other)?;
|
||||
s.font_search = Some(FontSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, fonts: sought, search });
|
||||
Ok(state(s))
|
||||
}
|
||||
|
||||
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
|
||||
fn state(s: &Session) -> Value {
|
||||
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
|
||||
let p = f.search.progress();
|
||||
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
|
||||
for (path, items) in &p.hits {
|
||||
for i in items {
|
||||
if let Some(v) = files.get_mut(*i) {
|
||||
v.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let fonts: Vec<Value> = f
|
||||
.fonts
|
||||
.iter()
|
||||
.zip(files)
|
||||
.map(|(font, mut files)| {
|
||||
files.sort();
|
||||
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
|
||||
})
|
||||
.collect();
|
||||
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
|
||||
let mut out = json!({
|
||||
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
|
||||
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
|
||||
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
|
||||
});
|
||||
if let Some(why) = p.stopped() {
|
||||
out["stopped"] = json!(why);
|
||||
}
|
||||
if let Some(e) = p.error() {
|
||||
out["error"] = json!(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stop the session's search when it is the one numbered `id` (`text.findFontFiles`'s `id`): a
|
||||
/// newer search, started by someone else, goes on.
|
||||
pub fn stop(s: &mut Session, id: u64) {
|
||||
if let Some(f) = s.font_search.as_ref().filter(|f| f.id == id) {
|
||||
f.search.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// What copying font files into a folder did: each file copied (from, to), kept because the same
|
||||
/// file is there already (from, the file there), or skipped (file, why).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct CopyReport {
|
||||
pub(crate) copied: Vec<(PathBuf, PathBuf)>,
|
||||
pub(crate) kept: Vec<(PathBuf, PathBuf)>,
|
||||
pub(crate) skipped: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.addFontFiles";
|
||||
let mut files: Vec<PathBuf> = p
|
||||
.get("files")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| (1..=MAX_ADD_FILES).contains(&a.len()))
|
||||
.and_then(|a| a.iter().map(|f| f.as_str().map(PathBuf::from)).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(C, "`files` must be 1 to 1000 paths of font files the last text.findFontFiles search found"))?;
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let dir = vectorcraft_text::app_font_dir()
|
||||
.ok_or_else(|| EngineError::Other("VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it".into()))?;
|
||||
let found: Vec<PathBuf> =
|
||||
s.font_search.as_ref().map(|f| f.search.progress().hits.into_iter().map(|(path, _)| path).collect()).unwrap_or_default();
|
||||
if let Some(f) = files.iter().find(|f| !found.contains(f)) {
|
||||
return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display())));
|
||||
}
|
||||
super::fileio::create_dir(&dir.to_string_lossy())?;
|
||||
let report = copy_into(&files, dir);
|
||||
let pairs = |v: &[(PathBuf, PathBuf)]| {
|
||||
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
|
||||
};
|
||||
let mut out = json!({
|
||||
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
|
||||
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
|
||||
});
|
||||
if !report.copied.is_empty() || !report.kept.is_empty() {
|
||||
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
|
||||
let r = super::fonts::rescan(s, &Value::Null)?;
|
||||
out["families"] = r["families"].clone();
|
||||
out["faces"] = r["faces"].clone();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where [`copy_one`] put a file: copied to, or kept as, the file in the folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
enum Placed {
|
||||
Copied(PathBuf),
|
||||
Kept(PathBuf),
|
||||
}
|
||||
|
||||
/// Copy `files` into `dir`, never replacing a file there: a file with the same contents is kept,
|
||||
/// and when another file has the name, the copy gets the first free name from `Name 2.otf` to
|
||||
/// `Name 99.otf`. At most [`MAX_FONT_FILE`] bytes a file and 1 GB in all.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn copy_into(files: &[PathBuf], dir: &Path) -> CopyReport {
|
||||
let mut report = CopyReport::default();
|
||||
let mut total = 0;
|
||||
for from in files {
|
||||
match copy_one(from, dir, &mut total) {
|
||||
Ok(Placed::Copied(to)) => report.copied.push((from.clone(), to)),
|
||||
Ok(Placed::Kept(to)) => report.kept.push((from.clone(), to)),
|
||||
Err(why) => report.skipped.push((from.clone(), why)),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// The web has no file system: nothing is copied.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport {
|
||||
CopyReport { skipped: files.iter().map(|f| (f.clone(), NOT_A_FILE.to_string())).collect(), ..CopyReport::default() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result<Placed, String> {
|
||||
use std::io::ErrorKind;
|
||||
let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err(NOT_A_FILE.into());
|
||||
}
|
||||
let font = from.extension().and_then(|e| e.to_str()).is_some_and(|e| FONT_EXTENSIONS.iter().any(|x| e.eq_ignore_ascii_case(x)));
|
||||
// A suitcase font's fonts are in its resource fork, which is copied with it, whatever its name.
|
||||
let fork = match (vectorcraft_text::is_suitcase(from), font) {
|
||||
(true, _) => Some(read_fork(from)?),
|
||||
(false, true) => None,
|
||||
(false, false) => return Err(NOT_A_FONT.into()),
|
||||
};
|
||||
let len = fork.as_ref().map_or(meta.len(), |f| f.len() as u64);
|
||||
if len > MAX_FONT_FILE {
|
||||
return Err(TOO_LARGE.into());
|
||||
}
|
||||
if total.saturating_add(len) > MAX_ADD_BYTES {
|
||||
return Err(TOO_MUCH.into());
|
||||
}
|
||||
let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(NOT_A_FILE.into()) };
|
||||
let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
for n in 1..=MAX_NUMBER {
|
||||
let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) };
|
||||
match std::fs::symlink_metadata(&to) {
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => match create_copy(from, &to, fork.as_deref()) {
|
||||
Ok(()) => {
|
||||
*total += len;
|
||||
return Ok(Placed::Copied(to));
|
||||
}
|
||||
// Taken meanwhile: the next name.
|
||||
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
},
|
||||
Ok(m) if m.is_file() && same_file(from, &to, m.len(), len, fork.as_deref()) => return Ok(Placed::Kept(to)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(NO_NAME.into())
|
||||
}
|
||||
|
||||
/// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose
|
||||
/// resource fork is `fork`; never in place of a file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn create_copy(from: &Path, to: &Path, fork: Option<&[u8]>) -> std::io::Result<()> {
|
||||
let Some(fork) = fork else { return vectorcraft_format::write_new_with(to, |f| copy_capped(from, f)) };
|
||||
vectorcraft_format::write_new_with(to, |_| Ok(()))?;
|
||||
std::fs::write(fork_path(to), fork).inspect_err(|_| {
|
||||
// Best effort: the empty file just made is all there is to clean up.
|
||||
let _ = std::fs::remove_file(to);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the file `to` (`to_len` bytes) holds what the copy of `from` (`len` bytes, or the
|
||||
/// resource fork `fork` of a suitcase font) would.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn same_file(from: &Path, to: &Path, to_len: u64, len: u64, fork: Option<&[u8]>) -> bool {
|
||||
match fork {
|
||||
None => to_len == len && same_contents(from, to),
|
||||
Some(fork) => to_len == 0 && read_fork(to).is_ok_and(|f| f == fork),
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource fork of the file at `path` (where macOS keeps a suitcase font's fonts), at most
|
||||
/// [`MAX_FONT_FILE`] bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_fork(path: &Path) -> std::result::Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
let mut fork = vec![];
|
||||
std::fs::File::open(fork_path(path)).and_then(|f| f.take(MAX_FONT_FILE + 1).read_to_end(&mut fork)).map_err(|e| e.to_string())?;
|
||||
if fork.len() as u64 > MAX_FONT_FILE {
|
||||
return Err(TOO_LARGE.into());
|
||||
}
|
||||
Ok(fork)
|
||||
}
|
||||
|
||||
/// Where macOS shows the resource fork of the file at `path`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fork_path(path: &Path) -> PathBuf {
|
||||
path.join("..namedfork/rsrc")
|
||||
}
|
||||
|
||||
/// Copy the file `from` into `to`, failing past [`MAX_FONT_FILE`] bytes (a file that grew since
|
||||
/// it was measured).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn copy_capped(from: &Path, to: &mut std::fs::File) -> std::io::Result<()> {
|
||||
use std::io::Read;
|
||||
let copied = std::io::copy(&mut std::fs::File::open(from)?.take(MAX_FONT_FILE + 1), to)?;
|
||||
if copied > MAX_FONT_FILE {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, TOO_LARGE));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the files `a` and `b` hold the same bytes (read 64 KB at a time).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn same_contents(a: &Path, b: &Path) -> bool {
|
||||
let (Ok(mut a), Ok(mut b)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false };
|
||||
let (mut x, mut y) = (vec![0; 1 << 16], vec![0; 1 << 16]);
|
||||
loop {
|
||||
match (fill(&mut a, &mut x), fill(&mut b, &mut y)) {
|
||||
(Ok(0), Ok(0)) => return true,
|
||||
(Ok(n), Ok(m)) if n == m && x.get(..n) == y.get(..m) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read from `f` until `buf` is full or the file ends: the bytes read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fill(f: &mut std::fs::File, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
use std::io::Read;
|
||||
let mut n = 0;
|
||||
while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
|
||||
match f.read(rest)? {
|
||||
0 => break,
|
||||
k => n += k,
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The fonts (family, style) of the type in the layers and symbols of `d`.
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
|
||||
/// A font as type names it: family, style and, when it names one, the installed version.
|
||||
pub(super) type UsedFont = (String, String, Option<String>);
|
||||
|
||||
/// How a font is named in reports: family and style, then the version the type names, if any.
|
||||
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
||||
match version {
|
||||
Some(v) => format!("{family} {style} ({v})"),
|
||||
None => format!("{family} {style}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut add = |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
|
||||
}
|
||||
};
|
||||
d.walk(&mut add);
|
||||
@@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut missing: Vec<String> = used_fonts(d)
|
||||
.into_iter()
|
||||
.filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||||
.map(|(family, _)| family)
|
||||
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||||
.map(|(family, _, _)| family)
|
||||
.collect();
|
||||
// Sorted by family: its styles are neighbours.
|
||||
// Sorted by family: its styles and versions are neighbours.
|
||||
missing.dedup();
|
||||
(!missing.is_empty()).then(|| {
|
||||
format!(
|
||||
@@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
@@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
.map(|((family, style, version), (runs, objects, chars))| {
|
||||
// The face the type is set in: the version it names when that one is installed.
|
||||
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
let mut row = json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
});
|
||||
if let Some(v) = version {
|
||||
row["version"] = json!(v);
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| r.style.font_style.clone());
|
||||
r.style.font_family = tf.clone();
|
||||
r.style.font_style = style;
|
||||
// Another font: none of the old one's versions.
|
||||
r.style.font_version = None;
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -135,11 +135,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index, name?, x?, y?, width?, height?}",
|
||||
"{index, name?, x?, y?, width?, height?, 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)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art),
|
||||
cmd!(
|
||||
"artboard.fitToArt",
|
||||
"Fit to Artwork Bounds",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
|
||||
has_doc,
|
||||
artboard_fit_art
|
||||
),
|
||||
cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel),
|
||||
cmd!(
|
||||
"layer.clippingMask.toggle",
|
||||
@@ -752,26 +760,69 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
|
||||
// Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
let art = if fit.is_some() {
|
||||
// Locked and hidden art too? Noted in the journal, so a replay scales the same objects
|
||||
// whatever the preference is then.
|
||||
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
s.doc()?.doc.art_on_artboard(was, all)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut sc = match fit {
|
||||
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
|
||||
_ => Scaling::default(),
|
||||
};
|
||||
sc.patterns = super::object::transform_patterns(s, p, &art)?;
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let was = a.rect;
|
||||
a.rect = rect_from(p, a.rect);
|
||||
a.rect = now;
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
a.name = n.to_string();
|
||||
}
|
||||
// Moved (not resized), it takes its guides along.
|
||||
let (id, now) = (a.id, a.rect);
|
||||
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
|
||||
let id = a.id;
|
||||
if let Some(xf) = fit {
|
||||
d.map_artboard_guides(id, xf);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
} else if !resized {
|
||||
// Moved (not resized), it takes its guides along.
|
||||
d.move_artboard_guides(id, now.origin() - was.origin());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
if !scale_art {
|
||||
return ok();
|
||||
}
|
||||
Ok(json!({ "scaled": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
|
||||
/// when `from` has no area or the map isn't finite (a size out of range).
|
||||
fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
||||
if from.width() <= 1e-9 || from.height() <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
|
||||
let xf = Affine::translate(to.origin().to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-from.origin().to_vec2());
|
||||
xf.is_finite().then_some(xf)
|
||||
}
|
||||
|
||||
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
|
||||
/// layers don't count.
|
||||
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?;
|
||||
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Libraries (Window › Libraries): local libraries of graphics, colours and text styles that every
|
||||
//! document can use. A library is one `.vclibrary` file (JSON) in the library folder next to the
|
||||
//! preferences, which the desktop app sets; without a folder (the web, headless sessions) libraries
|
||||
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
|
||||
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
|
||||
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
|
||||
//! merged into the document's).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::TextStyleDef;
|
||||
|
||||
use super::*;
|
||||
use crate::Clipboard;
|
||||
|
||||
/// The extension of a library file.
|
||||
pub const LIBRARY_EXT: &str = "vclibrary";
|
||||
/// Bytes a library file may have when read (graphics with large images included).
|
||||
const MAX_FILE: u64 = 512 << 20;
|
||||
/// Items of one kind a library may hold.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Library {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub colors: Vec<LibraryColor>,
|
||||
#[serde(default)]
|
||||
pub char_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryColor {
|
||||
pub name: String,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
/// A graphic: the art as a native document (its own resources included) and its thumbnail.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryGraphic {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Size of the art (points).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// The native document, base64.
|
||||
pub data: String,
|
||||
/// A PNG of the art, at most 256 pixels a side, base64 (empty when it draws nothing).
|
||||
#[serde(default)]
|
||||
pub thumbnail: String,
|
||||
}
|
||||
|
||||
/// The libraries of a session ([`Session::libraries`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Libraries {
|
||||
/// The library folder: none in the web app and headless sessions.
|
||||
dir: Option<String>,
|
||||
/// (id: the file's stem, library), in name order.
|
||||
libs: Vec<(String, Library)>,
|
||||
/// The library the panel shows, which commands without a `library` act on.
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
impl Libraries {
|
||||
pub fn dir(&self) -> Option<&str> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Set the library folder and read its libraries (unreadable files are skipped).
|
||||
pub fn set_dir(&mut self, dir: Option<String>) {
|
||||
self.dir = dir;
|
||||
self.libs.clear();
|
||||
if let Some(dir) = self.dir.clone() {
|
||||
for path in library_paths(&dir) {
|
||||
let id = stem_of(&path);
|
||||
let lib = super::fileio::file_stamp(&path)
|
||||
.filter(|(len, _)| *len <= MAX_FILE)
|
||||
.and_then(|_| super::fileio::read_file(&path).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
self.libs.push((id, lib));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.libs.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()).then(a.0.cmp(&b.0)));
|
||||
}
|
||||
|
||||
/// The libraries, as (id, library), in name order.
|
||||
pub fn all(&self) -> &[(String, Library)] {
|
||||
&self.libs
|
||||
}
|
||||
|
||||
/// The library the panel shows: the one chosen last, else the first.
|
||||
pub fn current(&self) -> Option<&str> {
|
||||
self.current.as_deref().filter(|c| self.get(c).is_some()).or_else(|| self.libs.first().map(|(i, _)| i.as_str()))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Library> {
|
||||
self.libs.iter().find(|(i, _)| i == id).map(|(_, l)| l)
|
||||
}
|
||||
|
||||
/// Library `key` by id, else by name (ignoring case).
|
||||
fn find(&self, key: &str) -> Option<String> {
|
||||
self.libs.iter().find(|(i, _)| i == key).or_else(|| self.libs.iter().find(|(_, l)| l.name.eq_ignore_ascii_case(key))).map(|(i, _)| i.clone())
|
||||
}
|
||||
|
||||
/// A new empty library called `name` → its id.
|
||||
fn create(&mut self, name: &str) -> Result<String> {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let base = base.trim();
|
||||
let base = if base.is_empty() { "Library" } else { base };
|
||||
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
|
||||
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
|
||||
if let Err(e) = self.save(&id) {
|
||||
self.libs.retain(|(i, _)| i != &id);
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
self.current = Some(id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_exists(&self, id: &str) -> bool {
|
||||
self.path(id).is_some_and(|p| std::path::Path::new(&p).exists())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn file_exists(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn path(&self, id: &str) -> Option<String> {
|
||||
let dir = self.dir.as_deref()?;
|
||||
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Write library `id` to its file (nothing to do without a folder).
|
||||
fn save(&self, id: &str) -> Result<()> {
|
||||
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
|
||||
if let Some(dir) = self.dir.as_deref() {
|
||||
super::fileio::create_dir(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
super::fileio::write_file(&path, &bytes)
|
||||
}
|
||||
|
||||
/// Change library `id` with `f` and save it; on a failed save the change is undone.
|
||||
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
|
||||
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
|
||||
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
|
||||
let r = match self.libs.get_mut(i) {
|
||||
Some((_, lib)) => f(lib)?,
|
||||
None => return Err(EngineError::Other(format!("no library `{id}`"))),
|
||||
};
|
||||
if let Err(e) = self.save(id) {
|
||||
if let Some((_, lib)) = self.libs.get_mut(i) {
|
||||
*lib = before;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete(&mut self, id: &str) -> Result<()> {
|
||||
if let Some(path) = self.path(id) {
|
||||
remove_file(&path)?;
|
||||
}
|
||||
self.libs.retain(|(i, _)| i != id);
|
||||
if self.current.as_deref() == Some(id) {
|
||||
self.current = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
|
||||
fn cap(&mut self) {
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_paths(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<String> = rd
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x.to_string_lossy().eq_ignore_ascii_case(LIBRARY_EXT)))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn library_paths(_: &str) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn remove_file(path: &str) -> Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(EngineError::Other(format!("{path}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn remove_file(_: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stem_of(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The kinds of library item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Graphic,
|
||||
FillColor,
|
||||
StrokeColor,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(p: &Value, cmd: &str) -> Result<Self> {
|
||||
Ok(match str_param(p, "kind") {
|
||||
Some("graphic") => Kind::Graphic,
|
||||
Some("fillColor") => Kind::FillColor,
|
||||
Some("strokeColor") => Kind::StrokeColor,
|
||||
Some("color") => Kind::FillColor,
|
||||
Some("charStyle") => Kind::CharStyle,
|
||||
Some("paraStyle") => Kind::ParaStyle,
|
||||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "library.list",
|
||||
"Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "library.get",
|
||||
"Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
cmd!(
|
||||
"library.setCurrent",
|
||||
"Show Library",
|
||||
[],
|
||||
None,
|
||||
"{library: id or name} the library the Libraries panel shows, which library commands without a `library` act on → {id}",
|
||||
always,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"library.create",
|
||||
"Create New Library",
|
||||
[],
|
||||
None,
|
||||
"{name?: \"My Library\"} a new empty library (its file in the library folder), made the current one → {id, name}",
|
||||
always,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"library.rename",
|
||||
"Rename Library",
|
||||
[],
|
||||
None,
|
||||
"{library?, name} rename a library (default: the current one) → {id, name}",
|
||||
always,
|
||||
rename
|
||||
),
|
||||
cmd!(
|
||||
"library.delete",
|
||||
"Delete Library",
|
||||
[],
|
||||
None,
|
||||
"{library?} delete a library (default: the current one) and its file; not undoable → null",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"library.add",
|
||||
"Add to Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"library.use",
|
||||
"Use Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
|
||||
]
|
||||
}
|
||||
|
||||
/// The library `p` names (`library`: id or name), else the current one.
|
||||
fn library_id(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match str_param(p, "library") {
|
||||
Some(key) => s.libraries.find(key).ok_or_else(|| bad(cmd, format!("no library `{key}`"))),
|
||||
None => s.libraries.current().map(str::to_string).ok_or_else(|| bad(cmd, "there is no library: create one first")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.setCurrent";
|
||||
let key = str_param(p, "library").ok_or_else(|| bad(C, "missing `library`"))?;
|
||||
let id = s.libraries.find(key).ok_or_else(|| bad(C, format!("no library `{key}`")))?;
|
||||
s.libraries.current = Some(id.clone());
|
||||
Ok(json!({ "id": id }))
|
||||
}
|
||||
|
||||
fn counts(lib: &Library) -> Value {
|
||||
json!({
|
||||
"graphics": lib.graphics.len(),
|
||||
"colors": lib.colors.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let libraries: Vec<Value> = s
|
||||
.libraries
|
||||
.all()
|
||||
.iter()
|
||||
.map(|(id, lib)| {
|
||||
let mut v = json!({ "id": id, "name": lib.name });
|
||||
if let (Some(o), Value::Object(c)) = (v.as_object_mut(), counts(lib)) {
|
||||
o.extend(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libraries, "current": s.libraries.current(), "folder": s.libraries.dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.get")?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad("library.get", format!("no library `{id}`")))?;
|
||||
Ok(json!({
|
||||
"id": id,
|
||||
"name": lib.name,
|
||||
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
|
||||
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
|
||||
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("My Library").to_string();
|
||||
let id = s.libraries.create(&name)?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.rename";
|
||||
let id = library_id(s, p, C)?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let n = name.clone();
|
||||
s.libraries.change(&id, |l| {
|
||||
l.name = n;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.delete")?;
|
||||
s.libraries.delete(&id)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The library `p` names, else the current one, else a new "My Library".
|
||||
fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() {
|
||||
return library_id(s, p, cmd);
|
||||
}
|
||||
s.libraries.create("My Library")
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.add";
|
||||
let kind = Kind::of(p, C)?;
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// Read what is added before a library is made for it, so a failure makes none.
|
||||
let item = match kind {
|
||||
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
|
||||
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
|
||||
Item::Style(para, attrs)
|
||||
}
|
||||
};
|
||||
let id = target_library(s, p, C)?;
|
||||
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
|
||||
let r = s.libraries.change(&id, |lib| {
|
||||
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
|
||||
Ok(match item {
|
||||
Item::Graphic(mut g) => {
|
||||
full(lib.graphics.len())?;
|
||||
if let Some(old) = lib.graphics.iter().find(|o| o.data == g.data) {
|
||||
return Ok(json!({ "name": old.name, "id": old.id, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| g.name.clone());
|
||||
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
|
||||
let v = json!({ "name": g.name, "id": g.id, "existing": false });
|
||||
lib.graphics.push(g);
|
||||
v
|
||||
}
|
||||
Item::Color((name, color)) => {
|
||||
full(lib.colors.len())?;
|
||||
if let Some(old) = lib.colors.iter().find(|o| o.color == color) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
|
||||
lib.colors.push(LibraryColor { name: name.clone(), color });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
Item::Style(para, attrs) => {
|
||||
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
|
||||
full(list.len())?;
|
||||
if let Some(old) = list.iter().find(|o| o.attrs == attrs) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
|
||||
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
|
||||
list.push(TextStyleDef { name: name.clone(), attrs });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
})
|
||||
})?;
|
||||
let mut r = r;
|
||||
r["library"] = json!(id);
|
||||
r["kind"] = json!(kind_name);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
enum Item {
|
||||
Graphic(LibraryGraphic),
|
||||
Color((String, Color)),
|
||||
Style(bool, Map<String, Value>),
|
||||
}
|
||||
|
||||
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
|
||||
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.in_paint_order(&st.doc),
|
||||
};
|
||||
let roots = super::edit::roots_of(&st.doc, ids);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(cmd, "select the art to add first"));
|
||||
}
|
||||
let clip = Clipboard::copy(st, &roots);
|
||||
let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;
|
||||
let mut doc = clip.to_document();
|
||||
let board = bounds.inflate(1.0, 1.0);
|
||||
// One artboard round the art: what the thumbnail shows.
|
||||
doc.artboards.truncate(1);
|
||||
match doc.artboards.first_mut() {
|
||||
Some(a) => a.rect = board,
|
||||
None => return Err(bad(cmd, "the graphic's document has no artboard")),
|
||||
}
|
||||
let name = match roots.as_slice() {
|
||||
[one] => st.doc.node(*one).map(|n| n.display_name()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let name = if name.trim().is_empty() || name.starts_with('<') { "Graphic".to_string() } else { name };
|
||||
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
|
||||
Ok(LibraryGraphic { id: String::new(), name, width: bounds.width(), height: bounds.height(), data, thumbnail })
|
||||
}
|
||||
|
||||
/// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a
|
||||
/// name: its swatch's, else its hex.
|
||||
fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> {
|
||||
// A colour given (a swatch dropped on the panel).
|
||||
if let Some(c) = p.get("color").filter(|v| !v.is_null()) {
|
||||
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
|
||||
return Ok((color.to_hex(), color));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
|
||||
paint.cloned()
|
||||
});
|
||||
let paint = match from_art {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let d = &s.paint;
|
||||
if stroke { d.stroke.clone() } else { d.fill.clone() }
|
||||
}
|
||||
};
|
||||
match paint {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
|
||||
Ok((name, color))
|
||||
}
|
||||
Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })),
|
||||
_ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
|
||||
/// paragraph style's from its alignment and size too, else "Paragraph Style".
|
||||
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
|
||||
let s = |k: &str| attrs.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let size = attrs.get("size").and_then(Value::as_f64).map(|v| format!("{} pt", (v * 100.0).round() / 100.0));
|
||||
let words: Vec<String> = [s("font_family"), s("font_style")].into_iter().chain(size).filter(|w| !w.is_empty()).collect();
|
||||
match (para, words.is_empty()) {
|
||||
(false, false) => words.join(" "),
|
||||
(false, true) => "Character Style".into(),
|
||||
(true, _) => "Paragraph Style".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The library item `p` names: (library id, kind).
|
||||
fn item_param<'a>(s: &Session, p: &'a Value, cmd: &str) -> Result<(String, Kind, &'a str)> {
|
||||
let id = library_id(s, p, cmd)?;
|
||||
let kind = Kind::of(p, cmd)?;
|
||||
let item = str_param(p, "item").ok_or_else(|| bad(cmd, "missing `item`"))?;
|
||||
Ok((id, kind, item))
|
||||
}
|
||||
|
||||
fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.use";
|
||||
// A replay (the journal) carries what the item was, so it doesn't depend on the library.
|
||||
let recorded = p.get("recorded").cloned();
|
||||
let (kind, data) = match recorded {
|
||||
Some(r) => (Kind::of(p, C)?, r),
|
||||
None => {
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let missing = || bad(C, format!("the library has no item `{item}`"));
|
||||
let data = match kind {
|
||||
Kind::Graphic => {
|
||||
let g =
|
||||
lib.graphics.iter().find(|g| g.id == item).or_else(|| lib.graphics.iter().find(|g| g.name == item)).ok_or_else(missing)?;
|
||||
json!({ "data": g.data })
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
|
||||
json!({ "color": c.color })
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
|
||||
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
|
||||
json!({ "name": t.name, "attrs": t.attrs })
|
||||
}
|
||||
};
|
||||
s.note_journal("recorded", data.clone());
|
||||
(kind, data)
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let bytes = data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(vectorcraft_format::base64_decode)
|
||||
.ok_or_else(|| bad(C, "the graphic's data is damaged"))?;
|
||||
let doc = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("the graphic can't be read: {e}")))?;
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the graphic is empty"));
|
||||
}
|
||||
let center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
|
||||
};
|
||||
let mut r = super::edit::place_clip(s, &clip, center)?;
|
||||
r["kind"] = json!("graphic");
|
||||
Ok(r)
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let stroke = match str_param(p, "to") {
|
||||
Some("fill") => false,
|
||||
Some("stroke") => true,
|
||||
Some(_) => return Err(bad(C, "to must be fill or stroke")),
|
||||
None => kind == Kind::StrokeColor,
|
||||
};
|
||||
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
|
||||
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
|
||||
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
|
||||
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let doc = &s.doc()?.doc;
|
||||
// The document's style of that name when it is the same; else a new one.
|
||||
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
|
||||
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
|
||||
let prefix = if para { "paraStyle" } else { "charStyle" };
|
||||
in_one_step(s, |s| {
|
||||
if !same {
|
||||
s.execute(&format!("{prefix}.new"), &json!({ "name": style, "attrs": attrs }))?;
|
||||
}
|
||||
s.execute(&format!("{prefix}.apply"), &json!({ "name": style }))
|
||||
})?;
|
||||
Ok(json!({ "kind": if para { "paraStyle" } else { "charStyle" }, "style": style }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
|
||||
/// already open (its own step then holds them).
|
||||
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
let open = s.active().is_some_and(|st| st.undo_group.is_some());
|
||||
if !open {
|
||||
s.begin_undo_group();
|
||||
}
|
||||
let r = f(s);
|
||||
if !open {
|
||||
s.end_undo_group(r.is_err());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.removeItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let item = item.to_string();
|
||||
s.libraries.change(&id, |lib| {
|
||||
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
|
||||
if let Some(i) = at {
|
||||
lib.graphics.remove(i);
|
||||
}
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
|
||||
}
|
||||
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
|
||||
return Err(bad(C, format!("the library has no item `{item}`")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -283,6 +283,9 @@ fn stale(d: &Document, g: &Group) -> bool {
|
||||
pub(crate) struct LinkedFile {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
/// The file actually read, which may be next to the current document after a move.
|
||||
/// Nested links must be resolved against this folder, not the obsolete original path.
|
||||
pub found_path: Option<String>,
|
||||
/// Read from where the file is found, else the pixels the document holds when they are the
|
||||
/// file's own; `None` when neither.
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
@@ -297,10 +300,10 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
.filter(|g| seen.insert(g.link.path.clone()))
|
||||
.map(|g| {
|
||||
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
|
||||
let bytes = locate(&g.link, folder(doc_path))
|
||||
.and_then(|mut f| f.read().ok())
|
||||
.or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), bytes }
|
||||
let found = locate(&g.link, folder(doc_path));
|
||||
let found_path = found.as_ref().map(|f| f.path.clone());
|
||||
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ mod effectcmd;
|
||||
pub mod expand;
|
||||
pub(crate) mod fileinfo;
|
||||
pub mod fileio;
|
||||
pub mod findfiles;
|
||||
pub mod flatten;
|
||||
pub mod fontfiles;
|
||||
mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub(crate) mod gradient;
|
||||
@@ -32,6 +34,7 @@ pub mod help;
|
||||
pub(crate) mod inline;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod library;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -222,6 +225,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(inline::specs());
|
||||
v.extend(textstyles::specs());
|
||||
v.extend(fonts::specs());
|
||||
v.extend(fontfiles::specs());
|
||||
v.extend(help::specs());
|
||||
v.extend(threads::specs());
|
||||
v.extend(textwrap::specs());
|
||||
@@ -245,6 +249,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(freeform::specs());
|
||||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(newart::specs());
|
||||
@@ -302,6 +307,21 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
|
||||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
|
||||
}
|
||||
/// Validate all explicit targets before a command changes the document or selection.
|
||||
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
|
||||
let values =
|
||||
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
|
||||
values.iter().map(|value| checked_id(s, value, command)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
|
||||
if s.doc()?.doc.node(id).is_none() {
|
||||
return Err(bad(command, format!("no such object id {value}")));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
@@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
|
||||
let angle = |k: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|a| a.is_finite())
|
||||
.map(|a| Some(a.rem_euclid(360.0)))
|
||||
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
|
||||
}
|
||||
};
|
||||
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
|
||||
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.set_sides(n);
|
||||
*path = l.to_path();
|
||||
}
|
||||
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
|
||||
&& (pie_start.is_some() || pie_end.is_some() || invert)
|
||||
{
|
||||
if let LiveShape::Ellipse { pie, .. } = l {
|
||||
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert {
|
||||
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
|
||||
}
|
||||
}
|
||||
*path = l.to_path();
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
use vectorcraft_doc::{Document, LinkInfo};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
@@ -32,7 +35,7 @@ type Entry = (String, Vec<u8>);
|
||||
|
||||
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
|
||||
/// desktop file systems do); the name is taken.
|
||||
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
|
||||
pub(crate) fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
|
||||
let p = Path::new(name);
|
||||
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
|
||||
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
@@ -50,6 +53,17 @@ fn font_ext(bytes: &[u8]) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a font's file gets in the package's `Fonts` folder: the name of the file at `path`,
|
||||
/// with the extension the font's `data` calls for ([`font_ext`]) when that name has no font
|
||||
/// extension; without a file, `family-style` with that extension.
|
||||
fn packaged_name(path: Option<&Path>, family: &str, style: &str, data: &[u8]) -> String {
|
||||
match path.and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()) {
|
||||
Some(name) if vectorcraft_text::is_font_file(Path::new(&name)) => name,
|
||||
Some(name) => format!("{name}.{}", font_ext(data)),
|
||||
None => format!("{family}-{style}.{}", font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of a package.
|
||||
struct Options {
|
||||
copy_links: bool,
|
||||
@@ -59,6 +73,238 @@ struct Options {
|
||||
report: bool,
|
||||
}
|
||||
|
||||
/// An asset that has been copied to the package, including rewritten linked documents.
|
||||
#[derive(Clone)]
|
||||
struct Copied {
|
||||
name: String,
|
||||
size: Option<u64>,
|
||||
hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The parent of a packaged document and its asset both live inside the package.
|
||||
/// Use paths relative to that *document*, not the package's root: a linked document
|
||||
/// in Links/ must reference its own sibling image as "image.png".
|
||||
fn relative_in_package(document: &str, asset: &str) -> String {
|
||||
let parent = document.rsplit_once('/').map_or("", |(dir, _)| dir);
|
||||
let a: Vec<&str> = parent.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let b: Vec<&str> = asset.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let common = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let mut out = vec![".."; a.len() - common];
|
||||
out.extend_from_slice(&b[common..]);
|
||||
out.join("/")
|
||||
}
|
||||
|
||||
fn safe_asset_name(name: &str) -> String {
|
||||
let name: String =
|
||||
name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect();
|
||||
let name = name.trim().trim_matches('.');
|
||||
if name.is_empty() { "asset".into() } else { name.into() }
|
||||
}
|
||||
|
||||
/// Collect dependencies depth-first so nested VectorCraft files are rewritten before
|
||||
/// their parents: each parent can record the final hash of the rewritten child.
|
||||
/// Cycles, excessive nesting, and unreadable children are reported, not fatal.
|
||||
struct Collector<'a> {
|
||||
opts: &'a Options,
|
||||
root: Option<PathBuf>,
|
||||
entries: Vec<Entry>,
|
||||
copies: BTreeMap<String, Copied>,
|
||||
visiting: BTreeMap<String, String>,
|
||||
taken: HashSet<String>,
|
||||
link_names: HashSet<String>,
|
||||
font_files: HashSet<String>,
|
||||
fonts: usize,
|
||||
missing: Vec<String>,
|
||||
skipped: Vec<Value>,
|
||||
warnings: Vec<String>,
|
||||
/// The report's lines, one section per document: the packaged one first, then each placed
|
||||
/// document in the order it is reached.
|
||||
sections: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> Collector<'a> {
|
||||
fn new(opts: &'a Options, root: Option<PathBuf>, doc_name: &str) -> Self {
|
||||
let mut taken = HashSet::new();
|
||||
taken.insert(doc_name.to_lowercase());
|
||||
Self {
|
||||
opts,
|
||||
root,
|
||||
entries: Vec::new(),
|
||||
copies: BTreeMap::new(),
|
||||
visiting: BTreeMap::new(),
|
||||
taken,
|
||||
link_names: HashSet::new(),
|
||||
font_files: HashSet::new(),
|
||||
fonts: 0,
|
||||
missing: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
sections: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, lines: &mut Vec<String>, message: String) {
|
||||
lines.push(format!("WARNING: {message}"));
|
||||
self.warnings.push(message);
|
||||
}
|
||||
|
||||
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> {
|
||||
// The caller checks cycles and depth before descending; active documents have
|
||||
// a destination reserved so back-links can point at their single packaged copy.
|
||||
self.visiting.insert(source.to_string(), destination.to_string());
|
||||
// This document's section comes before those of the documents it places.
|
||||
let section = self.sections.len();
|
||||
self.sections.push(vec![]);
|
||||
let mut lines = vec![];
|
||||
// The packaged document's sections read as before; a placed document's name its file.
|
||||
let heading = |lines: &mut Vec<String>, title: &str| {
|
||||
lines.push(title.into());
|
||||
if depth > 0 {
|
||||
lines.push(format!("Document: {source}"));
|
||||
}
|
||||
};
|
||||
// A file's saved path is only meaningful inside its own document.
|
||||
// Different linked documents can reuse the same stale absolute path yet
|
||||
// resolve it to distinct local assets. Keep a mapping per document.
|
||||
let mut local = BTreeMap::<String, Copied>::new();
|
||||
if self.opts.copy_links {
|
||||
let mut placed = HashSet::new();
|
||||
doc.visit_placed(|_, p| {
|
||||
placed.insert(p.link.path.clone());
|
||||
});
|
||||
heading(&mut lines, "LINKED FILES");
|
||||
for file in super::links::linked_files(doc, Some(source)) {
|
||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
|
||||
// The ancestor is already included (possibly the root document). A
|
||||
// cyclic reference cannot have a final content hash until both files
|
||||
// have been written, so leave its size/hash unspecified, not stale.
|
||||
self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
|
||||
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = self.copies.get(found_path) {
|
||||
local.insert(file.path.clone(), existing.clone());
|
||||
continue;
|
||||
}
|
||||
let Some(mut bytes) = file.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
|
||||
continue;
|
||||
};
|
||||
if self.copies.len() >= 10_000 {
|
||||
return Err(bad(C, "more than 10000 linked files: package dependencies in smaller parts"));
|
||||
}
|
||||
let dir = if self.opts.links_folder { "Links/" } else { "" };
|
||||
let name = safe_asset_name(&file.name);
|
||||
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
||||
let destination_file = format!("{dir}{unique}");
|
||||
if placed.contains(&file.path) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
} else {
|
||||
match vectorcraft_format::load_file(&bytes) {
|
||||
Ok(saved) => {
|
||||
let preview = vectorcraft_format::preview(&bytes);
|
||||
let pdf = vectorcraft_format::pdf_content(&bytes);
|
||||
let compressed = bytes.starts_with(&[0x1f, 0x8b]);
|
||||
let include_linked =
|
||||
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
|
||||
let mut nested = saved.doc;
|
||||
self.collect(&mut nested, found_path, &destination_file, depth + 1)?;
|
||||
if self.opts.relink {
|
||||
// Preserve auxiliary native-file content when rewriting.
|
||||
let mut options = vectorcraft_format::SaveOptions::for_doc(&nested);
|
||||
options.preview = preview;
|
||||
options.pdf = pdf;
|
||||
options.profiles = saved.profiles;
|
||||
options.compress = compressed;
|
||||
options.include_linked = include_linked;
|
||||
match vectorcraft_format::save_with(&nested, &options) {
|
||||
Ok(rewritten) => bytes = rewritten,
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!(
|
||||
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
|
||||
file.path
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
|
||||
self.copies.insert(found_path.to_string(), copy.clone());
|
||||
local.insert(file.path.clone(), copy);
|
||||
lines.push(format!("{} → {destination_file}", file.path));
|
||||
self.entries.push((destination_file, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
if self.opts.copy_fonts {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
heading(&mut lines, "FONTS");
|
||||
for used in super::fonts::used_fonts(doc) {
|
||||
let font = super::fonts::font_label(&used);
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names, as the canvas draws it (in the version the type names).
|
||||
let resolved = db
|
||||
.resolve(&family, &style)
|
||||
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
|
||||
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
// A style the family lacks is shown in its closest style, whose file is copied.
|
||||
let shown = match &resolved {
|
||||
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
|
||||
_ => String::new(),
|
||||
};
|
||||
let reason = match resolved {
|
||||
Some((f, _)) if f.embeddable() => {
|
||||
let data = f.file_data();
|
||||
let file = packaged_name(f.path(), &f.family, &f.style, data);
|
||||
// A collection, or a style shown in another face's file, is copied once.
|
||||
if self.font_files.insert(file.to_lowercase()) {
|
||||
self.entries.push((format!("Fonts/{file}"), data.to_vec()));
|
||||
self.fonts += 1;
|
||||
}
|
||||
lines.push(format!("{font}{shown} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(_) => "its licence doesn't allow embedding",
|
||||
None => "not available on this computer",
|
||||
};
|
||||
lines.push(format!("{font}{shown}: {reason}, not copied"));
|
||||
self.skipped.push(json!({ "font": font, "reason": reason }));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
if self.opts.relink {
|
||||
// The child files have already been rewritten; the hash and size of each
|
||||
// target must reflect the *packaged* bytes rather than the original.
|
||||
doc.update_links(|link: &mut LinkInfo| {
|
||||
if let Some(copy) = local.get(&link.path) {
|
||||
let relative = relative_in_package(destination, ©.name);
|
||||
link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned());
|
||||
link.relative = Some(relative);
|
||||
link.size = copy.size;
|
||||
link.hash = copy.hash.clone();
|
||||
link.modified = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(s) = self.sections.get_mut(section) {
|
||||
*s = lines;
|
||||
}
|
||||
self.visiting.remove(source);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = Options {
|
||||
copy_links: bool_or(p, "copyLinks", true),
|
||||
@@ -77,83 +323,26 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let root = folder.map(|f| Path::new(f).join(&name));
|
||||
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
||||
let st = s.doc()?;
|
||||
let mut entries: Vec<Entry> = vec![];
|
||||
let mut taken = HashSet::new();
|
||||
let mut lines = vec![];
|
||||
|
||||
// The linked files.
|
||||
let (mut copies, mut missing) = (BTreeMap::new(), vec![]);
|
||||
if o.copy_links {
|
||||
let dir = if o.links_folder { "Links/" } else { "" };
|
||||
let mut names = HashSet::new();
|
||||
lines.push("LINKED FILES".to_string());
|
||||
for f in super::links::linked_files(&st.doc, Some(&doc_path)) {
|
||||
let Some(bytes) = f.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", f.name, f.path));
|
||||
missing.push(f.name);
|
||||
continue;
|
||||
};
|
||||
let rel = format!("{dir}{}", free_name(&f.name, if o.links_folder { &mut names } else { &mut taken }));
|
||||
lines.push(format!("{} → {rel}", f.path));
|
||||
copies.insert(f.path, rel.clone());
|
||||
entries.push((rel, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
// The fonts.
|
||||
let (mut fonts, mut skipped) = (0, vec![]);
|
||||
if o.copy_fonts {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut files = HashSet::new();
|
||||
lines.push("FONTS".to_string());
|
||||
for (family, style) in super::fonts::used_fonts(&st.doc) {
|
||||
let font = format!("{family} {style}");
|
||||
let reason = match db.face(&family, &style) {
|
||||
Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => {
|
||||
let data = f.file_data();
|
||||
let file = f.path().and_then(Path::file_name).map_or_else(
|
||||
|| format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
|
||||
|n| n.to_string_lossy().into_owned(),
|
||||
);
|
||||
// A collection, or a style shown in another face's file, is copied once.
|
||||
if files.insert(file.to_lowercase()) {
|
||||
entries.push((format!("Fonts/{file}"), data.to_vec()));
|
||||
fonts += 1;
|
||||
}
|
||||
lines.push(format!("{font} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
|
||||
_ => "not available on this computer",
|
||||
};
|
||||
lines.push(format!("{font}: {reason}, not copied"));
|
||||
skipped.push(json!({ "font": font, "reason": reason }));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
// The document, linking to the copies.
|
||||
let mut doc = (*st.doc).clone();
|
||||
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
|
||||
// Prepare original links for the destination *before* rebasing copied links.
|
||||
// Recalculating afterward would overwrite the portable relative paths.
|
||||
if let Some(root) = &root
|
||||
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
|
||||
{
|
||||
doc = d;
|
||||
}
|
||||
if o.relink && !copies.is_empty() {
|
||||
let copied = |im: &vectorcraft_doc::ImageObject| im.link.as_ref().is_some_and(|l| copies.contains_key(&l.path));
|
||||
doc.update_images(copied, |im| {
|
||||
if let Some(l) = &mut im.link
|
||||
&& let Some(rel) = copies.get(&l.path)
|
||||
{
|
||||
l.path = root.as_ref().map_or_else(|| rel.clone(), |r| r.join(rel).to_string_lossy().into_owned());
|
||||
(l.relative, l.modified) = (Some(rel.clone()), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut collector = Collector::new(&o, root.clone(), &doc_name);
|
||||
collector.collect(&mut doc, &doc_path, &doc_name, 0)?;
|
||||
let mut entries = std::mem::take(&mut collector.entries);
|
||||
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
|
||||
taken.insert(doc_name.to_lowercase());
|
||||
let mut taken = std::mem::take(&mut collector.taken);
|
||||
let lines = collector.sections.concat();
|
||||
let missing = std::mem::take(&mut collector.missing);
|
||||
let skipped = std::mem::take(&mut collector.skipped);
|
||||
let warnings = std::mem::take(&mut collector.warnings);
|
||||
let fonts = collector.fonts;
|
||||
let links = collector.copies.len();
|
||||
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
@@ -165,7 +354,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect();
|
||||
let mut out = json!({ "files": files, "links": copies.len(), "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped });
|
||||
let mut out = json!({ "files": files, "links": links, "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped, "warnings": warnings });
|
||||
match root {
|
||||
Some(root) => {
|
||||
for (rel, bytes) in &entries {
|
||||
@@ -247,3 +436,21 @@ pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A font read from a file without a font extension (one a font manager keeps, for example) is
|
||||
/// packaged with the extension its data calls for.
|
||||
#[test]
|
||||
fn packaged_fonts_have_a_font_extension() {
|
||||
let (otf, ttc, ttf) = (b"OTTO....".as_slice(), b"ttcf....".as_slice(), b"\0\x01\0\0....".as_slice());
|
||||
let name = |path: Option<&str>, data: &[u8]| packaged_name(path.map(Path::new), "Some Family", "Bold", data);
|
||||
assert_eq!(name(Some("/fonts/A8F3C2"), otf), "A8F3C2.otf");
|
||||
assert_eq!(name(Some("/fonts/Shared Fonts.dat"), ttc), "Shared Fonts.dat.ttc");
|
||||
assert_eq!(name(Some("/fonts/Kept.TTF"), otf), "Kept.TTF");
|
||||
assert_eq!(name(Some("/fonts/Kept.otc"), ttc), "Kept.otc");
|
||||
assert_eq!(name(None, ttf), "SomeFamily-Bold.ttf");
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{} join two open paths' nearest endpoints, or close a single open path",
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -283,8 +283,27 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_paths(s)?;
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
let st = s.doc()?;
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
|
||||
return Err(bad(C, format!("{} isn't a path", id.0)));
|
||||
}
|
||||
ids
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
};
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
None => None,
|
||||
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
|
||||
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
|
||||
},
|
||||
};
|
||||
match ids.as_slice() {
|
||||
[one] => {
|
||||
let id = *one;
|
||||
@@ -305,6 +324,9 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
[a, b, ..] => {
|
||||
let (a, b) = (*a, *b);
|
||||
if a == b {
|
||||
return Err(bad(C, "join two different paths"));
|
||||
}
|
||||
s.edit("Join", |d, sel| {
|
||||
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
|
||||
let pa = path_mut(d, a)?;
|
||||
@@ -316,10 +338,11 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
};
|
||||
// Pick the closest pair of ends.
|
||||
// The ends asked for, else the closest pair.
|
||||
let cands =
|
||||
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
|
||||
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false));
|
||||
let (rx, ry) =
|
||||
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
|
||||
if rx {
|
||||
x.reverse();
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ impl Session {
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
|
||||
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
|
||||
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
|
||||
self.prefs = p;
|
||||
if fonts_folder_changed {
|
||||
let folder = self.prefs.fonts_folder.trim();
|
||||
@@ -486,6 +487,10 @@ impl Session {
|
||||
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if hyphen_exceptions_changed {
|
||||
// Preferences › Hyphenation › Exceptions (#394): the layout reads the process-wide list.
|
||||
vectorcraft_text::set_hyphenation_exceptions(&self.prefs.hyphenation_exceptions);
|
||||
}
|
||||
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
|
||||
for st in &mut self.docs {
|
||||
if history_changed {
|
||||
@@ -503,6 +508,10 @@ impl Session {
|
||||
d.grid.subdivisions = self.prefs.grid_subdivisions;
|
||||
st.revision += 1;
|
||||
}
|
||||
// Hyphenation exceptions change every text object's line breaks.
|
||||
if hyphen_exceptions_changed {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
|
||||
super::plugin::sync_prefs(self);
|
||||
|
||||
@@ -25,14 +25,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
|
||||
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
|
||||
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
|
||||
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
|
||||
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
|
||||
cmd!(
|
||||
"select.set",
|
||||
"Select Objects",
|
||||
[],
|
||||
None,
|
||||
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"select.add",
|
||||
"Add to Selection",
|
||||
[],
|
||||
None,
|
||||
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"select.toggle",
|
||||
"Toggle Selection",
|
||||
[],
|
||||
None,
|
||||
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it",
|
||||
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it; all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||||
has_doc,
|
||||
toggle
|
||||
),
|
||||
@@ -225,29 +241,39 @@ fn step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?;
|
||||
s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?;
|
||||
ok()
|
||||
let ids = checked_ids_param(s, p, "ids", "select.set")?;
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
selection_result(s)
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?;
|
||||
let ids = checked_ids_param(s, p, "ids", "select.add")?;
|
||||
s.select(|_, sel| {
|
||||
for i in ids {
|
||||
sel.add(i);
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
selection_result(s)
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
|
||||
let ids = if p.get("ids").is_some() {
|
||||
checked_ids_param(s, p, "ids", "select.toggle")?
|
||||
} else {
|
||||
let value = p.get("id").ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
|
||||
vec![checked_id(s, value, "select.toggle")?]
|
||||
};
|
||||
s.select(|_, sel| {
|
||||
for id in ids {
|
||||
sel.toggle(id);
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
selection_result(s)
|
||||
}
|
||||
|
||||
fn selection_result(s: &Session) -> Result<Value> {
|
||||
let selection = &s.doc()?.selection;
|
||||
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
|
||||
}
|
||||
|
||||
fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -15,7 +15,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
|
||||
|
||||
use super::menucmds::squash;
|
||||
use super::swatch::str_list;
|
||||
use super::swatchlib::{LibraryFile, LibraryInfo, stem};
|
||||
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Other Library…",
|
||||
["Window", "Graphic Style Libraries"],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}",
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
|
||||
always,
|
||||
load
|
||||
),
|
||||
@@ -211,7 +211,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
|
||||
let mut out = json!({ "count": lib.len() });
|
||||
s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?;
|
||||
s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
|
||||
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
|
||||
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
|
||||
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -12,8 +14,9 @@ use vectorcraft_color::palette_io::{self, PaletteFormat};
|
||||
use vectorcraft_color::recolor::Palette;
|
||||
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
|
||||
|
||||
use super::fileio::{create_dir, read_file, write_file};
|
||||
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
|
||||
use super::menucmds::squash;
|
||||
use super::package::free_name;
|
||||
use super::swatch::{map_default_paints, str_list, swatch_json};
|
||||
use super::*;
|
||||
|
||||
@@ -60,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Swatch Library…",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}",
|
||||
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
|
||||
has_doc,
|
||||
save
|
||||
),
|
||||
@@ -69,10 +72,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Other Library…",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
|
||||
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
|
||||
always,
|
||||
load
|
||||
),
|
||||
cmd!(
|
||||
"swatch.library.copyToUser",
|
||||
"Copy to User Defined",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
|
||||
has_user_folder,
|
||||
copy_to_user
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -85,6 +97,9 @@ pub struct Libraries<L = SwatchLibrary> {
|
||||
/// there are no User Defined libraries.
|
||||
user_dir: Option<String>,
|
||||
extra: Vec<Extra<L>>,
|
||||
/// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the
|
||||
/// loaded id and the User Defined id [`Libraries::get`] finds for it.
|
||||
copied: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -117,6 +132,14 @@ impl LibraryFile for SwatchLibrary {
|
||||
/// The extensions of library files [`palette_io::read_bytes`] reads.
|
||||
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
|
||||
|
||||
/// A library file the save commands write ([`Libraries::write`]).
|
||||
pub(crate) enum FileData {
|
||||
/// Text (`.vcswatches`, `.gpl`, CSS, `.vcstyles`), returned as `data`.
|
||||
Text(String),
|
||||
/// Bytes (`.ase`), returned as `dataBase64`.
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<L: LibraryFile> Libraries<L> {
|
||||
pub fn user_dir(&self) -> Option<&str> {
|
||||
self.user_dir.as_deref()
|
||||
@@ -145,9 +168,11 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.extra.iter().map(|e| &e.info)
|
||||
}
|
||||
|
||||
/// The User Defined or loaded library `id`.
|
||||
/// The User Defined or loaded library `id`. The id of a loaded library copied into the user
|
||||
/// library folder finds the User Defined copy.
|
||||
pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc<L>)> {
|
||||
self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()))
|
||||
let find = |id: &str| self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()));
|
||||
find(id).or_else(|| self.copied.iter().find(|(loaded, _)| loaded == id).and_then(|(_, user)| find(user)))
|
||||
}
|
||||
|
||||
/// The file library `id` was read from.
|
||||
@@ -155,36 +180,50 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
|
||||
}
|
||||
|
||||
/// Write library file `text` as the save commands do: into the user library folder as
|
||||
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
|
||||
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
|
||||
let ext = extension(file);
|
||||
self.extra
|
||||
.iter()
|
||||
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
|
||||
}
|
||||
|
||||
/// Write library file `data` as the save commands do: into the user library folder as
|
||||
/// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as
|
||||
/// `data`; the results go into `out`.
|
||||
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> {
|
||||
/// `data` (text) or `dataBase64` (bytes); the results go into `out`.
|
||||
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, data: FileData, out: &mut Value, cmd: &str) -> Result<()> {
|
||||
let bytes = match &data {
|
||||
FileData::Text(text) => text.as_bytes(),
|
||||
FileData::Binary(bytes) => bytes.as_slice(),
|
||||
};
|
||||
if bool_or(p, "user", false) {
|
||||
let dir = self
|
||||
.user_dir()
|
||||
.ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))?
|
||||
.to_string();
|
||||
// A file name from the library's name, without characters file systems reject.
|
||||
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
|
||||
let file = format!("{}.{ext}", base.trim_matches(['.', ' ']));
|
||||
let file = library_file_name(name, ext);
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
|
||||
create_dir(&dir)?;
|
||||
write_file(&path, text.as_bytes())?;
|
||||
write_file(&path, bytes)?;
|
||||
self.rescan();
|
||||
out["path"] = json!(path);
|
||||
out["library"] = json!(format!("user/{file}"));
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
write_file(path, text.as_bytes())?;
|
||||
write_file(path, bytes)?;
|
||||
out["path"] = json!(path);
|
||||
} else {
|
||||
out["data"] = json!(text);
|
||||
match data {
|
||||
FileData::Text(text) => out["data"] = json!(text),
|
||||
FileData::Binary(bytes) => out["dataBase64"] = json!(vectorcraft_format::base64_encode(&bytes)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the
|
||||
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is
|
||||
/// its User Defined library; others are listed as loaded until the app quits.
|
||||
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder,
|
||||
/// or a file with the same extension and bytes as one there, is that User Defined library;
|
||||
/// others are listed as loaded until the app quits.
|
||||
pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result<L>) -> Result<(LibraryInfo, Arc<L>)> {
|
||||
let path = str_param(p, "path");
|
||||
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
@@ -196,7 +235,9 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
|
||||
let lib = parse(&bytes, &file)?;
|
||||
self.rescan();
|
||||
if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) {
|
||||
let user =
|
||||
|p: &str| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p)).or_else(|| self.user_file(p, &bytes));
|
||||
if let Some(e) = path.and_then(user) {
|
||||
return Ok((e.info.clone(), e.lib.clone()));
|
||||
}
|
||||
let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" };
|
||||
@@ -205,6 +246,66 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() });
|
||||
Ok((info, lib))
|
||||
}
|
||||
|
||||
/// Copy loaded library `id` into the user library folder, where it is listed as User Defined in
|
||||
/// place of the loaded entry; `id` then finds the copy ([`Libraries::get`]). A library read
|
||||
/// from a library file (an extension of `L::EXTS`) is copied as that file's bytes under the
|
||||
/// file's name; any other (the swatches of a document, a library loaded from data) is written
|
||||
/// by `encode` as a `.{ext}` file named after the library. A name the folder already has,
|
||||
/// compared without case, gets a number ("Brand 2.ase"), and no file is replaced. When a file
|
||||
/// of the folder with the same extension holds the same bytes, its library is used and nothing
|
||||
/// is written. → the User Defined library, and whether a file was written.
|
||||
pub(crate) fn copy_to_user(
|
||||
&mut self,
|
||||
id: &str,
|
||||
ext: &str,
|
||||
encode: impl FnOnce(&L) -> Result<Vec<u8>>,
|
||||
cmd: &str,
|
||||
) -> Result<(LibraryInfo, Arc<L>, bool)> {
|
||||
let dir = self.user_dir().ok_or_else(|| bad(cmd, "no user library folder here"))?.to_string();
|
||||
let (source, lib) = self
|
||||
.extra
|
||||
.iter()
|
||||
.find(|e| e.info.id == id && e.info.category == "loaded")
|
||||
.map(|e| (e.path.clone(), e.lib.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => (file_name(&p), read_file(&p)?),
|
||||
None => (library_file_name(lib.name(), ext), encode(&lib)?),
|
||||
};
|
||||
self.rescan();
|
||||
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
|
||||
Some(same) => (same, None),
|
||||
None => {
|
||||
create_dir(&dir)?;
|
||||
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
|
||||
let file = free_name(&file, &mut taken);
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
|
||||
write_new_file(&path, &bytes)?;
|
||||
self.rescan();
|
||||
(format!("user/{file}"), Some(path))
|
||||
}
|
||||
};
|
||||
let Some((info, lib)) = self.get(&user_id) else {
|
||||
if let Some(path) = &written {
|
||||
// Best effort: the copy that can't be read is all there is to clean up.
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
return Err(bad(cmd, format!("the copy `{user_id}` can't be read as a library")));
|
||||
};
|
||||
self.extra.retain(|x| x.info.id != id);
|
||||
self.copied.retain(|(loaded, _)| loaded != id);
|
||||
self.copied.push((id.to_string(), info.id.clone()));
|
||||
Ok((info, lib, written.is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name of library `name` with extension `ext`: characters file systems reject become
|
||||
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
|
||||
pub fn library_file_name(name: &str, ext: &str) -> String {
|
||||
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
|
||||
let base = base.trim_matches(['.', ' ']);
|
||||
format!("{}.{ext}", if base.is_empty() { "Library" } else { base })
|
||||
}
|
||||
|
||||
/// The library files with extensions `exts` in folder `dir`, sorted by name.
|
||||
@@ -343,15 +444,16 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.library.save";
|
||||
let path = str_param(p, "path");
|
||||
let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
|
||||
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?,
|
||||
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?,
|
||||
None => PaletteFormat::Native,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
|
||||
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
|
||||
let (count, text) = (lib.len(), palette_io::write(&lib, format));
|
||||
let mut out = json!({"format": format.id(), "count": count});
|
||||
s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?;
|
||||
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
|
||||
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
|
||||
let mut out = json!({"format": format.id(), "count": lib.len()});
|
||||
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -383,6 +485,29 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// There is a user library folder to copy libraries into (the desktop app sets one).
|
||||
fn has_user_folder(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.swatch_libraries.user_dir() {
|
||||
Some(_) => Ok(()),
|
||||
None => Err("no user library folder here".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.library.copyToUser";
|
||||
let (info, lib) = library_param(s, p, C)?;
|
||||
let (info, lib, copied) = match info.category {
|
||||
"loaded" => {
|
||||
let native = |lib: &SwatchLibrary| palette_io::write(lib, PaletteFormat::Native).map_err(|e| bad(C, e));
|
||||
s.swatch_libraries.copy_to_user(&info.id, PaletteFormat::Native.id(), native, C)?
|
||||
}
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
}
|
||||
|
||||
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
|
||||
/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all.
|
||||
fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec<(Option<&'a str>, &'a Swatch)>> {
|
||||
|
||||
@@ -316,6 +316,10 @@ impl CharChange {
|
||||
if let Some(v) = &self.style {
|
||||
st.font_style = v.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
if self.font.is_some() || self.style.is_some() {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
@@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut faces = std::collections::HashMap::new();
|
||||
let mut covers = |st: &CharStyle, c: char| {
|
||||
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
|
||||
let face = faces
|
||||
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
|
||||
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
|
||||
face.as_ref().map(|f| f.covers(c))
|
||||
};
|
||||
// Byte ranges that keep a font (family, style).
|
||||
|
||||
@@ -241,6 +241,17 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
}))
|
||||
}
|
||||
|
||||
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
|
||||
/// library keeps of a text style. `None` without text selected.
|
||||
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
}
|
||||
|
||||
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
|
||||
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
|
||||
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
|
||||
}
|
||||
|
||||
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
|
||||
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
|
||||
}
|
||||
|
||||
@@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
node.appearance = st.appearance();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
// Underline and strikethrough bars become paths of their run's paint too (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
|
||||
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
|
||||
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
|
||||
node.appearance = st.appearance();
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
|
||||
d.remove(tid)?;
|
||||
if children.is_empty() {
|
||||
@@ -416,6 +423,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
if font.is_some() || style.is_some() {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
|
||||
@@ -829,6 +829,8 @@ pub struct Session {
|
||||
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
|
||||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
@@ -859,6 +861,15 @@ pub struct Session {
|
||||
pub(crate) plane_widget_press: bool,
|
||||
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
|
||||
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
|
||||
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
|
||||
/// starts; dropping it stops it.
|
||||
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
|
||||
/// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]).
|
||||
/// Tests set it.
|
||||
pub search_rules: Option<cmd::findfiles::Rules>,
|
||||
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
|
||||
/// it.
|
||||
pub search_threads: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -899,6 +910,7 @@ impl Session {
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
libraries: Default::default(),
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
@@ -910,6 +922,9 @@ impl Session {
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
ruler_guide: None,
|
||||
font_search: None,
|
||||
search_rules: None,
|
||||
search_threads: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1067,10 @@ impl Session {
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
|
||||
if let Err(why) = enabled {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Only top-level commands are journaled (commands that call other commands would otherwise
|
||||
@@ -1453,6 +1471,8 @@ mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fontfiles;
|
||||
#[cfg(test)]
|
||||
mod tests_fontlist;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
@@ -1475,6 +1495,8 @@ mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
|
||||
@@ -209,3 +209,26 @@ fn rotated_box_rect_matches_page_bounds_when_upright() {
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
|
||||
}
|
||||
|
||||
/// The Selection tool says when a drag moves, scales or rotates the selection (the canvas hides the
|
||||
/// bounding box meanwhile, #712), and not on a press that hasn't moved or a marquee.
|
||||
#[test]
|
||||
fn the_selection_tool_reports_a_transforming_drag() {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default());
|
||||
s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap();
|
||||
assert!(!s.tool_transforming(), "a press that hasn't moved");
|
||||
s.pointer(&ev(PointerKind::Drag, 190.0, 160.0), v).unwrap();
|
||||
assert!(s.tool_transforming(), "moving");
|
||||
s.pointer(&ev(PointerKind::Up, 190.0, 160.0), v).unwrap();
|
||||
assert!(!s.tool_transforming());
|
||||
// A marquee on empty canvas isn't a transform.
|
||||
s.pointer(&ev(PointerKind::Down, 500.0, 400.0), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Drag, 550.0, 450.0), v).unwrap();
|
||||
assert!(s.tool_busy() && !s.tool_transforming());
|
||||
s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap();
|
||||
}
|
||||
@@ -790,3 +790,57 @@ fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() {
|
||||
gesture(&mut s, &[(500.0, 400.0)], none);
|
||||
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5));
|
||||
}
|
||||
|
||||
/// #776: the Pen continues any open path from the end clicked, selected or not, and while drawing a
|
||||
/// click on an end of another open path joins the two into one path, in one undo step, which
|
||||
/// finishes it.
|
||||
#[test]
|
||||
fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
use vectorcraft_tools::Cursor;
|
||||
let (v, none) = (view(), Mods::default());
|
||||
let mut s = session();
|
||||
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let b = line(&mut s, 300.0, 200.0, 400.0, 200.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue, "an end of a path that isn't selected");
|
||||
gesture(&mut s, &[(200.0, 100.0)], none);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
assert!(s.doc().unwrap().doc.node(b).is_none() && paths(&s).len() == 1, "one path");
|
||||
// The join finished it: the next click starts a new path.
|
||||
gesture(&mut s, &[(500.0, 500.0)], none);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
// From a path's first end, drawing goes on from there: the path is reversed.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
gesture(&mut s, &[(100.0, 100.0)], none);
|
||||
gesture(&mut s, &[(50.0, 50.0)], none);
|
||||
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
|
||||
}
|
||||
|
||||
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
|
||||
#[test]
|
||||
fn join_takes_the_paths_and_the_ends_to_join() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
|
||||
let b = line(&mut s, 110.0, 0.0, 200.0, 0.0);
|
||||
// The far ends: a's first to b's last (the nearest pair would be a's last and b's first).
|
||||
s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap();
|
||||
let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();
|
||||
assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]);
|
||||
let r = rect(&mut s, 0.0, 50.0, 10.0, 10.0);
|
||||
for bad in [
|
||||
json!({"ids": [a.0, a.0]}),
|
||||
json!({"ids": [a.0, 999]}),
|
||||
json!({"ids": [a.0, r.0], "ends": ["first"]}),
|
||||
json!({"ids": [a.0, b.0], "ends": ["first", "middle"]}),
|
||||
] {
|
||||
assert!(s.execute("path.join", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Missing fonts: listed (`text.missingFonts`), their files found in a folder on threads of their
|
||||
//! own (`text.findFontFiles`), and copied into a folder without replacing a file there. These tests
|
||||
//! never set VectorCraft's Fonts folder and never add a font to the process's font database:
|
||||
//! `tests/font_folder.rs` does both, in a process of its own.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_text::FontMatch;
|
||||
|
||||
use crate::cmd::findfiles::{self, Limits, Rules, Visitor};
|
||||
use crate::cmd::fontfiles::{self, FontSearch, MAX_FONT_FILE, Sought, copy_into};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// A fresh temporary folder (canonical, as a search reports it).
|
||||
fn folder(tag: &str) -> PathBuf {
|
||||
let dir = vectorcraft_testkit::temp_dir(&format!("fontfiles-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
findfiles::plain(std::fs::canonicalize(&dir).unwrap())
|
||||
}
|
||||
|
||||
/// A session whose folder searches may go anywhere in `root`, on two threads.
|
||||
fn session_for(root: &Path) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() });
|
||||
s.search_threads = Some(2);
|
||||
s
|
||||
}
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended (10 seconds at most).
|
||||
fn finished(s: &mut Session) -> Value {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = s.execute("text.findFontFiles", &json!({})).unwrap();
|
||||
if r["state"] != "searching" || Instant::now() > until {
|
||||
return r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// New point type in `font` and `style` → its id.
|
||||
fn text(s: &mut Session, y: f64, font: &str, style: &str) -> u64 {
|
||||
s.execute("text.create", &json!({"x": 10, "y": y, "text": "Ab", "font": font, "style": style})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn bundled(file: &str) -> Vec<u8> {
|
||||
std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_are_listed_symbols_included() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
text(&mut s, 20.0, "No Such Font Family", "Regular");
|
||||
// Type naming another version of a missing font is listed with it.
|
||||
let other = vectorcraft_doc::NodeId(text(&mut s, 40.0, "No Such Font Family", "Regular"));
|
||||
s.edit("version", |d, _| {
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut d.node_mut(other).unwrap().kind {
|
||||
t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// A style no Source Sans 3 has, bundled or installed.
|
||||
text(&mut s, 60.0, "Source Sans 3", "Black Wide");
|
||||
text(&mut s, 100.0, "Source Sans 3", "Semibold");
|
||||
let id = text(&mut s, 140.0, "Symbol Only Family", "Bold");
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("symbol.new", &json!({})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
// CSS generic families, which no font file provides, are left out.
|
||||
text(&mut s, 180.0, "sans-serif", "Regular");
|
||||
text(&mut s, 220.0, "SERIF", "Bold");
|
||||
let r = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
let rows: Vec<(&str, &str, &str)> = r["fonts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| (f["family"].as_str().unwrap(), f["style"].as_str().unwrap(), f["status"].as_str().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[("No Such Font Family", "Regular", "missing"), ("Source Sans 3", "Black Wide", "substitute"), ("Symbol Only Family", "Bold", "missing")]
|
||||
);
|
||||
assert_eq!((r["count"].as_u64(), r["fonts"][1]["resolved"]["family"].as_str()), (Some(3), Some("Source Sans 3")));
|
||||
assert!(r.get("fontsNextToDocument").is_none(), "an unsaved document has no folder");
|
||||
let listed = s.execute("text.fonts", &json!({})).unwrap();
|
||||
assert!(!listed.as_array().unwrap().iter().any(|f| f["family"] == "Symbol Only Family"), "Find Font lists the layers' type only");
|
||||
assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif" && f["status"] == "missing"), "{listed}");
|
||||
assert!(matches!(Session::new().execute("text.missingFonts", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// A font whose name is longer than 256 bytes is left out of the list, and a search for the fonts
|
||||
/// listed finds the other fonts' files.
|
||||
#[test]
|
||||
fn an_overlong_font_name_leaves_the_other_fonts_searchable() {
|
||||
let root = folder("long-name");
|
||||
std::fs::write(root.join("Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
|
||||
let mut s = session_for(&root);
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
text(&mut s, 20.0, &"L".repeat(300), "Regular");
|
||||
text(&mut s, 60.0, "Findme Sans 3", "Regular");
|
||||
let listed = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
let fonts: Vec<Value> = listed["fonts"].as_array().unwrap().iter().map(|f| json!({"family": f["family"], "style": f["style"]})).collect();
|
||||
assert_eq!(fonts, [json!({"family": "Findme Sans 3", "style": "Regular"})]);
|
||||
s.execute("text.findFontFiles", &json!({"folder": root, "fonts": fonts})).unwrap();
|
||||
assert_eq!(finished(&mut s)["fonts"][0]["files"], json!([root.join("Findme.otf")]));
|
||||
}
|
||||
|
||||
/// The Fonts folder next to a document is offered only where a search may start: not inside other
|
||||
/// apps' folders, such as the attachments a mail app keeps in the home folder's Library.
|
||||
#[test]
|
||||
fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() {
|
||||
let root = folder("next-to");
|
||||
let home = root.join("Users/me");
|
||||
let mut s = Session::new();
|
||||
s.search_rules = Some(Rules { home: Some(home.clone()), ..Rules::default() });
|
||||
for (dir, offered) in [("Library/Mail Downloads/pkg", false), ("Documents/pkg", true)] {
|
||||
std::fs::create_dir_all(home.join(dir).join("Fonts")).unwrap();
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
doc.title = "doc".into();
|
||||
s.add_document(doc, Some(home.join(dir).join("doc.vectorcraft").to_string_lossy().into_owned()));
|
||||
text(&mut s, 20.0, "No Such Font Family", "Regular");
|
||||
let r = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
assert_eq!(r.get("fontsNextToDocument").is_some(), offered, "{dir}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document opened by a relative path gives the Fonts folder next to it as an absolute path,
|
||||
/// which `text.findFontFiles` takes.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_fonts_folder_next_to_a_document_is_an_absolute_path() {
|
||||
let root = folder("relative");
|
||||
std::fs::create_dir_all(root.join("pkg/Fonts")).unwrap();
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let up = "../".repeat(cwd.components().count().saturating_sub(1));
|
||||
let relative = format!("{up}{}", root.join("pkg/doc.vectorcraft").strip_prefix("/").unwrap().display());
|
||||
assert!(Path::new(&relative).is_relative());
|
||||
let mut s = session_for(&root);
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
doc.title = "doc".into();
|
||||
s.add_document(doc, Some(relative));
|
||||
text(&mut s, 20.0, "No Such Font Family", "Regular");
|
||||
let next = s.execute("text.missingFonts", &json!({})).unwrap()["fontsNextToDocument"].as_str().unwrap().to_string();
|
||||
assert!(Path::new(&next).is_absolute(), "{next}");
|
||||
assert_eq!(std::fs::canonicalize(&next).unwrap(), root.join("pkg/Fonts"));
|
||||
assert!(s.execute("text.findFontFiles", &json!({"folder": next})).is_ok());
|
||||
assert_ne!(finished(&mut s)["state"], "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_finds_a_missing_fonts_file_by_its_names() {
|
||||
let root = folder("find");
|
||||
std::fs::create_dir_all(root.join("x/y")).unwrap();
|
||||
std::fs::write(root.join("x/y/Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
|
||||
std::fs::write(root.join("other.ttf"), bundled("SourceSerif4-Regular.ttf")).unwrap();
|
||||
std::fs::write(root.join("readme.txt"), "Findme Sans 3").unwrap();
|
||||
let mut s = session_for(&root);
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
text(&mut s, 20.0, "Findme Sans 3", "Regular");
|
||||
// Never found: the search reads the whole folder.
|
||||
text(&mut s, 60.0, "No Such Font Family", "Regular");
|
||||
let r = s.execute("text.findFontFiles", &json!({"folder": root})).unwrap();
|
||||
assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}");
|
||||
let id = r["id"].clone();
|
||||
let r = finished(&mut s);
|
||||
assert_eq!((&r["state"], &r["id"], r.get("stopped")), (&json!("done"), &id, None), "{r}");
|
||||
let found = root.join("x").join("y").join("Findme.otf").to_string_lossy().into_owned();
|
||||
let findme = r["fonts"].as_array().unwrap().iter().find(|f| f["family"] == "Findme Sans 3").cloned().unwrap();
|
||||
assert_eq!((findme["status"].as_str(), findme["files"].clone()), (Some("missing"), json!([found])));
|
||||
assert_eq!(r["searched"]["fontFiles"], 2, "the font files are read, not the text file: {r}");
|
||||
assert_eq!(r["folder"], json!(root));
|
||||
// Fonts given by name: the search ends once each has a file.
|
||||
let r = s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Findme Sans 3"}]})).unwrap();
|
||||
assert_ne!(r["id"], id, "a new search");
|
||||
let r = finished(&mut s);
|
||||
assert_eq!((&r["state"], &r["fonts"][0]["files"]), (&json!("done"), &json!([found])), "{r}");
|
||||
// Fonts that are available aren't looked for.
|
||||
let r = s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Source Sans 3"}]}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
// Stopped by a new search, or by `stop`.
|
||||
s.execute("text.findFontFiles", &json!({"folder": root})).unwrap();
|
||||
let r = s.execute("text.findFontFiles", &json!({"stop": true})).unwrap();
|
||||
assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_search_params_never_fail_internally() {
|
||||
let root = folder("junk");
|
||||
let mut s = session_for(&root);
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
text(&mut s, 20.0, "No Such Font Family", "Regular");
|
||||
let internal = |r: &crate::Result<Value>| matches!(r, Err(EngineError::Internal { .. }));
|
||||
let mut cases = vec![];
|
||||
for v in vectorcraft_testkit::strategies::junk_values() {
|
||||
cases.extend([
|
||||
json!({"folder": root, "maxSeconds": v}),
|
||||
json!({"folder": root, "fonts": v}),
|
||||
json!({"folder": root, "stop": v}),
|
||||
json!({"folder": v}),
|
||||
json!({"folder": root, "fonts": [v]}),
|
||||
json!({"folder": root, "fonts": [{"family": v}]}),
|
||||
json!({"folder": root, "fonts": [{"family": "No Such Font Family", "style": v}]}),
|
||||
]);
|
||||
assert!(s.execute("text.addFontFiles", &json!({"files": v})).is_err());
|
||||
assert!(s.execute("text.addFontFiles", &json!({"files": [v]})).is_err());
|
||||
}
|
||||
let long = "x".repeat(257);
|
||||
let many: Vec<Value> = (0..1001).map(|i| json!({"family": format!("No Such Family {i}")})).collect();
|
||||
cases.extend([
|
||||
json!({"folder": root, "fonts": [{"family": long}]}),
|
||||
json!({"folder": root, "fonts": [{"family": "x", "style": long}]}),
|
||||
json!({"folder": root, "fonts": [{"family": "a\u{0}b"}]}),
|
||||
json!({"folder": root, "fonts": many}),
|
||||
json!({"folder": root, "fonts": many[..1000]}),
|
||||
json!({"folder": "/a\u{0}b"}),
|
||||
json!({"folder": "relative/folder"}),
|
||||
json!({"folder": "/\u{FFFD}"}),
|
||||
json!({"folder": format!("/{}", "x".repeat(4096))}),
|
||||
json!({"folder": root.join("No Such Folder")}),
|
||||
]);
|
||||
let mut started = 0;
|
||||
for p in &cases {
|
||||
let r = s.execute("text.findFontFiles", p);
|
||||
assert!(!internal(&r), "{p}: {r:?}");
|
||||
if r.is_ok() && p.get("folder").is_some_and(|f| f.is_string()) {
|
||||
started += 1;
|
||||
assert_ne!(finished(&mut s)["state"], "searching", "{p}");
|
||||
}
|
||||
}
|
||||
assert!(started > 40, "only {started} searches started");
|
||||
// After a finished search, files it didn't find aren't copied.
|
||||
for v in [json!(["/no/such.ttf"]), json!([root.join("readme.ttf")])] {
|
||||
assert!(s.execute("text.addFontFiles", &json!({"files": v})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads every file, and waits on reading one until its channel sends or closes.
|
||||
struct Blocked(Mutex<mpsc::Receiver<()>>);
|
||||
|
||||
impl Visitor for Blocked {
|
||||
fn wants(&self, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
fn items(&self, _: &Path) -> Vec<usize> {
|
||||
let _ = self.0.lock().unwrap().recv();
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ends_only_the_search_it_names() {
|
||||
let root = folder("stop");
|
||||
std::fs::write(root.join("a.ttf"), b"font").unwrap();
|
||||
let mut s = session_for(&root);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
|
||||
let fonts = vec![Sought { family: "A".into(), style: "Regular".into(), status: FontMatch::Missing }];
|
||||
s.font_search = Some(FontSearch { id: 7, folder: root.to_string_lossy().into_owned(), fonts, search });
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while s.execute("text.findFontFiles", &json!({})).unwrap()["searched"]["fontFiles"] != 1 && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
fontfiles::stop(&mut s, 8);
|
||||
assert_eq!(s.execute("text.findFontFiles", &json!({})).unwrap()["state"], "searching");
|
||||
fontfiles::stop(&mut s, 7);
|
||||
let r = s.execute("text.findFontFiles", &json!({})).unwrap();
|
||||
assert_eq!((r["state"].as_str(), r["stopped"].as_str(), r["id"].as_u64()), (Some("stopped"), Some("stop"), Some(7)));
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_fonts_needs_vectorcraft_s_fonts_folder() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("text.addFontFiles", &json!({"files": ["/fonts/a.ttf"]}));
|
||||
assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("isn't set here")), "{r:?}");
|
||||
// As many files as a search keeps (one for each of 1000 fonts) can be added at once.
|
||||
let files = |n: usize| json!({"files": (0..n).map(|i| format!("/fonts/{i}.ttf")).collect::<Vec<_>>()});
|
||||
let r = s.execute("text.addFontFiles", &files(1000));
|
||||
assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("isn't set here")), "{r:?}");
|
||||
assert!(matches!(s.execute("text.addFontFiles", &files(1001)), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_never_replaces_a_file() {
|
||||
let root = folder("copy");
|
||||
let (src, dest) = (root.join("src"), root.join("dest"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(src.join("a.otf"), b"new font").unwrap();
|
||||
std::fs::write(dest.join("a.otf"), b"keep").unwrap();
|
||||
let r = copy_into(&[src.join("a.otf")], &dest);
|
||||
assert_eq!(r.copied, [(src.join("a.otf"), dest.join("a 2.otf"))]);
|
||||
assert_eq!((std::fs::read(dest.join("a.otf")).unwrap(), std::fs::read(dest.join("a 2.otf")).unwrap()), (b"keep".to_vec(), b"new font".to_vec()));
|
||||
// The same file again: kept, not copied a third time.
|
||||
let r = copy_into(&[src.join("a.otf")], &dest);
|
||||
assert_eq!((r.copied.len(), r.kept.clone()), (0, vec![(src.join("a.otf"), dest.join("a 2.otf"))]));
|
||||
assert!(!dest.join("a 3.otf").exists());
|
||||
// A link of that name, even one to nothing, is neither replaced nor written through.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::write(src.join("b.otf"), b"b").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("nothing.otf"), dest.join("b.otf")).unwrap();
|
||||
let r = copy_into(&[src.join("b.otf")], &dest);
|
||||
assert_eq!(r.copied, [(src.join("b.otf"), dest.join("b 2.otf"))]);
|
||||
assert!(!root.join("nothing.otf").exists());
|
||||
}
|
||||
// A name taken in another case: on a file system that ignores case, the copy gets a number.
|
||||
std::fs::write(dest.join("C.OTF"), b"other").unwrap();
|
||||
std::fs::write(src.join("c.otf"), b"c").unwrap();
|
||||
let ignores_case = dest.join("c.otf").exists();
|
||||
let r = copy_into(&[src.join("c.otf")], &dest);
|
||||
let to = dest.join(if ignores_case { "c 2.otf" } else { "c.otf" });
|
||||
assert_eq!((r.copied, std::fs::read(dest.join("C.OTF")).unwrap()), (vec![(src.join("c.otf"), to)], b"other".to_vec()));
|
||||
// Every numbered name taken.
|
||||
std::fs::write(src.join("d.otf"), b"d").unwrap();
|
||||
std::fs::write(dest.join("d.otf"), b"other").unwrap();
|
||||
for n in 2..=99 {
|
||||
std::fs::write(dest.join(format!("d {n}.otf")), b"other").unwrap();
|
||||
}
|
||||
let r = copy_into(&[src.join("d.otf")], &dest);
|
||||
assert_eq!(r.skipped, [(src.join("d.otf"), "every numbered name for it is taken in the Fonts folder".to_string())]);
|
||||
// Too large (a sparse file), a folder, and a file that isn't there.
|
||||
let huge = std::fs::File::create(src.join("huge.otf")).unwrap();
|
||||
huge.set_len(MAX_FONT_FILE + 1).unwrap();
|
||||
std::fs::create_dir_all(src.join("folder.otf")).unwrap();
|
||||
let r = copy_into(&[src.join("huge.otf"), src.join("folder.otf"), src.join("none.otf")], &dest);
|
||||
let reasons: Vec<&str> = r.skipped.iter().map(|(_, why)| why.as_str()).collect();
|
||||
assert_eq!(reasons[..2], ["larger than 256 MB", "not a file"]);
|
||||
assert!(r.copied.is_empty() && reasons.len() == 3, "{r:?}");
|
||||
assert!(!dest.join("huge.otf").exists() && !dest.join("folder.otf").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_font_files_are_copied() {
|
||||
let root = folder("allowed");
|
||||
let (src, dest) = (root.join("src"), root.join("dest"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
let names = ["notes.txt", "Script", "web.WOFF2", "font.woff"];
|
||||
for name in names {
|
||||
std::fs::write(src.join(name), name).unwrap();
|
||||
}
|
||||
let r = copy_into(&names.map(|n| src.join(n)), &dest);
|
||||
let copied: Vec<PathBuf> = r.copied.iter().map(|(from, _)| from.clone()).collect();
|
||||
assert_eq!(copied, [src.join("web.WOFF2"), src.join("font.woff")]);
|
||||
let not_a_font = |n: &str| (src.join(n), "not a font file".to_string());
|
||||
assert_eq!(r.skipped, [not_a_font("notes.txt"), not_a_font("Script")]);
|
||||
assert!(!dest.join("notes.txt").exists() && !dest.join("Script").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font.
|
||||
#[test]
|
||||
fn a_search_skips_font_files_too_large_to_add() {
|
||||
let root = folder("too-large");
|
||||
let (big, small) = (root.join("big").join("Sizeme.otf"), root.join("small").join("Sizeme.otf"));
|
||||
let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3");
|
||||
for path in [&big, &small] {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, &font).unwrap();
|
||||
}
|
||||
// One byte past the limit, without writing it: the file system leaves the rest unallocated.
|
||||
std::fs::File::options().write(true).open(&big).unwrap().set_len(MAX_FONT_FILE + 1).unwrap();
|
||||
let mut s = session_for(&root);
|
||||
// A font no file has keeps the search going through every folder.
|
||||
s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
|
||||
let r = finished(&mut s);
|
||||
assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its
|
||||
/// resource fork, which only macOS keeps. A search finds it, and Add Fonts copies it with its fork.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn suitcase_fonts_are_found_and_copied_with_their_fork() {
|
||||
use vectorcraft_testkit::fonts::{renamed, suitcase_fork};
|
||||
let root = folder("suitcase");
|
||||
let (src, dest) = (root.join("src"), root.join("dest"));
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
let suitcase = |path: &Path, family: &str| {
|
||||
let fork = suitcase_fork(&[renamed(family)]);
|
||||
std::fs::write(path, b"").unwrap();
|
||||
std::fs::write(path.join("..namedfork/rsrc"), &fork).unwrap();
|
||||
fork
|
||||
};
|
||||
let found = src.join("sub/Suitme");
|
||||
let fork = suitcase(&found, "Suitme Sans 3");
|
||||
// A file without an extension that isn't a suitcase is checked and left out.
|
||||
std::fs::write(src.join("README"), b"text").unwrap();
|
||||
let mut s = session_for(&root);
|
||||
s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suitme Sans 3"}]})).unwrap();
|
||||
let r = finished(&mut s);
|
||||
assert_eq!(r["fonts"][0]["files"], json!([found]), "{r}");
|
||||
// A suitcase with the `.suit` extension is found too, and a `.suit` file that isn't one is left out.
|
||||
let suit = src.join("Suited.SUIT");
|
||||
suitcase(&suit, "Suited Sans 3");
|
||||
std::fs::write(src.join("Plain.suit"), b"text").unwrap();
|
||||
// A font no file has keeps the search going through every folder.
|
||||
s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suited Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
|
||||
let r = finished(&mut s);
|
||||
assert_eq!((r["fonts"][0]["files"].clone(), r["searched"]["fontFiles"].as_u64()), (json!([suit]), Some(2)), "{r}");
|
||||
let r = copy_into(std::slice::from_ref(&found), &dest);
|
||||
assert_eq!(r.copied, [(found.clone(), dest.join("Suitme"))]);
|
||||
assert!(vectorcraft_text::is_suitcase(&dest.join("Suitme")));
|
||||
assert_eq!(std::fs::read(dest.join("Suitme/..namedfork/rsrc")).unwrap(), fork);
|
||||
// The same suitcase again is kept; another of that name gets a number.
|
||||
assert_eq!(copy_into(std::slice::from_ref(&found), &dest).kept, [(found.clone(), dest.join("Suitme"))]);
|
||||
let other = src.join("Suitme");
|
||||
suitcase(&other, "Other Sans 33");
|
||||
assert_eq!(copy_into(std::slice::from_ref(&other), &dest).copied, [(other.clone(), dest.join("Suitme 2"))]);
|
||||
// A suitcase named like a font file keeps its fork too.
|
||||
let named = src.join("Suitext.ttf");
|
||||
let named_fork = suitcase(&named, "Suitext Sans3");
|
||||
assert_eq!(copy_into(std::slice::from_ref(&named), &dest).copied, [(named.clone(), dest.join("Suitext.ttf"))]);
|
||||
assert_eq!(std::fs::read(dest.join("Suitext.ttf/..namedfork/rsrc")).unwrap(), named_fork);
|
||||
// The font scan reads the copies.
|
||||
let db = vectorcraft_text::FontDb::with_font_dirs(vec![dest.clone()]);
|
||||
assert_eq!((db.styles("Suitme Sans 3"), db.styles("Other Sans 33")), (vec!["Regular".to_string()], vec!["Regular".to_string()]));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The Libraries panel's libraries (#745): making, renaming and deleting them, adding graphics,
|
||||
//! colours and text styles from the selection, using them in another document, and keeping them
|
||||
//! in files across sessions.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
|
||||
}
|
||||
|
||||
fn hex_of(s: &Session, id: &Value, stroke: bool) -> String {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let paint = if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() };
|
||||
paint.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_are_made_renamed_and_deleted() {
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"], json!([]));
|
||||
let id = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
let other = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
assert_ne!(id, other, "two libraries of one name are two files");
|
||||
run(&mut s, "library.rename", json!({"library": other, "name": "Client"}));
|
||||
let list = run(&mut s, "library.list", json!({}));
|
||||
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Brand", "Client"]);
|
||||
assert!(list["folder"].is_null(), "no folder: libraries last for the session");
|
||||
run(&mut s, "library.delete", json!({"library": "client"}));
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "shape.ellipse", json!({"x": 10, "y": 10, "width": 40, "height": 30}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [id]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
// With no library yet, adding makes "My Library".
|
||||
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
|
||||
assert_eq!(added["existing"], false);
|
||||
let lib = added["library"].as_str().unwrap().to_string();
|
||||
let item = added["id"].as_str().unwrap().to_string();
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
assert_eq!(got["name"], "My Library");
|
||||
let g = &got["graphics"][0];
|
||||
// Its size as it draws: the 1 pt stroke included.
|
||||
assert!((g["width"].as_f64().unwrap() - 41.0).abs() < 1e-6 && (g["height"].as_f64().unwrap() - 31.0).abs() < 1e-6, "{g}");
|
||||
assert!(!g["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the art");
|
||||
// The same art again is the item already there.
|
||||
assert_eq!(run(&mut s, "library.add", json!({"kind": "graphic"}))["existing"], true);
|
||||
// Placed in another document, centred where asked, selected, one undo step.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let placed = run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}));
|
||||
let new_id = placed["ids"][0].clone();
|
||||
let st = s.doc().unwrap();
|
||||
let b = st.doc.node(NodeId(new_id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 150.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6, "{b:?}");
|
||||
assert_eq!(st.selection.objects, vec![NodeId(new_id.as_u64().unwrap())]);
|
||||
assert_eq!(hex_of(&s, &new_id, false), "#cc3300");
|
||||
assert_eq!(objects(&s), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(objects(&s), 0);
|
||||
// A graphic is removed by id.
|
||||
run(&mut s, "library.removeItem", json!({"library": lib, "kind": "graphic", "item": item}));
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "graphic", "item": item})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_come_from_the_selection_and_paint_it() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [a]}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#abcdef", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "#123456");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}))["name"], "#abcdef");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], true);
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456"}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456", "to": "stroke"}));
|
||||
assert_eq!(hex_of(&s, &b, false), "#123456");
|
||||
assert_eq!(hex_of(&s, &b, true), "#123456");
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "charStyle"})).is_err(), "no type selected");
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let name = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
assert!(name.ends_with("31 pt"), "{name}");
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}));
|
||||
// Another document: the style is added and applied, and one undo takes both back.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [u]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], name);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let size = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(u.as_u64().unwrap())).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert_eq!(size(&s), 31.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(size(&s), 9.0);
|
||||
assert!(s.doc().unwrap().doc.char_styles.iter().all(|c| c.name != name), "the style went with the undo");
|
||||
// A style of that name with other attributes in the document: the library's comes in numbered.
|
||||
run(&mut s, "charStyle.new", json!({"name": name, "attrs": {"size": 5}}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], format!("{name} 2"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_kept_in_files_across_sessions() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-libraries-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
s.libraries.set_dir(Some(folder.clone()));
|
||||
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "graphic", "name": "Square"}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
|
||||
let mut t = session();
|
||||
t.libraries.set_dir(Some(folder.clone()));
|
||||
let got = run(&mut t, "library.get", json!({"library": "Kept"}));
|
||||
assert_eq!(got["graphics"][0]["name"], "Square");
|
||||
assert_eq!(got["colors"].as_array().unwrap().len(), 1);
|
||||
run(&mut t, "library.delete", json!({"library": "Kept"}));
|
||||
let mut u = session();
|
||||
u.libraries.set_dir(Some(folder));
|
||||
assert_eq!(run(&mut u, "library.list", json!({}))["libraries"], json!([]), "deleting removes the file");
|
||||
// A damaged file is skipped.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Bad.vclibrary"), b"{not json").unwrap();
|
||||
let mut v = session();
|
||||
v.libraries.set_dir(Some(dir.to_string_lossy().to_string()));
|
||||
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -279,3 +279,31 @@ fn a_pen_path_rounds_its_corners_but_not_its_ends() {
|
||||
run(&mut s, json!({"id": e.0, "radius": 8}));
|
||||
assert_eq!(node_path(&s, e), before);
|
||||
}
|
||||
|
||||
/// #812: an ellipse's Pie Start and End Angle cut it as a pie; Invert Pie shows the other part;
|
||||
/// one undo step each; a bad angle is an error.
|
||||
#[test]
|
||||
fn an_ellipse_takes_pie_angles_and_inverts() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
|
||||
let (p, b) = pie(&s);
|
||||
assert_eq!(p, (0.0, 90.0));
|
||||
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "the quarter up and right of the centre: {b:?}");
|
||||
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
|
||||
let (p, b) = pie(&s);
|
||||
assert_eq!(p, (90.0, 360.0));
|
||||
assert!(b.x0.abs() < 1e-6 && (b.y1 - 100.0).abs() < 1e-6, "the other three quarters: {b:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pie(&s).0, (0.0, 90.0));
|
||||
// 0 to 360 (or an end of 0) is the whole ellipse again.
|
||||
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 0})).unwrap();
|
||||
assert_eq!(pie(&s).0, (0.0, 360.0));
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
|
||||
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
|
||||
}
|
||||
@@ -157,6 +157,36 @@ fn a_missing_link_is_reported_not_copied() {
|
||||
assert!(report.contains("blue.png: not found"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_named_by_another_of_its_names_is_copied() {
|
||||
let dir = Folder::new("package-alias");
|
||||
let mut s = poster(&dir);
|
||||
// The bundled Semibold by its PostScript name, as an SVG or a PDF can name it.
|
||||
run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Alias", "font": "SourceSans3-Semibold"}));
|
||||
let r = run(&mut s, "file.package", json!({}));
|
||||
assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), json!([])), "{}", r["skippedFonts"]);
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap();
|
||||
assert!(report.contains("SourceSans3-Semibold Regular → Fonts/"), "{report}");
|
||||
let details = report.lines().find(|l| l.starts_with("SourceSans3-Semibold Regular: ")).unwrap_or_default();
|
||||
assert!(details.contains("embedding allowed") && !details.contains("missing"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_substituted_style_names_the_style_shown() {
|
||||
let dir = Folder::new("package-style");
|
||||
let mut s = poster(&dir);
|
||||
// No Source Sans 3 has a Black Wide, bundled or installed: its closest style stands in, and
|
||||
// that style's file is copied.
|
||||
run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"}));
|
||||
let r = run(&mut s, "file.package", json!({}));
|
||||
assert_eq!(r["skippedFonts"], json!([]));
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap();
|
||||
assert!(report.contains("Source Sans 3 Black Wide (shown in Source Sans 3 "), "{report}");
|
||||
assert!(report.contains("Source Sans 3 Black Wide: substituted: shown in Source Sans 3 "), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_info_report_has_every_category() {
|
||||
let dir = Folder::new("docinfo-report");
|
||||
@@ -193,3 +223,311 @@ fn the_document_info_report_has_every_category() {
|
||||
let face = vectorcraft_text::FontDb::global().face("Source Sans 3", "Regular").unwrap();
|
||||
assert!(face.embeddable() && face.path().is_none());
|
||||
}
|
||||
|
||||
/// A professional handoff: the poster links to a reusable illustration, whose
|
||||
/// own artwork links to a raster asset. Both dependency levels must travel.
|
||||
fn poster_with_placed_document(dir: &Folder) -> Session {
|
||||
let image = dir.file("art/photo.png");
|
||||
write(&image, &png(600, 300, RED));
|
||||
let mut part = session();
|
||||
place(&mut part, &image);
|
||||
let part_path = dir.file("art/part.vectorcraft");
|
||||
save(&mut part, &part_path);
|
||||
|
||||
let mut poster = session();
|
||||
let placed = run(&mut poster, "file.place", json!({ "path": part_path, "link": true }));
|
||||
assert_eq!(placed["linked"], true, "{placed}");
|
||||
save(&mut poster, &dir.file("poster.vectorcraft"));
|
||||
poster
|
||||
}
|
||||
|
||||
/// Every link must point to a packaged copy, including links inside a linked
|
||||
/// VectorCraft file. Validate after original assets have been removed.
|
||||
fn verify_portable_nested_package(folder: &std::path::Path) {
|
||||
let main = folder.join("poster.vectorcraft");
|
||||
let part = folder.join("Links/part.vectorcraft");
|
||||
let image = folder.join("Links/photo.png");
|
||||
assert!(main.is_file() && part.is_file() && image.is_file());
|
||||
|
||||
let mut parent = session();
|
||||
let opened = open(&mut parent, &main.to_string_lossy());
|
||||
assert_eq!(opened["missingLinks"], json!([]), "main package has broken links: {opened}");
|
||||
let mut targets = Vec::new();
|
||||
parent.doc().unwrap().doc.visit_placed(|_, p| targets.push(p.link.clone()));
|
||||
assert_eq!(targets.len(), 1);
|
||||
let link = &targets[0];
|
||||
assert_eq!(link.relative.as_deref(), Some("Links/part.vectorcraft"));
|
||||
let packaged_part = std::fs::read(&part).unwrap();
|
||||
let expected_hash = vectorcraft_doc::links::hash_bytes(&packaged_part);
|
||||
assert_eq!(link.hash.as_deref(), Some(expected_hash.as_str()));
|
||||
let mut nested = session();
|
||||
let opened = open(&mut nested, &part.to_string_lossy());
|
||||
assert_eq!(opened["missingLinks"], json!([]), "nested document has broken links: {opened}");
|
||||
let mut img_links = Vec::new();
|
||||
nested.doc().unwrap().doc.visit_images(|_, im| img_links.extend(im.link.clone()));
|
||||
assert_eq!(img_links.len(), 1);
|
||||
assert_eq!(img_links[0].relative.as_deref(), Some("photo.png"));
|
||||
assert_eq!(std::fs::read(&image).unwrap(), png(600, 300, RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_recursively_relinks_placed_documents_and_their_images() {
|
||||
let dir = Folder::new("package-placed");
|
||||
let mut s = poster_with_placed_document(&dir);
|
||||
let out = dir.file("delivery");
|
||||
let result = run(&mut s, "file.package", json!({ "folder": out }));
|
||||
assert_eq!((result["links"].as_u64(), result["missingLinks"].clone()), (Some(2), json!([])), "{result}");
|
||||
let folder = dir.0.join("delivery/poster Folder");
|
||||
// The package must work on a second computer without the source tree.
|
||||
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
|
||||
verify_portable_nested_package(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zipped_package_recursively_relinks_without_absolute_creator_paths() {
|
||||
let dir = Folder::new("package-placed-zip");
|
||||
let mut s = poster_with_placed_document(&dir);
|
||||
let result = run(&mut s, "file.package", json!({}));
|
||||
assert_eq!(result["links"], 2, "{result}");
|
||||
let bytes = vectorcraft_format::base64_decode(result["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let archive = unzip(&bytes);
|
||||
let extraction = dir.file("extracted");
|
||||
for (name, bytes) in archive {
|
||||
write(&format!("{extraction}/{name}"), &bytes);
|
||||
}
|
||||
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
|
||||
verify_portable_nested_package(&dir.0.join("extracted/poster Folder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_keeps_distinct_linked_documents_with_colliding_file_names() {
|
||||
let dir = Folder::new("package-collisions");
|
||||
let mut poster = session();
|
||||
for (number, color) in [(1, RED), (2, BLUE)] {
|
||||
let image = dir.file(&format!("art{number}/picture.png"));
|
||||
write(&image, &png(300, 300, color));
|
||||
let mut part = session();
|
||||
place(&mut part, &image);
|
||||
let name = dir.file(&format!("art{number}/part.vectorcraft"));
|
||||
save(&mut part, &name);
|
||||
run(&mut poster, "file.place", json!({ "path": name, "link": true }));
|
||||
}
|
||||
save(&mut poster, &dir.file("poster.vectorcraft"));
|
||||
let result = run(&mut poster, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["links"], 4, "{result}");
|
||||
assert_eq!(result["missingLinks"], json!([]));
|
||||
let folder = dir.0.join("delivery/poster Folder");
|
||||
let packaged: Vec<_> =
|
||||
result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".vectorcraft")).collect();
|
||||
assert_eq!(packaged.len(), 3, "both nested documents must be retained: {packaged:?}");
|
||||
std::fs::remove_dir_all(dir.0.join("art1")).unwrap();
|
||||
std::fs::remove_dir_all(dir.0.join("art2")).unwrap();
|
||||
let mut check = session();
|
||||
assert_eq!(open(&mut check, &folder.join("poster.vectorcraft").to_string_lossy())["missingLinks"], json!([]));
|
||||
for name in packaged.into_iter().filter(|n| n.starts_with("Links/")) {
|
||||
let mut inner = session();
|
||||
let result = open(&mut inner, &folder.join(name).to_string_lossy());
|
||||
assert_eq!(result["missingLinks"], json!([]), "{name}: {result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_finds_nested_assets_after_the_source_tree_moves() {
|
||||
let dir = Folder::new("package-moved-nested");
|
||||
let _original = poster_with_placed_document(&dir);
|
||||
let moved = dir.0.join("moved");
|
||||
std::fs::create_dir_all(&moved).unwrap();
|
||||
std::fs::rename(dir.0.join("art"), moved.join("art")).unwrap();
|
||||
std::fs::rename(dir.0.join("poster.vectorcraft"), moved.join("poster.vectorcraft")).unwrap();
|
||||
// The file paths embedded in both documents still point to the old location.
|
||||
// Opening the parent repairs its direct link, but the child must resolve
|
||||
// the image from the folder where it was actually found.
|
||||
let mut opened = session();
|
||||
open(&mut opened, &moved.join("poster.vectorcraft").to_string_lossy());
|
||||
let result = run(&mut opened, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["missingLinks"], json!([]), "moved file's nested link wasn't found: {result}");
|
||||
std::fs::remove_dir_all(moved.join("art")).unwrap();
|
||||
verify_portable_nested_package(&dir.0.join("delivery/poster Folder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_stale_paths_in_different_nested_documents_keep_separate_assets() {
|
||||
let dir = Folder::new("package-stale-paths");
|
||||
let mut poster = session();
|
||||
for (number, color) in [(1, RED), (2, BLUE)] {
|
||||
let image = dir.file(&format!("art{number}/photo.png"));
|
||||
write(&image, &png(300, 300, color));
|
||||
let mut part = session();
|
||||
place(&mut part, &image);
|
||||
let name = dir.file(&format!("art{number}/part.vectorcraft"));
|
||||
save(&mut part, &name);
|
||||
|
||||
// Two otherwise independent linked documents have the *same* obsolete
|
||||
// absolute image path. Their relative paths identify different files.
|
||||
let mut saved = vectorcraft_format::load(&std::fs::read(&name).unwrap()).unwrap();
|
||||
saved.update_links(|link| {
|
||||
link.path = "/old-computer/illustrations/photo.png".into();
|
||||
link.relative = Some("photo.png".into());
|
||||
});
|
||||
write(&name, &vectorcraft_format::save_file(&saved));
|
||||
run(&mut poster, "file.place", json!({ "path": name, "link": true }));
|
||||
}
|
||||
save(&mut poster, &dir.file("poster.vectorcraft"));
|
||||
let result = run(&mut poster, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["links"], 4, "both images and both documents must be copied: {result}");
|
||||
assert_eq!(result["missingLinks"], json!([]), "{result}");
|
||||
let folder = dir.0.join("delivery/poster Folder");
|
||||
std::fs::remove_dir_all(dir.0.join("art1")).unwrap();
|
||||
std::fs::remove_dir_all(dir.0.join("art2")).unwrap();
|
||||
|
||||
let mut actual = Vec::new();
|
||||
for filename in result["files"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|name| name.ends_with(".png")) {
|
||||
actual.push(std::fs::read(folder.join(filename)).unwrap());
|
||||
}
|
||||
assert_eq!(actual.len(), 2, "stale source paths must not cause asset deduplication");
|
||||
assert_ne!(actual[0], actual[1], "different original illustrations must keep different pixels");
|
||||
for filename in result["files"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|name| name.starts_with("Links/") && name.ends_with(".vectorcraft"))
|
||||
{
|
||||
let mut nested = session();
|
||||
let opened = open(&mut nested, &folder.join(filename).to_string_lossy());
|
||||
assert_eq!(opened["missingLinks"], json!([]), "{filename}: {opened}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaging_nested_files_preserves_embedded_preview_pdf_and_compression() {
|
||||
let dir = Folder::new("package-native-extras");
|
||||
let mut parent = poster_with_placed_document(&dir);
|
||||
let source = dir.file("art/part.vectorcraft");
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let doc = vectorcraft_format::load(&original).unwrap();
|
||||
let preview = png(8, 8, BLUE);
|
||||
let pdf = b"%PDF-1.7\n%Embedded client preview\n".to_vec();
|
||||
let mut opts = vectorcraft_format::SaveOptions::for_doc(&doc);
|
||||
opts.preview = Some(preview.clone());
|
||||
opts.pdf = Some(pdf.clone());
|
||||
opts.compress = true;
|
||||
write(&source, &vectorcraft_format::save_with(&doc, &opts).unwrap());
|
||||
|
||||
let result = run(&mut parent, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["missingLinks"], json!([]), "{result}");
|
||||
let packaged = std::fs::read(dir.0.join("delivery/poster Folder/Links/part.vectorcraft")).unwrap();
|
||||
assert!(packaged.starts_with(&[0x1f, 0x8b]), "compression must be preserved");
|
||||
assert_eq!(vectorcraft_format::preview(&packaged), Some(preview));
|
||||
assert_eq!(vectorcraft_format::pdf_content(&packaged), Some(pdf));
|
||||
let nested = vectorcraft_format::load(&packaged).unwrap();
|
||||
let mut images = Vec::new();
|
||||
nested.visit_images(|_, image| images.push(image.link.clone()));
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].as_ref().unwrap().relative.as_deref(), Some("photo.png"));
|
||||
}
|
||||
|
||||
/// The report keeps the packaged document's sections as they were, then gives each placed
|
||||
/// document's own, headed by its file, instead of nesting them inside the parent's list.
|
||||
#[test]
|
||||
fn the_report_lists_a_placed_documents_links_in_a_section_of_its_own() {
|
||||
let dir = Folder::new("package-report-sections");
|
||||
let mut parent = poster_with_placed_document(&dir);
|
||||
run(&mut parent, "file.package", json!({ "folder": dir.file("delivery"), "copyFonts": false }));
|
||||
let report = std::fs::read_to_string(dir.0.join("delivery/poster Folder/poster Report.txt")).unwrap();
|
||||
let lines: Vec<&str> = report.lines().collect();
|
||||
let sections: Vec<usize> = lines.iter().enumerate().filter(|(_, l)| **l == "LINKED FILES").map(|(i, _)| i).collect();
|
||||
let [poster, part] = sections[..] else { panic!("two link sections: {report}") };
|
||||
assert!(lines[poster + 1].ends_with("→ Links/part.vectorcraft"), "the packaged document's section as before: {report}");
|
||||
assert!(lines[part + 1].starts_with("Document: ") && lines[part + 1].ends_with("part.vectorcraft"), "{report}");
|
||||
assert!(lines[part + 2].ends_with("→ Links/photo.png"), "{report}");
|
||||
}
|
||||
|
||||
/// A damaged or newer-format linked native file is still an asset worth delivering.
|
||||
/// Package must preserve the exact bytes instead of failing the entire handoff.
|
||||
#[test]
|
||||
fn unreadable_placed_document_is_copied_unchanged_with_warning() {
|
||||
let dir = Folder::new("package-unreadable-placed");
|
||||
let mut parent = poster_with_placed_document(&dir);
|
||||
let damaged = dir.file("art/part.vectorcraft");
|
||||
let bytes = b"not a parseable VectorCraft file".to_vec();
|
||||
write(&damaged, &bytes);
|
||||
|
||||
let result = run(&mut parent, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["links"], 1, "{result}");
|
||||
assert_eq!(result["missingLinks"], json!([]), "{result}");
|
||||
let warnings = result["warnings"].as_array().unwrap();
|
||||
assert_eq!(warnings.len(), 1, "{result}");
|
||||
assert!(warnings[0].as_str().unwrap().contains("unreadable linked document"), "{result}");
|
||||
|
||||
let packaged = dir.0.join("delivery/poster Folder");
|
||||
assert_eq!(std::fs::read(packaged.join("Links/part.vectorcraft")).unwrap(), bytes);
|
||||
let report = std::fs::read_to_string(packaged.join("poster Report.txt")).unwrap();
|
||||
assert!(report.lines().any(|line| line == "LINKED FILES"), "{report}");
|
||||
assert!(report.contains("copied unchanged") && report.contains("its own links were not collected"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_placed_links_copy_each_document_once_and_warn() {
|
||||
let dir = Folder::new("package-placed-cycle");
|
||||
let source = dir.file("root.vectorcraft");
|
||||
let child_path = dir.file("child.vectorcraft");
|
||||
|
||||
let mut root = session();
|
||||
save(&mut root, &source);
|
||||
let mut child = session();
|
||||
run(&mut child, "file.place", json!({ "path": source, "link": true }));
|
||||
save(&mut child, &child_path);
|
||||
run(&mut root, "file.place", json!({ "path": child_path, "link": true }));
|
||||
save(&mut root, &source);
|
||||
|
||||
let result = run(&mut root, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
assert_eq!(result["links"], 1, "the root must not be copied a second time: {result}");
|
||||
assert_eq!(result["missingLinks"], json!([]), "{result}");
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("circular placed-document link")), "{result}");
|
||||
let packaged = dir.0.join("delivery/root Folder");
|
||||
let root_doc = vectorcraft_format::load(&std::fs::read(packaged.join("root.vectorcraft")).unwrap()).unwrap();
|
||||
let child_doc = vectorcraft_format::load(&std::fs::read(packaged.join("Links/child.vectorcraft")).unwrap()).unwrap();
|
||||
let mut direct = Vec::new();
|
||||
root_doc.visit_placed(|_, p| direct.push(p.link.relative.clone()));
|
||||
assert_eq!(direct, [Some("Links/child.vectorcraft".into())]);
|
||||
let mut back = Vec::new();
|
||||
child_doc.visit_placed(|_, p| {
|
||||
back.push((p.link.relative.clone(), p.link.hash.clone(), p.link.size));
|
||||
});
|
||||
assert_eq!(back, [(Some("../root.vectorcraft".into()), None, None)]);
|
||||
let report = std::fs::read_to_string(packaged.join("root Report.txt")).unwrap();
|
||||
assert!(report.lines().any(|line| line == "LINKED FILES"), "{report}");
|
||||
assert!(report.contains("stopped following the cycle"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_limit_copies_boundary_file_and_reports_uncollected_links() {
|
||||
let dir = Folder::new("package-placed-depth-limit");
|
||||
let mut deepest = session();
|
||||
let mut next = dir.file("leaf.vectorcraft");
|
||||
save(&mut deepest, &next);
|
||||
|
||||
let mut root = None;
|
||||
for depth in (0..=vectorcraft_doc::placed_document::MAX_DEPTH).rev() {
|
||||
let mut current = session();
|
||||
run(&mut current, "file.place", json!({ "path": next, "link": true }));
|
||||
let name = dir.file(&format!("level{depth}.vectorcraft"));
|
||||
save(&mut current, &name);
|
||||
next = name;
|
||||
if depth == 0 {
|
||||
root = Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
let mut root = root.unwrap();
|
||||
let result = run(&mut root, "file.package", json!({ "folder": dir.file("delivery") }));
|
||||
let expected_links = vectorcraft_doc::placed_document::MAX_DEPTH + 1;
|
||||
assert_eq!(result["links"].as_u64(), Some(expected_links as u64), "{result}");
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("nesting limit reached")), "{result}");
|
||||
let folder = dir.0.join("delivery/level0 Folder");
|
||||
let packaged = std::fs::read(folder.join("Links/leaf.vectorcraft")).unwrap();
|
||||
assert_eq!(packaged, std::fs::read(dir.file("leaf.vectorcraft")).unwrap());
|
||||
let report = std::fs::read_to_string(folder.join("level0 Report.txt")).unwrap();
|
||||
assert!(report.contains("nesting limit reached"), "{report}");
|
||||
}
|
||||
@@ -670,3 +670,27 @@ world", "size": 20}),
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") };
|
||||
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
|
||||
}
|
||||
|
||||
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
|
||||
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
|
||||
#[test]
|
||||
fn hyphenation_exceptions_preference_reaches_the_hyphenator() {
|
||||
struct Clear;
|
||||
impl Drop for Clear {
|
||||
fn drop(&mut self) {
|
||||
vectorcraft_text::set_hyphenation_exceptions("");
|
||||
}
|
||||
}
|
||||
let _clear = Clear;
|
||||
let mut s = Session::new();
|
||||
set_pref(&mut s, "hyphenationExceptions", json!(""));
|
||||
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let before = s.doc().unwrap().revision;
|
||||
set_pref(&mut s, "hyphenationExceptions", json!("typography, hap-pen"));
|
||||
assert_eq!(s.prefs.hyphenation_exceptions, "typography, hap-pen");
|
||||
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
|
||||
assert!(s.doc().unwrap().revision > before, "type reflows");
|
||||
set_pref(&mut s, "hyphenationExceptions", json!(""));
|
||||
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
|
||||
}
|
||||
@@ -192,8 +192,15 @@ fn user_libraries_and_other_documents_styles() {
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64)));
|
||||
assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref()));
|
||||
// Opening a file of the user folder gives its User Defined library.
|
||||
// Opening a file of the user folder gives its User Defined library, and so does a file with the
|
||||
// same bytes in another folder.
|
||||
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles");
|
||||
let elsewhere = dir.with_file_name(format!("vc-stylelib-elsewhere-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
let copy = elsewhere.join("Looks.vcstyles");
|
||||
std::fs::copy(&path, ©).unwrap();
|
||||
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": copy.to_string_lossy() }))["library"], "user/My- Looks.vcstyles");
|
||||
let _ = std::fs::remove_dir_all(elsewhere);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
// Another document's graphic styles load as a library.
|
||||
|
||||
@@ -182,6 +182,10 @@ fn libraries_saved_in_the_user_folder_are_user_defined() {
|
||||
let css = dir.join("out.css").to_string_lossy().to_string();
|
||||
run(&mut s, "swatch.library.save", json!({ "path": css }));
|
||||
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
|
||||
// A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it.
|
||||
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."}));
|
||||
assert_eq!(r["library"], "user/Library.vcswatches");
|
||||
assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -235,6 +239,139 @@ fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_exchange_files_save_with_models_kinds_and_groups() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}}));
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
|
||||
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"}));
|
||||
assert_eq!(r["format"], "ase");
|
||||
assert!(r.get("data").is_none(), "a binary file comes back as dataBase64");
|
||||
let b64 = r["dataBase64"].as_str().unwrap().to_string();
|
||||
assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF"));
|
||||
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"}));
|
||||
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(lib.name, "Brand Kit", "named after the file");
|
||||
let ink = lib.swatch("Ink").unwrap();
|
||||
assert!(ink.spot && ink.global);
|
||||
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
|
||||
assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0)));
|
||||
assert!(lib.swatch("Sunset").is_none(), "a gradient is left out");
|
||||
let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
|
||||
assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order");
|
||||
// Gray colors come back within float rounding.
|
||||
let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches;
|
||||
for (w, want) in lib.groups[0].swatches.iter().zip(grays) {
|
||||
let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else {
|
||||
panic!("{} isn't gray", w.name)
|
||||
};
|
||||
assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name);
|
||||
}
|
||||
// Into the user library folder, and to a path ending in .ase.
|
||||
let dir = temp_dir("ase-save");
|
||||
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
|
||||
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"}));
|
||||
assert_eq!(r["library"], "user/Brand Kit.ase");
|
||||
assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF"));
|
||||
assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it");
|
||||
let path = dir.join("out.ase").to_string_lossy().to_string();
|
||||
assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase");
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF"));
|
||||
let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string();
|
||||
assert!(e.contains("vcswatches, gpl, ase or css"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
|
||||
use vectorcraft_testkit::ase::{Block, ase, sample};
|
||||
let dir = temp_dir("copy");
|
||||
let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere"));
|
||||
std::fs::create_dir_all(&user).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
let source = elsewhere.join("Brand.ase");
|
||||
std::fs::write(&source, sample()).unwrap();
|
||||
let source = source.to_string_lossy().to_string();
|
||||
// Another library whose file name differs only in case.
|
||||
let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]);
|
||||
std::fs::write(user.join("brand.ase"), &other).unwrap();
|
||||
let mut s = session();
|
||||
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
|
||||
assert_eq!(loaded, format!("loaded/{source}"));
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||||
// A swatch exchange library is named after its file.
|
||||
assert_eq!(
|
||||
(r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()),
|
||||
(Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true))
|
||||
);
|
||||
assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string());
|
||||
assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is");
|
||||
assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced");
|
||||
let listed = |s: &mut Session| -> Vec<String> {
|
||||
let list = run(s, "swatch.library.list", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded")));
|
||||
libs.map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only");
|
||||
// The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors.
|
||||
let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id);
|
||||
assert_eq!(found.as_deref(), Some("user/Brand 2.ase"));
|
||||
assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some());
|
||||
// The same file again opens the copy, which is listed once; copying it writes nothing.
|
||||
let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
|
||||
assert_eq!(again, "user/Brand 2.ase");
|
||||
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]);
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again }));
|
||||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false)));
|
||||
assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2);
|
||||
// A document's swatches and a library loaded from data are written as .vcswatches.
|
||||
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
|
||||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||||
let poster = elsewhere.join("Poster.vectorcraft");
|
||||
std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true)));
|
||||
assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some());
|
||||
// Copied again, the document's swatches are written as the same bytes: the copy there is used.
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false)));
|
||||
let kit = vectorcraft_format::base64_encode(&sample());
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
|
||||
assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches");
|
||||
// A library without a name is copied as Library.
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone();
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
|
||||
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true)));
|
||||
// When the source no longer reads as a library, the copy fails and no new file is left in the folder.
|
||||
let junk = elsewhere.join("Junk.ase");
|
||||
std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap();
|
||||
let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone();
|
||||
std::fs::write(&junk, b"not a library").unwrap();
|
||||
let files = std::fs::read_dir(&user).unwrap().count();
|
||||
let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
|
||||
assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&user).unwrap().count(), files);
|
||||
// A User Defined library comes back as it is; a built-in one is an error.
|
||||
let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"}));
|
||||
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false)));
|
||||
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in"));
|
||||
assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err());
|
||||
// Without a user library folder (the web, headless sessions) there is nowhere to copy to, and
|
||||
// the command is off.
|
||||
let mut headless = session();
|
||||
let info = find_command("swatch.library.copyToUser").unwrap().info(&headless);
|
||||
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}");
|
||||
assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled);
|
||||
let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
|
||||
let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
|
||||
assert!(e.contains("no user library folder"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
|
||||
use vectorcraft_testkit::ase::{Block, ase};
|
||||
|
||||
@@ -316,6 +316,106 @@ fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
|
||||
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
|
||||
}
|
||||
|
||||
fn stroke_width(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width
|
||||
}
|
||||
|
||||
fn guide_positions(s: &Session) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
|
||||
}
|
||||
|
||||
/// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the
|
||||
/// artboard and its guides from the old rectangle onto the new one, each side by its own ratio, in
|
||||
/// one undo step; art partly outside and canvas guides stay.
|
||||
#[test]
|
||||
fn artboard_set_props_scales_the_art_and_guides_with_the_artboard() {
|
||||
let mut s = session();
|
||||
let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0);
|
||||
for (vertical, pos, artboard) in [(true, 400.0, json!(0)), (false, 300.0, json!(0)), (true, 400.0, json!(null))] {
|
||||
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "artboard": artboard})).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
// Half the size: half the art, half the stroke (Scale Strokes & Effects).
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true, "strokes": true})).unwrap();
|
||||
assert_eq!(out["scaled"], json!([inside.0]));
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0));
|
||||
assert_eq!(bounds(&s, inside), Rect::new(50.0, 50.0, 150.0, 100.0));
|
||||
assert_eq!(stroke_width(&s, inside), 2.0);
|
||||
assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0), "art partly outside stays");
|
||||
assert_eq!(guide_positions(&s), vec![200.0, 150.0, 400.0], "the artboard's guides scale, the canvas guide stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
|
||||
// Moved and stretched (left edge to -100, twice as wide), strokes kept: the art follows.
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "x": -100, "width": 1600, "scaleArt": true, "strokes": false})).unwrap();
|
||||
assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 500.0, 200.0));
|
||||
assert_eq!(stroke_width(&s, inside), 4.0);
|
||||
assert_eq!(guide_positions(&s), vec![700.0, 300.0, 400.0]);
|
||||
}
|
||||
|
||||
/// Without `scaleArt` a resize leaves the art and guides alone (and returns nothing, as before);
|
||||
/// with it, a pure move scales nothing (guides move along, as they always have).
|
||||
#[test]
|
||||
fn artboard_set_props_scales_art_only_when_asked_and_resized() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap();
|
||||
assert_eq!(s.execute("artboard.setProps", &json!({"index": 0, "width": 400})).unwrap(), Value::Null);
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(guide_positions(&s), vec![400.0]);
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 50, "scaleArt": true})).unwrap();
|
||||
assert_eq!(out["scaled"], json!([]));
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(guide_positions(&s), vec![450.0]);
|
||||
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
|
||||
}
|
||||
|
||||
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
|
||||
/// moves only with it.
|
||||
#[test]
|
||||
fn scale_artwork_with_artboard_leaves_locked_art_unless_the_preference_says() {
|
||||
let mut s = session();
|
||||
let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let scaled = |s: &mut Session| {
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
out["scaled"].clone()
|
||||
};
|
||||
assert_eq!(scaled(&mut s), json!([]));
|
||||
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": true})).unwrap();
|
||||
assert_eq!(scaled(&mut s), json!([locked.0]));
|
||||
// The choice is journaled: replayed with the preference off, the same art scales.
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true);
|
||||
let (id, p) = s.journal.last().unwrap().clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": false})).unwrap();
|
||||
assert_eq!(s.execute(&id, &p).unwrap()["scaled"], json!([locked.0]));
|
||||
}
|
||||
|
||||
/// The Artboard tool with its `scaleArt` option on resizes proportionally and the art scales with
|
||||
/// the artboard, in one undo step.
|
||||
#[test]
|
||||
fn artboard_tool_scales_art_with_the_artboard() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.set_tool_options(Some("artboard"), json!({"scaleArt": true}).as_object().unwrap());
|
||||
// The right handle 100 pt out: 9/8 as wide, and as tall about the middle of the left side.
|
||||
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, -37.5, 900.0, 637.5));
|
||||
assert_eq!(bounds(&s, r), Rect::new(112.5, 75.0, 337.5, 187.5));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
|
||||
// Off again (the option is remembered like Move Artwork with Artboard): the art stays.
|
||||
s.set_tool_options(Some("artboard"), json!({"scaleArt": false}).as_object().unwrap());
|
||||
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_wand_selects_same_fill() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -358,6 +358,11 @@ impl Session {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
/// Is the active tool moving, scaling or rotating the selection with a drag?
|
||||
pub fn tool_transforming(&self) -> bool {
|
||||
self.tool.transforming()
|
||||
}
|
||||
|
||||
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
|
||||
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Synthetic version-12 regression documents exercise placement and clipping, independently of
|
||||
//! the pinned real-file corpus. These are reader fixtures, never claimed as Affinity-authored.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use kurbo::Rect;
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
|
||||
fn field(t: &[u8; 4], f: F) -> (vectorcraft_affinity::stream::Tag, F) {
|
||||
(tag(t), f)
|
||||
}
|
||||
|
||||
fn red(id: u32) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"FDsc")],
|
||||
vec![field(
|
||||
b"FDeF",
|
||||
F::Def(
|
||||
id + 1,
|
||||
vec![tag(b"FilS")],
|
||||
vec![field(
|
||||
b"Colr",
|
||||
F::Def(
|
||||
id + 2,
|
||||
vec![tag(b"RGBA")],
|
||||
vec![field(b"_col", F::Struct([1f32, 0.0, 0.0, 1.0].into_iter().flat_map(f32::to_le_bytes).collect()))],
|
||||
),
|
||||
)],
|
||||
),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn shape(id: u32, name: &str, board: bool, ellipse: bool, bounds: [f64; 4], transform: [f64; 6], children: Vec<F>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
field(b"Desc", F::Str(name.into())),
|
||||
field(b"ABEn", F::Bool(board)),
|
||||
field(b"Shpe", F::Def(id + 1, vec![tag(if ellipse { b"ShpE" } else { b"ShNR" })], vec![])),
|
||||
field(b"ShpB", F::F64s(bounds.to_vec())),
|
||||
field(b"Xfrm", F::F64s(transform.to_vec())),
|
||||
field(b"BFFl", F::Shared(vec![red(id + 2)])),
|
||||
field(b"Chld", F::Shared(children)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
||||
|
||||
fn document(spreads: Vec<Vec<F>>, dpi: f64, method: Method) -> Vec<u8> {
|
||||
let spreads = spreads
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, nodes)| {
|
||||
F::Def(1000 + i as u32, vec![tag(b"Sprd")], vec![field(b"SprB", F::F64s(vec![0.0, 0.0, 200.0, 100.0])), field(b"Chld", F::Shared(nodes))])
|
||||
})
|
||||
.collect();
|
||||
let doc = synth::stream(&[
|
||||
field(b"UVCn", F::Obj(tag(b"UVCn"), vec![field(b"UPPI", F::F64(dpi))])),
|
||||
field(b"DocR", F::Def(2000, vec![tag(b"DocN")], vec![field(b"Chld", F::Shared(spreads))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &doc, method)], None)
|
||||
}
|
||||
|
||||
fn find<'a>(nodes: &'a [std::sync::Arc<Node>], name: &str) -> Option<&'a Node> {
|
||||
nodes.iter().find_map(|n| {
|
||||
if n.name.as_deref() == Some(name) && matches!(n.kind, NodeKind::Group { .. }) {
|
||||
Some(n.as_ref())
|
||||
} else {
|
||||
n.children().and_then(|c| find(c, name))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_boards_keep_order_and_negative_origins_at_document_resolution() {
|
||||
let inner = shape(20, "Inner", true, false, [0.0, 0.0, 20.0, 10.0], [1.0, 0.0, 10.0, 0.0, 1.0, 5.0], vec![]);
|
||||
let outer = shape(10, "Outer", true, false, [0.0, 0.0, 100.0, 60.0], [1.0, 0.0, -40.0, 0.0, 1.0, -20.0], vec![inner]);
|
||||
let last = shape(30, "Last", true, false, [0.0, 0.0, 40.0, 80.0], [1.0, 0.0, 150.0, 0.0, 1.0, 0.0], vec![]);
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("boards.af", &document(vec![vec![outer.clone(), last.clone()]], 144.0, method)).unwrap();
|
||||
let boards = &loaded.doc.artboards;
|
||||
assert_eq!(boards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Outer", "Inner", "Last"]);
|
||||
assert_eq!(boards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3]);
|
||||
assert_eq!(boards[0].rect, Rect::new(-20.0, -10.0, 30.0, 20.0));
|
||||
assert_eq!(boards[1].rect, Rect::new(-15.0, -7.5, -5.0, -2.5));
|
||||
assert_eq!(boards[2].rect, Rect::new(75.0, 0.0, 95.0, 40.0));
|
||||
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
|
||||
assert_eq!(reopened.doc.artboards, loaded.doc.artboards);
|
||||
assert_eq!(reopened.doc.layers, loaded.doc.layers);
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
|
||||
let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false);
|
||||
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_board_clips_to_its_outline_instead_of_its_export_rectangle() {
|
||||
let child = shape(20, "Oversized", false, false, [-100.0, -100.0, 200.0, 200.0], IDENTITY, vec![]);
|
||||
let c = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let board = shape(10, "Diamond", true, false, [0.0, 0.0, 80.0, 80.0], [c, -c, 60.0, c, c, 0.0], vec![child]);
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("rotated.af", &document(vec![vec![board]], 72.0, Method::Zstd)).unwrap();
|
||||
let d = &loaded.doc;
|
||||
let g = find(&d.layers, "Diamond").unwrap();
|
||||
let NodeKind::Path { path, clipping: true, .. } = &g.children().unwrap()[0].kind else { panic!() };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert!((a[0].p.x - 60.0).abs() < 1e-8 && a[0].p.y.abs() < 1e-8);
|
||||
assert!((a[1].p.x - (60.0 + 80.0 * c)).abs() < 1e-8);
|
||||
assert_eq!(pixel(d, 2, 2), [0, 0, 0, 0], "bbox corners are outside the diamond");
|
||||
assert_eq!(pixel(d, 55, 55), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_ellipse_artboard_keeps_curved_clipping() {
|
||||
let child = shape(20, "Oversized", false, false, [-20.0, -20.0, 120.0, 120.0], IDENTITY, vec![]);
|
||||
let board = shape(10, "Ellipse", true, true, [0.0, 0.0, 100.0, 100.0], IDENTITY, vec![child]);
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("ellipse.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
|
||||
assert_eq!(pixel(&loaded.doc, 2, 2), [0, 0, 0, 0]);
|
||||
assert_eq!(pixel(&loaded.doc, 50, 50), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_board_keeps_children_and_reports_the_loss() {
|
||||
let child = shape(20, "Retained", false, false, [1.0, 2.0, 10.0, 20.0], IDENTITY, vec![]);
|
||||
let board = shape(10, "Invalid", true, false, [0.0, 0.0, 0.0, 100.0], IDENTITY, vec![child]);
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("invalid.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("artboard without valid bounds")));
|
||||
assert!(!loaded.preview_only);
|
||||
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
|
||||
let invalid = find(&loaded.doc.layers, "Invalid").unwrap();
|
||||
assert_eq!(invalid.children().unwrap()[0].name.as_deref(), Some("Retained"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_spreads_preserve_each_board_and_leave_room_between_spreads() {
|
||||
let a = shape(10, "First", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
|
||||
let b = shape(20, "Second", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load("spreads.af", &document(vec![vec![a], vec![b]], 72.0, Method::Stored)).unwrap();
|
||||
assert_eq!(loaded.doc.artboards.len(), 2);
|
||||
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 100.0, 60.0));
|
||||
assert_eq!(loaded.doc.artboards[1].rect, Rect::new(136.0, 0.0, 236.0, 60.0));
|
||||
}
|
||||
@@ -4,14 +4,27 @@
|
||||
//! (0–255 per channel, both flattened on white, at the thumbnail's size) must stay under its
|
||||
//! ceiling, which sits just above the difference measured when the importer landed.
|
||||
//!
|
||||
//! Skips when `corpus/affinity` is absent.
|
||||
//! Each artboard is checked. Local runs may skip an absent corpus; CI requires the complete
|
||||
//! pinned corpus with `AFFINITY_CORPUS_REQUIRED=1`. Tests recheck hashes before using inputs.
|
||||
// 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::path::PathBuf;
|
||||
|
||||
#[path = "../../affinity/tests/support/corpus.rs"]
|
||||
mod corpus_gate;
|
||||
|
||||
/// (file, ceiling). Measured differences when the importer landed are in the comments.
|
||||
const FILES: &[(&str, f64)] = &[
|
||||
("patchy-artboards.af", 0.25), // 0.00 across both boards, including overflow clipping
|
||||
("patchy-rgba8.af", 3.0), // 2.34: embedded thumbnail versus raster sampling
|
||||
("patchy-rgba16.af", 3.0), // 2.34
|
||||
("patchy-dpi300.af", 1.5), // 0.87
|
||||
("patchy-group.af", 0.25), // 0.00
|
||||
("patchy-transform.af", 1.0), // 0.19
|
||||
("patchy-vector.af", 1.0), // 0.12
|
||||
("patchy-lazy-placed.af", 1.0), // 0.50: upstream byte-derived lazy-image regression
|
||||
("patchy-embedded-jpeg.af", 1.0), // 0.50
|
||||
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
|
||||
("affinity3-mexican-man.af", 3.5), // 2.24
|
||||
("affinity3-mexican-woman.af", 3.5), // 2.55
|
||||
@@ -37,8 +50,7 @@ const FILES: &[(&str, f64)] = &[
|
||||
];
|
||||
|
||||
fn corpus() -> Option<PathBuf> {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
dir.join("SOURCES.md").exists().then_some(dir)
|
||||
corpus_gate::pinned()
|
||||
}
|
||||
|
||||
/// Mean absolute difference of two RGBA8 images of one size, both composited on white.
|
||||
@@ -48,7 +60,7 @@ fn difference(a: &[u8], b: &[u8]) -> f64 {
|
||||
[0, 1, 2].map(|i| f64::from(p[i]) * al + 255.0 * (1.0 - al))
|
||||
};
|
||||
let (mut sum, mut n) = (0.0, 0usize);
|
||||
for (p, q) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
for (p, q) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0.iter()) {
|
||||
let (x, y) = (white(p), white(q));
|
||||
sum += (0..3).map(|i| (x[i] - y[i]).abs()).sum::<f64>();
|
||||
n += 3;
|
||||
@@ -69,23 +81,178 @@ fn public_affinity_documents_render_like_their_affinity_thumbnails() {
|
||||
assert!(!loaded.preview_only, "{name} opened as its preview: {:?}", loaded.warnings);
|
||||
let d = &loaded.doc;
|
||||
let thumb = image::load_from_memory(vectorcraft_affinity::preview(&bytes).unwrap().png).unwrap().to_rgba8();
|
||||
// The thumbnail shows the first page, or every artboard: compare the largest artboard
|
||||
// with its part of the thumbnail.
|
||||
// The embedded thumbnail shows the union of the artboards. Check every board,
|
||||
// including small ones that the previous largest-board-only check omitted.
|
||||
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(union.width().is_finite() && union.width() > 0.0);
|
||||
let scale = f64::from(thumb.width()) / union.width();
|
||||
let board = d.artboards.iter().max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap().rect;
|
||||
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
|
||||
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
|
||||
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
|
||||
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
|
||||
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
|
||||
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
|
||||
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
|
||||
let diff = difference(expected.as_raw(), got.as_raw());
|
||||
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
|
||||
if diff > *ceiling {
|
||||
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
|
||||
for (index, artboard) in d.artboards.iter().enumerate() {
|
||||
let board = artboard.rect;
|
||||
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
|
||||
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
|
||||
assert!(x < thumb.width() && y < thumb.height(), "{name} board {index}: thumbnail crop outside image");
|
||||
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
|
||||
assert!(w > 0 && h > 0, "{name} board {index}: empty thumbnail crop");
|
||||
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
|
||||
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
|
||||
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
|
||||
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
|
||||
let diff = difference(expected.as_raw(), got.as_raw());
|
||||
eprintln!("{name} board {index} ({}): {diff:.2} (ceiling {ceiling})", artboard.name);
|
||||
if diff > *ceiling {
|
||||
failures.push(format!("{name} board {index}: {diff:.2} > {ceiling}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Every actual .af fixture must open as editable art, retain its complete native document,
|
||||
/// and export every artboard. Reimporting our SVG/PDF and comparing our PSD merged pixels
|
||||
/// checks our export paths; it is not an independent Affinity-versus-PSD fidelity oracle.
|
||||
#[test]
|
||||
fn affinity3_all_artboards_survive_native_save_and_export() {
|
||||
let Some(dir) = corpus() else { return };
|
||||
let names: Vec<_> = include_str!("../../../xtask/affinity-corpus.sha256")
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once(" "))
|
||||
.map(|(_, name)| name)
|
||||
.filter(|name| name.ends_with(".af"))
|
||||
.collect();
|
||||
for name in names {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load(name, &bytes).unwrap();
|
||||
assert!(!loaded.preview_only, "{name}: preview-only import: {:?}", loaded.warnings);
|
||||
let doc = &loaded.doc;
|
||||
assert!(!doc.artboards.is_empty(), "{name}: no artboards");
|
||||
let native = vectorcraft_format::save(doc, false);
|
||||
let reloaded = vectorcraft_format::load(&native).unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded).unwrap(), serde_json::to_value(doc).unwrap(), "{name}: native save/reload lost document data");
|
||||
if doc.artboards.len() > 1 {
|
||||
let params = serde_json::json!({"range": "all", "useArtboards": true, "preserveEditing": false});
|
||||
let svg = vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", ¶ms).unwrap();
|
||||
assert_eq!(svg.files.len(), doc.artboards.len(), "{name}: SVG must write every artboard");
|
||||
assert_eq!(
|
||||
svg.files.iter().map(|(index, _)| *index).collect::<Vec<_>>(),
|
||||
(0..doc.artboards.len()).map(Some).collect::<Vec<_>>(),
|
||||
"{name}: SVG artboard order"
|
||||
);
|
||||
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", ¶ms).unwrap();
|
||||
assert_eq!(
|
||||
vectorcraft_pdf::info(&pdf, None).unwrap().pages.len(),
|
||||
doc.artboards.len(),
|
||||
"{name}: PDF must write every artboard as a page"
|
||||
);
|
||||
}
|
||||
for (index, board) in doc.artboards.iter().enumerate() {
|
||||
assert!(
|
||||
board.rect.width().is_finite() && board.rect.width() > 0.0 && board.rect.height().is_finite() && board.rect.height() > 0.0,
|
||||
"{name} board {index}: invalid bounds"
|
||||
);
|
||||
let scale = 128.0 / board.rect.width().max(board.rect.height());
|
||||
let params =
|
||||
serde_json::json!({"artboard": index, "useArtboards": true, "scale": scale, "background": "transparent", "preserveEditing": false});
|
||||
let svg = vectorcraft_engine::cmd::fileio::encode(doc, "svg", ¶ms).unwrap();
|
||||
let svg = vectorcraft_svg::import(std::str::from_utf8(&svg).unwrap()).unwrap();
|
||||
assert_eq!(svg.artboards.len(), 1, "{name} board {index}: SVG page count");
|
||||
assert!((svg.artboards[0].rect.width() - board.rect.width()).abs() < 0.01, "{name} board {index}: SVG width");
|
||||
assert!((svg.artboards[0].rect.height() - board.rect.height()).abs() < 0.01, "{name} board {index}: SVG height");
|
||||
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", ¶ms).unwrap();
|
||||
let info = vectorcraft_pdf::info(&pdf, None).unwrap();
|
||||
assert_eq!(info.pages.len(), 1, "{name} board {index}: PDF page count");
|
||||
assert!((info.pages[0].width - board.rect.width()).abs() < 0.01, "{name} board {index}: PDF width");
|
||||
assert!((info.pages[0].height - board.rect.height()).abs() < 0.01, "{name} board {index}: PDF height");
|
||||
assert_eq!(vectorcraft_pdf::import(&pdf).unwrap().artboards.len(), 1, "{name} board {index}: PDF reimport");
|
||||
let psd = vectorcraft_engine::cmd::fileio::encode(doc, "psd", ¶ms).unwrap();
|
||||
let png = vectorcraft_engine::cmd::fileio::encode(doc, "png", ¶ms).unwrap();
|
||||
let png = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
let (merged, has_alpha) = psd_merged(&psd);
|
||||
assert_eq!(merged.dimensions(), png.dimensions(), "{name} board {index}: PSD image dimensions");
|
||||
// PSD merged RGB is stored on a white matte for readers that ignore alpha;
|
||||
// the separate alpha plane, when present, keeps the original coverage.
|
||||
for (pixel, (p, q)) in png.pixels().zip(merged.pixels()).enumerate() {
|
||||
if has_alpha {
|
||||
assert_eq!(p[3], q[3], "{name} board {index} pixel {pixel}: PSD alpha");
|
||||
}
|
||||
let alpha = u32::from(p[3]);
|
||||
for channel in 0..3 {
|
||||
let expected = ((u32::from(p[channel]) * alpha + 255 * (255 - alpha) + 127) / 255) as u8;
|
||||
assert!(
|
||||
q[channel].abs_diff(expected) <= 1,
|
||||
"{name} board {index} pixel {pixel} channel {channel}: PSD white-matted RGB {} differs from PNG on white {expected}",
|
||||
q[channel]
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!("{name} board {index}: native/SVG/PDF/PSD verified (import warnings: {:?})", loaded.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal test-only decoder written from the public PSD file-format layout:
|
||||
/// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
/// It independently reads the merged RGB planes and PackBits rows, skipping metadata/layers.
|
||||
fn psd_merged(bytes: &[u8]) -> (image::RgbaImage, bool) {
|
||||
let be16 = |at: usize| u16::from_be_bytes(bytes[at..at + 2].try_into().unwrap());
|
||||
let be32 = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
assert_eq!(&bytes[..4], b"8BPS");
|
||||
assert_eq!(be16(4), 1);
|
||||
assert_eq!(&bytes[6..12], &[0; 6]);
|
||||
let channels = usize::from(be16(12));
|
||||
assert!((3..=4).contains(&channels));
|
||||
let (height, width) = (be32(14), be32(18));
|
||||
assert_eq!(be16(22), 8);
|
||||
assert_eq!(be16(24), 3);
|
||||
let mut at = 26;
|
||||
for _ in 0..3 {
|
||||
at += 4 + be32(at);
|
||||
}
|
||||
let compression = be16(at);
|
||||
at += 2;
|
||||
let mut planes = vec![vec![0; width * height]; channels];
|
||||
match compression {
|
||||
0 => {
|
||||
for plane in &mut planes {
|
||||
plane.copy_from_slice(&bytes[at..at + width * height]);
|
||||
at += width * height;
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let row_lengths: Vec<_> = (0..channels * height).map(|i| usize::from(be16(at + i * 2))).collect();
|
||||
at += channels * height * 2;
|
||||
for (channel, plane) in planes.iter_mut().enumerate() {
|
||||
for row in 0..height {
|
||||
let end = at + row_lengths[channel * height + row];
|
||||
let mut decoded = Vec::new();
|
||||
while at < end {
|
||||
let control = bytes[at] as i8;
|
||||
at += 1;
|
||||
match control {
|
||||
0..=127 => {
|
||||
let n = control as usize + 1;
|
||||
assert!(at + n <= end);
|
||||
decoded.extend_from_slice(&bytes[at..at + n]);
|
||||
at += n;
|
||||
}
|
||||
-127..=-1 => {
|
||||
assert!(at < end);
|
||||
decoded.extend(std::iter::repeat_n(bytes[at], (1i16 - i16::from(control)) as usize));
|
||||
at += 1;
|
||||
}
|
||||
-128 => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(decoded.len(), width);
|
||||
plane[row * width..(row + 1) * width].copy_from_slice(&decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => panic!("unexpected PSD compression {compression}"),
|
||||
}
|
||||
assert_eq!(at, bytes.len(), "PSD merged planes must consume the complete image section");
|
||||
let image = image::RgbaImage::from_fn(width as u32, height as u32, |x, y| {
|
||||
let i = y as usize * width + x as usize;
|
||||
image::Rgba([planes[0][i], planes[1][i], planes[2][i], planes.get(3).map_or(255, |p| p[i])])
|
||||
});
|
||||
(image, channels == 4)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Editable text import regressions on original synthetic version-12 `.af` streams and pinned
|
||||
//! public Affinity 3 files. Font rendering depends on installed fonts; these are structure and
|
||||
//! conversion oracles, not claims of pixel-identical typography.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
#[path = "../../affinity/tests/support/corpus.rs"]
|
||||
mod corpus;
|
||||
#[path = "../../affinity/tests/support/text_fixture.rs"]
|
||||
mod fixture;
|
||||
use fixture::*;
|
||||
use vectorcraft_affinity::synth::{F, tag};
|
||||
use vectorcraft_doc::{
|
||||
Document, Node, NodeKind, TextObject,
|
||||
text::{CharStyle, Justify, TextKind},
|
||||
};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
fn texts(d: &Document) -> Vec<&TextObject> {
|
||||
fn walk<'a>(n: &'a Node, out: &mut Vec<&'a TextObject>) {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.push(t);
|
||||
}
|
||||
if let Some(children) = n.children() {
|
||||
for child in children {
|
||||
walk(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for n in &d.layers {
|
||||
walk(n, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn import(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> fileio::Loaded {
|
||||
let loaded = fileio::load("original-text.af", &document(blocks, frame, dpi, transform)).unwrap();
|
||||
assert!(!loaded.preview_only);
|
||||
loaded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_mixed_fonts_line_breaks_and_styles_remain_editable_after_save() {
|
||||
let loaded = import(
|
||||
vec![block(
|
||||
utf8("é😀\u{2028}ab\u{2029}尾\0"),
|
||||
vec![run(2, Some(attrs("First", "FirstPS", 700, true, 24.0))), run(8, Some(attrs("Second", "SecondPS", 300, false, 20.0)))],
|
||||
&[1],
|
||||
)],
|
||||
false,
|
||||
96.0,
|
||||
[2.0, 0.0, 30.0, 0.0, 2.0, 40.0],
|
||||
);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "\nab\n尾"]);
|
||||
assert_eq!(t.runs[0].style.font_family, "First");
|
||||
assert_eq!(t.runs[0].style.font_style, "Bold Italic");
|
||||
assert_eq!(t.runs[1].style.font_style, "Light");
|
||||
assert_eq!((t.runs[0].style.size, t.runs[0].style.leading, t.runs[0].style.tracking), (36.0, Some(45.0), 25.0));
|
||||
assert_eq!(t.para.justify, Justify::Center);
|
||||
assert!(matches!(t.kind, TextKind::Point));
|
||||
assert_eq!(t.xf.as_coeffs(), [1.0, 0.0, 0.0, 1.0, 187.5, 87.0]);
|
||||
let saved = vectorcraft_format::save_file(&loaded.doc);
|
||||
let restored = vectorcraft_format::load(&saved).unwrap();
|
||||
assert_eq!(texts(&restored)[0], t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_font_name_uses_named_default_and_postscript_name_is_preserved() {
|
||||
let loaded =
|
||||
import(vec![block(utf8("ab"), vec![run(1, Some(attrs("", "OnlyPostScript", 400, false, 12.0))), run(2, None)], &[])], false, 72.0, IDENTITY);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs[0].style.font_family, "OnlyPostScript");
|
||||
assert_eq!(t.runs[1].style.font_family, CharStyle::default().font_family);
|
||||
assert_eq!(t.runs[1].style.fill.color().unwrap().to_rgb(), [0.0, 0.0, 0.0]);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("without a font name")));
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("without character attributes")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_type_keeps_dimensions_alignment_and_unit_conversion() {
|
||||
let loaded = import(vec![block(utf8("text"), vec![run(4, Some(attrs("Frame", "", 500, false, 20.0)))], &[3])], true, 144.0, IDENTITY);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs[0].style.size, 10.0);
|
||||
assert_eq!(t.runs[0].style.font_style, "Medium");
|
||||
assert_eq!(t.para.justify, Justify::JustifyLeft);
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
|
||||
assert_eq!(frame.bounds().unwrap().width(), 100.0);
|
||||
assert_eq!(frame.bounds().unwrap().height(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_font_weight_and_italic_maps_to_the_expected_style() {
|
||||
let cases = [
|
||||
(100, "Thin"),
|
||||
(200, "ExtraLight"),
|
||||
(300, "Light"),
|
||||
(400, "Regular"),
|
||||
(500, "Medium"),
|
||||
(600, "SemiBold"),
|
||||
(700, "Bold"),
|
||||
(800, "ExtraBold"),
|
||||
(900, "Black"),
|
||||
];
|
||||
for (weight, expected) in cases {
|
||||
for italic in [false, true] {
|
||||
let loaded = import(vec![block(utf8("x"), vec![run(1, Some(attrs("Family", "", weight, italic, 12.0)))], &[])], false, 72.0, IDENTITY);
|
||||
let style = &texts(&loaded.doc)[0].runs[0].style.font_style;
|
||||
let expected = if !italic {
|
||||
expected.into()
|
||||
} else if expected == "Regular" {
|
||||
"Italic".into()
|
||||
} else {
|
||||
format!("{expected} Italic")
|
||||
};
|
||||
assert_eq!(*style, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_feature_overrides_pass_through_to_editable_type() {
|
||||
let mut a = attrs("Family", "", 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 = F::Shared(vec![
|
||||
solid(1.0),
|
||||
F::Null,
|
||||
F::Null,
|
||||
F::Null,
|
||||
F::Null,
|
||||
F::Null,
|
||||
F::Null,
|
||||
F::Obj(
|
||||
tag(b"OtAt"),
|
||||
vec![(
|
||||
tag(b"Setn"),
|
||||
F::Objs(
|
||||
tag(b"OTFS"),
|
||||
vec![
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
|
||||
],
|
||||
),
|
||||
)],
|
||||
),
|
||||
]);
|
||||
}
|
||||
let loaded = import(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])], false, 72.0, IDENTITY);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs[0].text, "Mixed");
|
||||
assert!(t.runs[0].style.all_caps);
|
||||
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
|
||||
assert_eq!(texts(&restored)[0], t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_affinity3_text_opens_as_editable_unicode_with_resolved_emoji_font() {
|
||||
let Some(corpus) = corpus::pinned() else { return };
|
||||
let load = |name: &str| {
|
||||
let loaded = fileio::load(name, &std::fs::read(corpus.join(name)).unwrap()).unwrap();
|
||||
assert!(!loaded.preview_only);
|
||||
loaded
|
||||
};
|
||||
let loaded = load("patchy-text-runs.af");
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<String>(), "ABcdÉ😀X");
|
||||
assert_eq!(t.runs[3].style.font_family, "Segoe UI Emoji");
|
||||
assert_eq!(t.runs[4].style.font_family, "Courier New");
|
||||
let loaded = load("patchy-text-caps.af");
|
||||
let ts = texts(&loaded.doc);
|
||||
assert!(ts[0].runs[0].style.all_caps);
|
||||
assert_eq!(ts[1].runs[0].style.features, ["smcp"]);
|
||||
for name in ["patchy-text-artistic.af", "patchy-text-frame.af", "patchy-text-rotated.af", "patchy-text-indent.af", "patchy-text-para-spacing.af"]
|
||||
{
|
||||
let loaded = load(name);
|
||||
assert!(!texts(&loaded.doc).is_empty(), "{name}");
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
|
||||
assert_eq!(texts(&restored), texts(&loaded.doc), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_postscript_identity_overrides_ambiguous_family_and_weight() {
|
||||
// This is an OS font metadata check. No font file is committed or required; the structural
|
||||
// fallback cases above always run, including when the optional font repository is absent.
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let Some((family, face_style)) = db.by_postscript_name("DejaVuSans") else {
|
||||
eprintln!("DejaVuSans is not installed; exact PostScript face test skipped");
|
||||
return;
|
||||
};
|
||||
let loaded =
|
||||
import(vec![block(utf8("x"), vec![run(1, Some(attrs("Ambiguous Family", "DejaVuSans", 900, true, 12.0)))], &[])], false, 72.0, IDENTITY);
|
||||
let style = &texts(&loaded.doc)[0].runs[0].style;
|
||||
assert_eq!(style.font_family, family);
|
||||
assert_eq!(style.font_style, face_style);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Clear deletes its exact targets, including compound members (#786).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::{EngineError, Session};
|
||||
|
||||
fn compound() -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let mut rectangle =
|
||||
|x| NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());
|
||||
let a = rectangle(10);
|
||||
let b = rectangle(50);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
(s, a, b, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_selected_compound_member_preserves_its_sibling_and_is_undoable() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
assert!(s.active().unwrap().selection.is_empty());
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Clear");
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![a]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_explicit_ids_works_without_a_selection_and_overrides_selected_anchors() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let info = s.commands().into_iter().find(|info| info.id == "edit.clear").unwrap();
|
||||
assert!(!info.enabled, "the menu stays disabled with no selection");
|
||||
s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]]})).unwrap();
|
||||
let sibling = s.active().unwrap().doc.node(b).unwrap().clone();
|
||||
s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(s.active().unwrap().doc.node(b).unwrap(), &sibling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_explicit_targets_are_validated_atomically() {
|
||||
let (mut s, a, _, _) = compound();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
for ids in [json!([a.0, 999]), json!([a.0, -1]), json!([a.0, "bad"]), json!([a.0, 2.5]), json!(null), json!(a.0)] {
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let journal = s.journal.clone();
|
||||
assert!(matches!(s.execute("edit.clear", &json!({"ids": ids})), Err(EngineError::BadParams { .. })), "{ids}");
|
||||
assert_eq!(s.active().unwrap().doc, doc);
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
assert!(s.execute("edit.clear", &json!({})).is_err());
|
||||
assert!(Session::new().execute("edit.clear", &json!({"ids": [a.0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_compound_and_member_together_deletes_the_compound() {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
|
||||
s.execute("edit.clear", &if explicit { json!({"ids": [a.0, c.0, c.0]}) } else { json!({}) }).unwrap();
|
||||
for id in [a, b, c] {
|
||||
assert!(s.active().unwrap().doc.node(id).is_none());
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_compound_member_keeps_copy_and_delete_targets_consistent() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(s.clipboard.nodes.len(), 1);
|
||||
assert_eq!(s.clipboard.nodes[0].id, c);
|
||||
assert!(s.active().unwrap().doc.node(c).is_none());
|
||||
s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let pasted = s.active().unwrap().selection.objects[0];
|
||||
assert_eq!(s.active().unwrap().doc.node(pasted).unwrap().children().unwrap().len(), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_direct_selected_anchors_keeps_the_rest_of_the_path() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() {
|
||||
let (mut s, a, _, _) = compound();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 10})).unwrap();
|
||||
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||||
let history = s.active().unwrap().history.undo.len();
|
||||
let selection = s.active().unwrap().selection.clone();
|
||||
s.execute("edit.clear", &json!({"ids": []})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), history);
|
||||
assert_eq!(s.active().unwrap().selection, selection);
|
||||
assert_eq!(s.active().unwrap().doc.guides.len(), 1);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.guides.is_empty());
|
||||
assert!(s.active().unwrap().doc.node(a).is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.guides.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_keeps_layers_but_deletes_an_explicitly_targeted_child() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
let layer = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("edit.clear", &json!({"ids": [layer.0, a.0]})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(layer).is_some());
|
||||
assert!(s.active().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_direct_selected_anchors_retains_anchor_deletion() {
|
||||
let (mut s, a, b, c) = compound();
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
@@ -272,6 +272,8 @@ fn structured_junk() {
|
||||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||||
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
|
||||
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
|
||||
("artboard.new", json!({"width": 0, "height": -1})),
|
||||
("artboard.delete", json!({"index": 0})),
|
||||
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! VectorCraft's own Fonts folder: a missing font's file, found in a folder, is copied into it
|
||||
//! without replacing a file there, and type set in the font redraws in it. One test in its own
|
||||
//! process, so the process-wide font database and Fonts folder start fresh, as when the app starts.
|
||||
// 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::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::findfiles::{Rules, plain};
|
||||
|
||||
/// `text.findFontFiles {}` once the search ended (10 seconds at most).
|
||||
fn finished(s: &mut Session) -> Value {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = s.execute("text.findFontFiles", &json!({})).unwrap();
|
||||
if r["state"] != "searching" || Instant::now() > until {
|
||||
return r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
|
||||
let root = vectorcraft_testkit::temp_dir("font-folder");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Downloads/Deep")).unwrap();
|
||||
let root = plain(std::fs::canonicalize(&root).unwrap());
|
||||
let fonts = root.join("App Fonts");
|
||||
vectorcraft_text::set_app_font_dir(fonts.clone());
|
||||
assert!(vectorcraft_text::system_font_dirs().contains(&fonts));
|
||||
let file = root.join("Downloads").join("Deep").join("Findme.otf");
|
||||
std::fs::write(&file, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
|
||||
let serif = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSerif4-Regular.ttf");
|
||||
std::fs::copy(serif, root.join("other.ttf")).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
s.search_rules = Some(Rules { user_content: vec![root.clone()], ..Rules::default() });
|
||||
s.search_threads = Some(2);
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Findme", "font": "Findme Sans 3"})).unwrap();
|
||||
let r = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
assert_eq!((r["fonts"][0]["family"].as_str(), r["fonts"][0]["status"].as_str()), (Some("Findme Sans 3"), Some("missing")), "{r}");
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
|
||||
s.execute("text.findFontFiles", &json!({"folder": root})).unwrap();
|
||||
let r = finished(&mut s);
|
||||
assert_eq!((&r["state"], &r["fonts"][0]["files"]), (&json!("done"), &json!([file])), "{r}");
|
||||
|
||||
// A file of that name is in the Fonts folder already: it stays, and the copy takes a number.
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("Findme.otf"), b"keep").unwrap();
|
||||
let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap();
|
||||
assert_eq!(r["copied"], json!([{"from": file, "to": fonts.join("Findme 2.otf")}]), "{r}");
|
||||
assert!(r["faces"].as_u64().is_some_and(|n| n > 0) && r["families"].as_u64().is_some(), "{r}");
|
||||
assert_eq!(std::fs::read(fonts.join("Findme.otf")).unwrap(), b"keep");
|
||||
|
||||
// The type is set in the font now, without editing the document.
|
||||
let listed = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let findme = listed.as_array().unwrap().iter().find(|f| f["family"] == "Findme Sans 3").cloned().unwrap();
|
||||
assert_eq!((findme["status"].as_str(), findme["resolved"]["family"].as_str()), (Some("exact"), Some("Findme Sans 3")), "{findme}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.revision > revision && !st.is_dirty());
|
||||
assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0);
|
||||
|
||||
// The same file again is kept, not copied a third time.
|
||||
let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap();
|
||||
assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}");
|
||||
assert!(!fonts.join("Findme 3.otf").exists());
|
||||
// Only files the last search found are copied.
|
||||
let r = s.execute("text.addFontFiles", &json!({"files": [root.join("other.ttf")]}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("the last text.findFontFiles search")), "{r:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user