Merge origin/main into #777
package.rs: the collector takes main's font lookup (fonts found by any of their names, in the version the type names, a missing style shown in its closest one, files named by packaged_name). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
94f27299d4
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -167,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/*
|
||||
|
|
@ -191,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
|
||||
|
|
@ -236,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/*
|
||||
|
|
@ -259,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
|
||||
|
|
@ -274,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
|
||||
|
|
@ -292,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
|
||||
|
|
@ -328,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/*
|
||||
|
|
@ -344,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
|
||||
|
|
@ -371,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/*
|
||||
|
|
@ -392,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
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
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/*
|
||||
|
|
@ -66,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
|
||||
|
|
|
|||
|
|
@ -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,11 +4344,12 @@ dependencies = [
|
|||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
|
@ -4348,7 +4360,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
|
|
@ -4360,7 +4372,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
|
@ -4376,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",
|
||||
|
|
@ -4395,7 +4409,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
|
|
@ -4407,7 +4421,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
|
|
@ -4424,7 +4438,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
|
|
@ -4438,7 +4452,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
|
@ -4473,7 +4487,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
|
@ -4492,7 +4506,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
|
|
@ -4506,7 +4520,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
|
|
@ -4518,7 +4532,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
|
|
@ -4537,7 +4551,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
|
@ -4551,7 +4565,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
|
|
@ -4563,7 +4577,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
|
|
@ -4592,7 +4606,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -4606,7 +4620,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
|
|
@ -4631,7 +4645,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
|
@ -4655,7 +4669,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
|
@ -4672,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",
|
||||
|
|
@ -4690,7 +4704,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
|
@ -4705,7 +4719,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
|
|
@ -4718,7 +4732,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
|
|
@ -4747,7 +4761,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -4756,6 +4770,7 @@ dependencies = [
|
|||
"rfd",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
|
@ -5730,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"
|
||||
|
|
@ -5850,7 +5871,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
28
ROADMAP.md
28
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,3 +40,8 @@ sync_github_name = true
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,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)
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ fn source_backed_tiles_retain_the_embedded_jpeg_pixels() {
|
|||
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.chunks_exact(4).all(|p| p[3] == 255));
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[0] != p[1]));
|
||||
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]
|
||||
|
|
@ -80,8 +80,8 @@ fn eight_and_sixteen_bit_channels_retain_the_same_pixel_pattern() {
|
|||
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.chunks_exact(4).any(|p| p[3] < 255));
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[0] != p[1]));
|
||||
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);
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
|
|||
}
|
||||
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
|
||||
assert_eq!(pixels.len(), 80 * 60 * 4);
|
||||
assert!(pixels.chunks_exact(4).all(|p| p[3] == 255));
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
|
||||
assert_eq!(crc32fast::hash(pixels), 0x3720a442);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,9 +196,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();
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -151,10 +152,10 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
|||
}
|
||||
|
||||
/// 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 are
|
||||
/// off), an error when it can't be read.
|
||||
/// 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 {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return None;
|
||||
}
|
||||
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
|
||||
|
|
@ -178,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))
|
||||
})?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,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
|
||||
),
|
||||
|
|
@ -725,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"))]
|
||||
|
|
@ -786,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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,3 +108,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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",
|
||||
|
|
@ -810,9 +818,11 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
|||
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()}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -223,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());
|
||||
|
|
@ -304,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,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();
|
||||
|
|
@ -53,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,
|
||||
|
|
@ -224,29 +235,35 @@ impl<'a> Collector<'a> {
|
|||
let db = vectorcraft_text::FontDb::global();
|
||||
self.lines.push("FONTS".into());
|
||||
self.lines.push(format!("Document: {source}"));
|
||||
for (family, style) in super::fonts::used_fonts(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() => {
|
||||
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 = 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(),
|
||||
);
|
||||
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;
|
||||
}
|
||||
self.lines.push(format!("{font} → Fonts/{file}"));
|
||||
self.lines.push(format!("{font}{shown} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
|
||||
_ => "not available on this computer",
|
||||
Some(_) => "its licence doesn't allow embedding",
|
||||
None => "not available on this computer",
|
||||
};
|
||||
self.lines.push(format!("{font}: {reason}, not copied"));
|
||||
self.lines.push(format!("{font}{shown}: {reason}, not copied"));
|
||||
self.skipped.push(json!({ "font": font, "reason": reason }));
|
||||
}
|
||||
self.lines.push(String::new());
|
||||
|
|
@ -401,3 +418,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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -861,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 {
|
||||
|
|
@ -913,6 +922,9 @@ impl Session {
|
|||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
ruler_guide: None,
|
||||
font_search: None,
|
||||
search_rules: None,
|
||||
search_threads: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1055,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
|
||||
|
|
@ -1456,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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -60,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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@
|
|||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
|
||||
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions).
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
|
||||
//! font files a folder search reads (damaged fonts, collections whose headers give any count of
|
||||
//! faces).
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
|
|
@ -823,6 +825,20 @@ fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The rich document's swatches with a CMYK spot color and a global Lab color added, saved as a
|
||||
/// swatch exchange file, made once.
|
||||
fn saved_ase() -> Vec<u8> {
|
||||
static ASE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
ASE.get_or_init(|| {
|
||||
let mut s = rich_session();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 1, "m": 0.5, "y": 0, "k": 0.2}, "spot": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}, "global": true})).unwrap();
|
||||
let r = s.execute("swatch.library.save", &json!({"format": "ase"})).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A random swatch exchange block (type, body). The body holds a name, a color model when the block
|
||||
/// is a color, and random bytes for the rest.
|
||||
fn arb_ase_block() -> impl Strategy<Value = (u16, Vec<u8>)> {
|
||||
|
|
@ -899,6 +915,19 @@ proptest! {
|
|||
}
|
||||
swatch_exchange("mutated swatch exchange file", &bytes)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_saved_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..4_000), edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
|
||||
let mut bytes = saved_ase();
|
||||
for (at, b) in edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
if let Some(cut) = cut {
|
||||
bytes.truncate(cut);
|
||||
}
|
||||
swatch_exchange("mutated saved swatch exchange file", &bytes)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// A small image of each raster format Place reads, with resolution metadata where it has some.
|
||||
|
|
@ -1704,7 +1733,7 @@ proptest! {
|
|||
*x = b;
|
||||
}
|
||||
}
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?;
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1859,9 +1888,13 @@ proptest! {
|
|||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![]).0))?;
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false).0))?;
|
||||
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true).0))?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
// An EPS whose page is its art's box, not its artboard: what prints outside it is compared too.
|
||||
let boxed = ai::eps(data.replace("%AI3_Cropmarks: 0 0 200 100", "%AI3_Cropmarks: -50 -50 300 200").as_bytes(), ai::page_ps());
|
||||
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
|
||||
|
|
@ -1884,3 +1917,127 @@ proptest! {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- font files a folder search reads ----------
|
||||
|
||||
/// A small font file holding what a folder search reads: an outline table's tag, the `name` and
|
||||
/// `OS/2` tables of the bundled Source Sans 3 renamed "Findme Sans 3", and an `fvar` table with a
|
||||
/// weight axis and three named instances (named by name ids 2, 1 and 4).
|
||||
fn search_font() -> Vec<u8> {
|
||||
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
|
||||
let be32 = |at: usize| u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) as usize;
|
||||
let table = |tag: &[u8; 4]| {
|
||||
let n = u16::from_be_bytes([font[4], font[5]]) as usize;
|
||||
let r = (0..n).map(|i| 12 + 16 * i).find(|&r| &font[r..r + 4] == tag).unwrap();
|
||||
font[be32(r + 8)..be32(r + 8) + be32(r + 12)].to_vec()
|
||||
};
|
||||
// fvar 1.0: the axis array at 16, one axis of 20 bytes, three instances of 8.
|
||||
let mut fvar = vec![];
|
||||
for v in [1u16, 0, 16, 2, 1, 20, 3, 8] {
|
||||
fvar.extend(v.to_be_bytes());
|
||||
}
|
||||
fvar.extend(b"wght");
|
||||
for v in [100i32, 400, 900] {
|
||||
fvar.extend((v << 16).to_be_bytes());
|
||||
}
|
||||
fvar.extend([0, 0, 1, 0]);
|
||||
for (name, weight) in [(2u16, 300i32), (1, 600), (4, 900)] {
|
||||
fvar.extend(name.to_be_bytes());
|
||||
fvar.extend(0u16.to_be_bytes());
|
||||
fvar.extend((weight << 16).to_be_bytes());
|
||||
}
|
||||
let tables: [(&[u8; 4], Vec<u8>); 4] = [(b"OS/2", table(b"OS/2")), (b"fvar", fvar), (b"glyf", vec![0; 4]), (b"name", table(b"name"))];
|
||||
let mut out = 0x0001_0000_u32.to_be_bytes().to_vec();
|
||||
for v in [tables.len() as u16, 0, 0, 0] {
|
||||
out.extend(v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
out.extend(*tag);
|
||||
for v in [0, offset as u32, data.len() as u32] {
|
||||
out.extend(v.to_be_bytes());
|
||||
}
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
out.extend(data);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `font` as a collection of `faces % 5` faces that all read it, whose header gives `faces` as the
|
||||
/// count.
|
||||
fn search_collection(font: &[u8], faces: u32) -> Vec<u8> {
|
||||
let k = (faces % 5) as usize;
|
||||
let base = 12 + 4 * k;
|
||||
let mut out = b"ttcf".to_vec();
|
||||
out.extend(0x0001_0000_u32.to_be_bytes());
|
||||
out.extend(faces.to_be_bytes());
|
||||
for _ in 0..k {
|
||||
out.extend((base as u32).to_be_bytes());
|
||||
}
|
||||
// The tables' offsets count from the start of the collection.
|
||||
let mut f = font.to_vec();
|
||||
let n = u16::from_be_bytes([f[4], f[5]]) as usize;
|
||||
for i in 0..n {
|
||||
let at = 12 + 16 * i + 8;
|
||||
let offset = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32;
|
||||
f[at..at + 4].copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
out.extend(f);
|
||||
out
|
||||
}
|
||||
|
||||
/// The fonts a search for "Findme Sans 3" looks for: the face, and a style of the family as if the
|
||||
/// family were installed.
|
||||
fn findme_wanted() -> vectorcraft_text::WantedFonts {
|
||||
use vectorcraft_text::WantedFont;
|
||||
vectorcraft_text::WantedFonts::new(&[
|
||||
WantedFont { family: "Findme Sans 3".into(), style: "Regular".into(), installed: None },
|
||||
WantedFont { family: "FINDME SANS 3".into(), style: "Black".into(), installed: Some("Findme Sans 3".into()) },
|
||||
WantedFont { family: "SourceSans3-Regular".into(), style: "Bold".into(), installed: None },
|
||||
])
|
||||
}
|
||||
|
||||
/// Whether reading `bytes` as a font file a search reads panics.
|
||||
fn search_reads(what: &str, bytes: &[u8]) -> Result<Vec<usize>, TestCaseError> {
|
||||
let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}")))
|
||||
}
|
||||
|
||||
/// The samples the fuzz properties mutate reach the matching: each provides the face it names.
|
||||
#[test]
|
||||
fn the_search_font_samples_are_read_whole() {
|
||||
let font = search_font();
|
||||
assert_eq!(search_reads("font", &font).unwrap(), [0, 2]);
|
||||
assert_eq!(search_reads("collection", &search_collection(&font, 3)).unwrap(), [0, 2]);
|
||||
// Two faces, and a header that gives seven.
|
||||
assert_eq!(search_reads("collection claiming more faces", &search_collection(&font, 7)).unwrap(), [0, 2]);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn font_files_a_search_reads_never_panic(
|
||||
faces in prop::option::of(any::<u32>()),
|
||||
cut in prop::option::of(0usize..12_000),
|
||||
edits in prop::collection::vec((0usize..12_000, any::<u8>()), 0..12),
|
||||
) {
|
||||
let font = search_font();
|
||||
let mut bytes = match faces {
|
||||
Some(n) => search_collection(&font, n),
|
||||
None => font,
|
||||
};
|
||||
for (at, b) in edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
if let Some(cut) = cut {
|
||||
bytes.truncate(cut);
|
||||
}
|
||||
search_reads("mutated font file", &bytes)?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
//! Selection command results and validation for agent callers (#784).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::{EngineError, Session};
|
||||
|
||||
fn session() -> (Session, 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": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
let a = rectangle(0);
|
||||
let b = rectangle(20);
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_commands_reject_invalid_ids_without_changing_state() {
|
||||
for command in ["select.set", "select.add", "select.toggle"] {
|
||||
let (mut s, a, b) = session();
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true), json!(u64::MAX)] {
|
||||
let selection = s.active().unwrap().selection.clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let journal = s.journal.clone();
|
||||
let error = s.execute(command, &json!({"ids": [b.0, invalid]})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "{command}: {error}");
|
||||
assert!(error.to_string().contains(&invalid.to_string()), "the error must identify {invalid}: {error}");
|
||||
assert_eq!(s.active().unwrap().selection, selection, "{command}: {invalid}");
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_commands_require_an_id_array() {
|
||||
for command in ["select.set", "select.add", "select.toggle"] {
|
||||
let (mut s, _, _) = session();
|
||||
for params in [json!({}), json!({"ids": null}), json!({"ids": 2}), json!({"ids": "2"}), json!({"ids": true})] {
|
||||
let selection = s.active().unwrap().selection.clone();
|
||||
assert!(matches!(s.execute(command, ¶ms), Err(EngineError::BadParams { .. })), "{command}: {params}");
|
||||
assert_eq!(s.active().unwrap().selection, selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_commands_report_the_resulting_selection() {
|
||||
let (mut s, a, b) = session();
|
||||
assert_eq!(s.execute("select.set", &json!({"ids": [b.0, a.0, b.0]})).unwrap(), json!({"count": 2, "ids": [b.0, a.0]}));
|
||||
assert_eq!(s.execute("select.set", &json!({"ids": []})).unwrap(), json!({"count": 0, "ids": []}));
|
||||
assert_eq!(s.execute("select.add", &json!({"ids": [a.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]}));
|
||||
assert_eq!(s.execute("select.add", &json!({"ids": [b.0]})).unwrap(), json!({"count": 2, "ids": [a.0, b.0]}));
|
||||
assert_eq!(s.execute("select.toggle", &json!({"id": a.0})).unwrap(), json!({"count": 1, "ids": [b.0]}));
|
||||
assert_eq!(s.execute("select.toggle", &json!({"ids": [b.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]}));
|
||||
for command in ["select.add", "select.toggle"] {
|
||||
assert_eq!(s.execute(command, &json!({"ids": []})).unwrap(), json!({"count": 1, "ids": [a.0]}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_id_toggle_validates_before_mutating() {
|
||||
let (mut s, a, _) = session();
|
||||
for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true)] {
|
||||
let selection = s.active().unwrap().selection.clone();
|
||||
assert!(matches!(s.execute("select.toggle", &json!({"id": invalid})), Err(EngineError::BadParams { .. })));
|
||||
assert_eq!(s.active().unwrap().selection, selection);
|
||||
}
|
||||
// A malformed array must not silently fall back to a valid single id.
|
||||
assert!(s.execute("select.toggle", &json!({"ids": "bad", "id": a.0})).is_err());
|
||||
}
|
||||
|
|
@ -311,6 +311,9 @@ const SKIPPED: &[(&str, &str)] = &[
|
|||
("Alternate_Content", "End_Versioned_Content"),
|
||||
];
|
||||
|
||||
/// The note for a `/ForeignObject`, art the editor shows but doesn't edit.
|
||||
pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet";
|
||||
|
||||
/// A section comment's marker without its `AI<version>_` prefix and value (`AI14_BeginSymbol` →
|
||||
/// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`.
|
||||
fn marker(c: &str) -> &str {
|
||||
|
|
@ -645,8 +648,13 @@ impl<'a> Reader<'a> {
|
|||
"," => {
|
||||
if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() {
|
||||
obj::add_entry(o, &mut self.stack, *base);
|
||||
// Binary data in ASCII85 follows, up to its `~>`.
|
||||
if o.ty == "Binary" && o.entries.last().is_some_and(|(k, _)| k.as_deref() == Some("ASCII85Decode")) {
|
||||
// Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's
|
||||
// (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s
|
||||
// `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line
|
||||
// of it may start with `%_` and read as hidden tokens: a `(` there would open
|
||||
// a string that swallows the rest of the layer.
|
||||
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
|
||||
if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) {
|
||||
self.lex.skip_past(b"~>");
|
||||
}
|
||||
}
|
||||
|
|
@ -826,6 +834,9 @@ impl<'a> Reader<'a> {
|
|||
"Document" => self.document(&o),
|
||||
"AI11Text" => self.text_slot(&o)?,
|
||||
"SymbolInstance" => self.unreadable("symbols"),
|
||||
// Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in
|
||||
// the dictionary, which nothing draws from yet, so the file opens without it.
|
||||
"ForeignObject" => self.warn(NON_NATIVE_ART),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -329,6 +329,18 @@ fn sections_it_skips() {
|
|||
assert_eq!(art(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
|
||||
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
|
||||
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
|
||||
/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note.
|
||||
#[test]
|
||||
fn non_native_art_is_skipped_with_its_ascii85_data() {
|
||||
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
|
||||
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
|
||||
assert_eq!(art(&s.doc).len(), 1);
|
||||
assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_documents() {
|
||||
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;
|
||||
|
|
|
|||
|
|
@ -166,8 +166,15 @@ fn reason(e: &PsError, fault: Option<&Fault>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read an EPS or PostScript file (its first page).
|
||||
/// Read an EPS or PostScript file (its first page), an Illustrator EPS through the editing copy of
|
||||
/// its art when it carries one (see `native`).
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import_with(bytes, true)
|
||||
}
|
||||
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
|
||||
if !ps.starts_with(b"%!") {
|
||||
return Err("this is not a PostScript file".into());
|
||||
|
|
@ -189,7 +196,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
|||
}
|
||||
let drew = !out.drawn.is_empty();
|
||||
let why = match (&result, drew) {
|
||||
(Ok(()), true) => return Ok(native::layered(ps, finish(out))),
|
||||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out))),
|
||||
(Ok(()), true) => return Ok(finish(out)),
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
//!
|
||||
//! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did
|
||||
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
|
||||
//! also the safety net: when the layers' art looks different from it, the page is used.
|
||||
//! also the safety net: when the layers' art looks different from it (one draws an object the
|
||||
//! other doesn't, or much of it differs; an EPS whose page is its art's box has nothing printed
|
||||
//! outside it), the page is used.
|
||||
//!
|
||||
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
|
||||
|
||||
|
|
@ -550,10 +552,49 @@ fn as_printed(doc: &Document) -> Option<Document> {
|
|||
hide(&mut d.layers).then_some(d)
|
||||
}
|
||||
|
||||
/// The lightness (0 to 255, Rec. 601 weights) of a premultiplied pixel seen on white.
|
||||
fn lightness(p: &[u8; 4]) -> u8 {
|
||||
let on_white = |c: u8| u32::from(c) + 255 - u32::from(p[3]).min(255);
|
||||
((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8
|
||||
/// A premultiplied pixel seen on white.
|
||||
fn on_white(p: &[u8; 4]) -> [u32; 3] {
|
||||
let paper = 255 - u32::from(p[3]).min(255);
|
||||
[0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255))
|
||||
}
|
||||
|
||||
/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white.
|
||||
fn lightness([r, g, b]: [u32; 3]) -> u32 {
|
||||
(r * 299 + g * 587 + b * 114) / 1000
|
||||
}
|
||||
|
||||
/// How far (0 to 255) a pixel seen on white is from white: how much ink it has.
|
||||
fn ink([r, g, b]: [u32; 3]) -> u32 {
|
||||
255 - r.min(g).min(b)
|
||||
}
|
||||
|
||||
/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look
|
||||
/// alike.
|
||||
const SAME_PIXEL: u32 = 48;
|
||||
/// The most ink a pixel seen as white has.
|
||||
const WHITE: u32 = 8;
|
||||
|
||||
/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other
|
||||
/// is white?
|
||||
fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
let (p, q) = (on_white(p), on_white(q));
|
||||
let (a, b) = (ink(p), ink(q));
|
||||
lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL)
|
||||
}
|
||||
|
||||
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
|
||||
/// those whose neighbours all differ too, away from type (where one draws an object that the other
|
||||
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
|
||||
/// differently: a line the page sets in pieces is laid out afresh as one).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Difference {
|
||||
any: f64,
|
||||
solid: f64,
|
||||
}
|
||||
|
||||
/// The scale the comparisons draw a region at: about 400 pixels along its longer side.
|
||||
fn compare_scale(r: Rect) -> f64 {
|
||||
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
|
||||
}
|
||||
|
||||
/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers
|
||||
|
|
@ -561,6 +602,8 @@ fn lightness(p: &[u8; 4]) -> u8 {
|
|||
/// can share their keys and not their pixels, so the two don't share one).
|
||||
struct PageView {
|
||||
page: vectorcraft_render::Rendered,
|
||||
/// Where the page's own type is, in its pixels.
|
||||
page_type: Vec<Rect>,
|
||||
layers: vectorcraft_render::Renderer,
|
||||
scale: f64,
|
||||
}
|
||||
|
|
@ -568,36 +611,118 @@ struct PageView {
|
|||
impl PageView {
|
||||
fn new(frame: &Frame<'_>) -> Self {
|
||||
let ra = frame.page_rect;
|
||||
let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0);
|
||||
let scale = compare_scale(ra);
|
||||
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
|
||||
// the app that wrote the file.
|
||||
let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false);
|
||||
Self { page, layers: vectorcraft_render::Renderer::new(), scale }
|
||||
let mut page_type = vec![];
|
||||
type_areas(&frame.page.layers, ra, scale, &mut page_type);
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale }
|
||||
}
|
||||
|
||||
/// How much `b` on `rb` (a rectangle the page's size) draws differently from the page (0 to 1),
|
||||
/// each seen on white. Pixels are compared by lightness: the page of an RGB document is written in
|
||||
/// the colours' CMYK equivalents, and what the comparison looks for is art that is missing or out
|
||||
/// of place.
|
||||
fn difference(&mut self, b: &Document, rb: Rect) -> f64 {
|
||||
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
|
||||
fn difference(&mut self, b: &Document, rb: Rect) -> Difference {
|
||||
// The page doesn't print what isn't printed.
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let (x, y) = (&self.page, self.layers.render_region(b, rb, self.scale, false));
|
||||
if x.width != y.width || x.height != y.height {
|
||||
return 1.0;
|
||||
}
|
||||
let differing =
|
||||
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
|
||||
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
let mut areas = self.page_type.clone();
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
}
|
||||
}
|
||||
|
||||
/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]),
|
||||
/// for a region other than the page's ([`PageView`] draws that one once).
|
||||
fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let scale = compare_scale(ra);
|
||||
// A renderer each: the two documents' images can share their keys and not their pixels.
|
||||
let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false);
|
||||
let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false);
|
||||
let mut areas = vec![];
|
||||
type_areas(&a.layers, ra, scale, &mut areas);
|
||||
type_areas(&b.layers, rb, scale, &mut areas);
|
||||
compared(&x, &y, &areas)
|
||||
}
|
||||
|
||||
/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from
|
||||
/// the type in `areas` (pixels). Pixels are compared by lightness, and by whether they are inked
|
||||
/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents,
|
||||
/// and what the comparison looks for is art that is missing or out of place.
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
|
||||
if x.width != y.width || x.height != y.height {
|
||||
return Difference { any: 1.0, solid: 1.0 };
|
||||
}
|
||||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false);
|
||||
let typed = covered(areas, w, h);
|
||||
// A pixel on the region's edge has neighbours outside it, which don't count as differing.
|
||||
let solid = (1..h.saturating_sub(1))
|
||||
.flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j)))
|
||||
.filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))))
|
||||
.count();
|
||||
let area = (w as f64 * h as f64).max(1.0);
|
||||
Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, solid: solid as f64 / area }
|
||||
}
|
||||
|
||||
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
|
||||
/// for the strokes round it).
|
||||
fn type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>) {
|
||||
for n in nodes.iter().filter(|n| n.visible) {
|
||||
if let NodeKind::Text(_) = n.kind
|
||||
&& let Some(b) = n.visual_bounds()
|
||||
{
|
||||
let px =
|
||||
|r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale);
|
||||
out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH)));
|
||||
} else if let Some(c) = n.children() {
|
||||
type_areas(c, region, scale, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of `w` × `h` pixels (row by row) lie in one of `areas`.
|
||||
fn covered(areas: &[Rect], w: usize, h: usize) -> Vec<bool> {
|
||||
// Each area adds one inside it: corners marked, then summed along the rows and down the columns.
|
||||
let stride = w + 1;
|
||||
let mut count = vec![0i64; stride * (h + 1)];
|
||||
let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 };
|
||||
for r in areas {
|
||||
let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h));
|
||||
for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] {
|
||||
if let Some(c) = count.get_mut(y * stride + x) {
|
||||
*c += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in count.chunks_mut(stride) {
|
||||
let mut run = 0;
|
||||
for c in row {
|
||||
run += *c;
|
||||
*c = run;
|
||||
}
|
||||
}
|
||||
let mut column = vec![0i64; stride];
|
||||
for row in count.chunks_mut(stride) {
|
||||
for (c, sum) in row.iter_mut().zip(column.iter_mut()) {
|
||||
*sum += *c;
|
||||
*c = *sum;
|
||||
}
|
||||
}
|
||||
(0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect()
|
||||
}
|
||||
|
||||
/// More of the page than this differing between the layers' art and the page's own, and the
|
||||
/// layers aren't used.
|
||||
const MAX_DIFFERENCE: f64 = 0.05;
|
||||
/// More than this and the import says so.
|
||||
const NOTABLE_DIFFERENCE: f64 = 0.002;
|
||||
/// More of the page than this where one draws an object that the other doesn't (see
|
||||
/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page.
|
||||
const MAX_SOLID_DIFFERENCE: f64 = 0.001;
|
||||
|
||||
/// Which box of the editing copy is the page.
|
||||
#[derive(Clone, Copy)]
|
||||
|
|
@ -616,13 +741,17 @@ struct Frame<'a> {
|
|||
page_rect: Rect,
|
||||
/// The same area in the layers' document.
|
||||
rect: Rect,
|
||||
/// The page holds all the art that prints (an EPS's page is its art's box, unless it was
|
||||
/// saved as its artboard): the layers print nothing outside it.
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
|
||||
/// file read as its page, put in its text objects → the document and its notes. Without a page
|
||||
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
|
||||
/// nothing is compared.
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Document, Vec<String>), String> {
|
||||
/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in
|
||||
/// the text objects.
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> Result<(Document, Vec<String>), String> {
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
|
||||
let (mut shown, mut hidden) = (vec![], vec![]);
|
||||
let mut frame = None;
|
||||
|
|
@ -648,10 +777,18 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Documen
|
|||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
frame = Some(Frame { page: visible, page_rect, rect });
|
||||
// An EPS saved as its artboard has the art outside it left out of its page.
|
||||
let artboard_page =
|
||||
artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0));
|
||||
let whole = matches!(page, Page::Art) && !artboard_page;
|
||||
frame = Some(Frame { page: visible, page_rect, rect, whole });
|
||||
}
|
||||
let mut infos = vec![];
|
||||
collect_slots(&doc.layers, true, &mut infos);
|
||||
// Type asked for as outlines is the page's: the text objects that show can't be filled.
|
||||
if outlined && infos.iter().any(|i| i.shown) {
|
||||
return Err("its type is to come in as outlines, which only its page has".into());
|
||||
}
|
||||
if infos.iter().all(|i| !i.shown) && !shown.is_empty() {
|
||||
return Err("its page has text, and its layers have no text objects to put it in".into());
|
||||
}
|
||||
|
|
@ -753,7 +890,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
|||
if found.is_empty() || found.len() > MAX_WEIGHED {
|
||||
return 0;
|
||||
}
|
||||
let mut with = view.difference(doc, rect);
|
||||
let mut with = view.difference(doc, rect).any;
|
||||
let mut removed = 0;
|
||||
for (id, bounds) in found {
|
||||
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
|
||||
|
|
@ -763,7 +900,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
|||
if !take_out(&mut without.layers, id, true) {
|
||||
continue;
|
||||
}
|
||||
let d = view.difference(&without, rect);
|
||||
let d = view.difference(&without, rect).any;
|
||||
if d - with <= UNSEEN_MARGIN {
|
||||
take_out(&mut doc.layers, id, false);
|
||||
with = d;
|
||||
|
|
@ -773,17 +910,30 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
|||
removed
|
||||
}
|
||||
|
||||
/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note
|
||||
/// to give when they differ a little.
|
||||
/// How far (points) the art the layers print may reach past a page that holds it all.
|
||||
const PAST_THE_PAGE: f64 = 1.0;
|
||||
|
||||
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
|
||||
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little.
|
||||
fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result<Option<String>, String> {
|
||||
let diff = view.difference(layered, frame.rect);
|
||||
if diff > MAX_DIFFERENCE {
|
||||
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
|
||||
let mut diff = view.difference(layered, frame.rect);
|
||||
if frame.whole
|
||||
&& let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered))
|
||||
.filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a))
|
||||
{
|
||||
// The page and the art the layers print beyond it, each in its own document's place.
|
||||
let all = frame.rect.union(art);
|
||||
let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0);
|
||||
let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all);
|
||||
diff = Difference { any: diff.any.max(round.any), solid: diff.solid.max(round.solid) };
|
||||
}
|
||||
Ok((diff > NOTABLE_DIFFERENCE).then(|| {
|
||||
if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE {
|
||||
return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0));
|
||||
}
|
||||
Ok((diff.any > NOTABLE_DIFFERENCE).then(|| {
|
||||
format!(
|
||||
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
|
||||
diff * 100.0
|
||||
diff.any * 100.0
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
|
@ -794,7 +944,7 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
|||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = ai::eps_data(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art));
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
|
|
@ -816,12 +966,14 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
|||
/// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the
|
||||
/// file, its artboards, and the art that lies outside them, which its PDF part doesn't have.
|
||||
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
||||
/// `outlined`: the PDF part's type was read as outlines (a file whose type shows then comes in as
|
||||
/// its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
|
||||
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
|
||||
return (visible, warnings);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard) {
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined) {
|
||||
Ok((done, notes)) => {
|
||||
let mut all = warnings;
|
||||
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
|
||||
|
|
@ -839,7 +991,7 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
|
|||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
let data = ai::decode_private(private)?;
|
||||
build(&data, None, Page::Artboard)
|
||||
build(&data, None, Page::Artboard, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ fn pdf_part(x: f64, y: f64) -> Document {
|
|||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()]);
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
|
||||
assert_eq!(warnings, ["a note"], "nothing else to say");
|
||||
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
|
|
@ -227,12 +227,12 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
|||
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
// No editing data this reads.
|
||||
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![]);
|
||||
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
|
||||
assert_eq!(d.layers.len(), pdf.layers.len());
|
||||
assert!(w.is_empty());
|
||||
// Editing data that isn't for this page.
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![]);
|
||||
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
|
@ -280,7 +280,7 @@ fn custom_colours_open_paths_and_planar_group_marks_are_read() {
|
|||
let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\
|
||||
0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF";
|
||||
let r = import(&older_eps(
|
||||
"0 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill",
|
||||
"0.1 0.2 0.3 0.4 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill 0.8 0.1 0 0 setcmykcolor 20 20 moveto 24 20 lineto 24 24 lineto closepath fill",
|
||||
&native(&layer("Art", true, body)),
|
||||
))
|
||||
.unwrap();
|
||||
|
|
@ -399,9 +399,9 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
|
|||
fn a_text_object_written_as_comments_is_read_like_any_other() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let art = native_with_text(&layer("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &commented)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[0]);
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
|
||||
assert_eq!(texts[0].plain_text(), "Hi");
|
||||
}
|
||||
|
|
@ -453,9 +453,9 @@ fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_objec
|
|||
let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() };
|
||||
let body = text_object(0) + &commented(0) + &commented(1);
|
||||
let art = native_with_text(&layer("Spare", false, &body), &doc);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &body)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts: Vec<String> = text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect();
|
||||
let texts: Vec<String> = text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect();
|
||||
assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
|
|
@ -542,3 +542,67 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
|||
assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}");
|
||||
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
|
||||
let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra)));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing
|
||||
/// `page`, with the editing copy of `layers`.
|
||||
fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec<u8> {
|
||||
let [x0, y0, x1, y1] = art_box;
|
||||
let native = native(layers).replace(
|
||||
"%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100",
|
||||
&format!("%%BoundingBox: {x0} {y0} {x1} {y1}\n%%HiResBoundingBox: {x0} {y0} {x1} {y1}"),
|
||||
);
|
||||
String::from_utf8(eps(page, &native))
|
||||
.unwrap()
|
||||
.replacen("%%BoundingBox: 0 0 100 100", &format!("%%BoundingBox: {x0} {y0} {x1} {y1}"), 1)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_page() {
|
||||
// The page is the box of the art it prints, round the square at (10, 10).
|
||||
let file = |far: &str| eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + far));
|
||||
// A square the layers print far from it: the page would have it.
|
||||
let r = import(&file(&layer("Far", true, &square(80, 80)))).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
|
||||
// Hidden, or on a layer that doesn't print, the page doesn't have it.
|
||||
let r = import(&file(&layer("Far", false, &square(80, 80)))).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
|
||||
let not_printed = format!("%AI5_BeginLayer\n1 1 1 0 0 0 1 0 79 128 255 0 50 0 Lb\n(Notes) Ln\n{}\nLB\n%AI5_EndLayer--\n", square(80, 80));
|
||||
let r = import(&file(¬_printed)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Notes"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
|
||||
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
|
||||
let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Scraps", true, &square(120, 10))));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Scraps"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
|
||||
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
|
||||
let (d, w) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
|
||||
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
|
||||
// Type only on a hidden layer: the layers come in.
|
||||
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
|
||||
let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
|
||||
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ mod ps;
|
|||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, ai_alone, family_style, import, is_loss, layered_ai};
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
|
||||
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
|
||||
//! [`write_new_with`] creates a file the same way without ever replacing one.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
|
|
@ -26,6 +27,48 @@ pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result
|
|||
})
|
||||
}
|
||||
|
||||
/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The
|
||||
/// content goes to a temporary file beside it, which is then linked in under `path`
|
||||
/// (`std::fs::hard_link`, which fails with `AlreadyExists` when the name is taken, also when it was
|
||||
/// taken meanwhile). The file appears whole or not at all. Where hard links fail for another
|
||||
/// reason, `path` is created with `create_new` and the content copied in; a failed copy removes
|
||||
/// it. The temporary file is always removed.
|
||||
pub fn write_new_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
write_new_linking(path, fill, |tmp, path| std::fs::hard_link(tmp, path))
|
||||
}
|
||||
|
||||
/// [`write_new_with`], with `link` linking the temporary file in under `path`.
|
||||
fn write_new_linking(
|
||||
path: &Path,
|
||||
fill: impl FnOnce(&mut File) -> io::Result<()>,
|
||||
link: impl FnOnce(&Path, &Path) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let tmp = temp_path(path)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
drop(f);
|
||||
let placed = filled.and_then(|()| match link(&tmp, path) {
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path),
|
||||
linked => linked,
|
||||
});
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
placed
|
||||
}
|
||||
|
||||
/// Create `path` (never replacing anything) with the content of the file `from`; a failed copy
|
||||
/// removes it again.
|
||||
fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
|
||||
let mut to = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all());
|
||||
drop(to);
|
||||
if copied.is_err() {
|
||||
// Best effort: the part copied is all there is to clean up.
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
copied
|
||||
}
|
||||
|
||||
/// A symbolic link keeps pointing at its file: write the file it names.
|
||||
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
|
|
@ -93,4 +136,64 @@ mod tests {
|
|||
assert!(write_atomic(&d.join("missing").join("x.vectorcraft"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_new_never_replaces_what_is_there() {
|
||||
let d = dir("new");
|
||||
let path = d.join("a.otf");
|
||||
write_new_with(&path, |f| f.write_all(b"first")).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
assert!(leftovers(&d, "a.otf").is_empty(), "the temporary file is removed");
|
||||
let r = write_new_with(&path, |f| f.write_all(b"second"));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
assert!(leftovers(&d, "a.otf").is_empty());
|
||||
// A failed write leaves nothing.
|
||||
let failed = d.join("b.otf");
|
||||
assert!(write_new_with(&failed, |f| f.write_all(b"half").and(Err(io::Error::other("disk full")))).is_err());
|
||||
assert!(!failed.exists() && leftovers(&d, "a.otf").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Where hard links fail (a file system without them), the content is copied in, and a taken
|
||||
/// name stays as it was. A link that fails because the name is taken copies nothing.
|
||||
#[test]
|
||||
fn write_new_copies_where_hard_links_fail() {
|
||||
let d = dir("new-copy");
|
||||
let unsupported = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::Unsupported));
|
||||
let path = d.join("c.otf");
|
||||
write_new_linking(&path, |f| f.write_all(b"copied"), unsupported).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"copied");
|
||||
assert!(leftovers(&d, "c.otf").is_empty(), "the temporary file is removed");
|
||||
let r = write_new_linking(&path, |f| f.write_all(b"second"), unsupported);
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"copied");
|
||||
let taken = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::AlreadyExists));
|
||||
let other = d.join("d.otf");
|
||||
let r = write_new_linking(&other, |f| f.write_all(b"other"), taken);
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
|
||||
assert!(!other.exists() && leftovers(&d, "c.otf").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A link of that name, even one to nothing, is never replaced or written through.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_new_leaves_links_alone() {
|
||||
let d = dir("new-links");
|
||||
let target = d.join("target.otf");
|
||||
std::fs::write(&target, b"target").unwrap();
|
||||
std::os::unix::fs::symlink(&target, d.join("link.otf")).unwrap();
|
||||
std::os::unix::fs::symlink(d.join("nothing.otf"), d.join("dangling.otf")).unwrap();
|
||||
for name in ["link.otf", "dangling.otf"] {
|
||||
let r = write_new_with(&d.join(name), |f| f.write_all(b"new"));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists), "{name}");
|
||||
}
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"target");
|
||||
assert!(!d.join("nothing.otf").exists());
|
||||
let mut names: Vec<String> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["dangling.otf", "link.otf", "target.otf"], "no temporary file is left");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ use serde::{Deserialize, Serialize};
|
|||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{write_atomic, write_atomic_with};
|
||||
pub use atomic::{write_atomic, write_atomic_with, write_new_with};
|
||||
|
||||
/// v4: large data after the JSON (v1 to v3 files still load).
|
||||
pub const VERSION: u32 = 4;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,50 @@ pub fn ellipse(r: Rect) -> PathData {
|
|||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured
|
||||
/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then
|
||||
/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`].
|
||||
/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors.
|
||||
pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
|
||||
let span = (end - start).rem_euclid(360.0);
|
||||
if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 {
|
||||
return ellipse(r);
|
||||
}
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
|
||||
// Screen y runs down: counterclockwise on screen is a falling y.
|
||||
let at = |deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
Point::new(c.x + rx * t.cos(), c.y - ry * t.sin())
|
||||
};
|
||||
let tangent = |deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
Vec2::new(-rx * t.sin(), -ry * t.cos())
|
||||
};
|
||||
let pieces = if span <= 90.0 {
|
||||
1
|
||||
} else if span <= 180.0 {
|
||||
2
|
||||
} else if span <= 270.0 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
};
|
||||
let step = span / f64::from(pieces);
|
||||
let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
|
||||
let mut anchors = vec![Anchor::corner(c)];
|
||||
for i in 0..=pieces {
|
||||
let a = start + step * f64::from(i);
|
||||
let (p, d) = (at(a), tangent(a) * k);
|
||||
let h_in = if i == 0 { p } else { p - d };
|
||||
let h_out = if i == pieces { p } else { p + d };
|
||||
let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth };
|
||||
anchors.push(Anchor { p, h_in, h_out, kind });
|
||||
}
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
|
||||
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
|
||||
let n = sides.max(3);
|
||||
|
|
@ -207,6 +251,27 @@ mod tests {
|
|||
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
|
||||
}
|
||||
|
||||
/// A pie is the arc from its start to its end angle, counterclockwise on screen, closed through
|
||||
/// the centre; a whole turn (or none) is the ellipse.
|
||||
#[test]
|
||||
fn a_pie_is_its_arc_closed_through_the_centre() {
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
assert_eq!(ellipse_pie(r, 0.0, 360.0), ellipse(r));
|
||||
assert_eq!(ellipse_pie(r, 90.0, 90.0), ellipse(r));
|
||||
// A quarter, from 3 o'clock up to 12: the centre, then the arc's two ends.
|
||||
let q = ellipse_pie(r, 0.0, 90.0);
|
||||
let a: Vec<Point> = q.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(a.len(), 3);
|
||||
assert!(a[0].distance(Point::new(100.0, 50.0)) < 1e-9 && a[1].distance(Point::new(200.0, 50.0)) < 1e-9);
|
||||
assert!(a[2].distance(Point::new(100.0, 0.0)) < 1e-9, "counterclockwise on screen: up");
|
||||
assert_eq!(q.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((100.0, 0.0, 200.0, 50.0)));
|
||||
// Three quarters, from 12 round to 3 o'clock: four pieces at most, the whole box covered.
|
||||
let big = ellipse_pie(r, 90.0, 0.0);
|
||||
assert!(big.subpaths[0].anchors.len() <= 6 && big.subpaths[0].closed);
|
||||
assert_eq!(big.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((0.0, 0.0, 200.0, 100.0)));
|
||||
assert!(ellipse_pie(r, f64::NAN, 10.0).anchor_count() == 4, "not a number: the whole ellipse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_is_4_corners_clockwise() {
|
||||
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,9 @@ impl Exporter<'_> {
|
|||
let (below, above) = n.appearance.split_contents();
|
||||
let all = (!n.appearance.items.is_empty()).then(|| {
|
||||
let mut all = layout.to_bezpath();
|
||||
for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) {
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
all.apply_affine(t.xf);
|
||||
to_path(&all).map(|p| (all, p))
|
||||
});
|
||||
|
|
@ -1314,16 +1317,27 @@ impl Exporter<'_> {
|
|||
s.push_transform(&xf(t.xf));
|
||||
// Real text: the characters' fills as text in embedded fonts.
|
||||
let plain = (!self.outline_text).then(|| t.plain_text());
|
||||
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
// Underline and strikethrough bars are paths, with real text too (#847).
|
||||
let mut run_bars = BezPath::new();
|
||||
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
|
||||
run_bars.extend(bar.iter());
|
||||
}
|
||||
bp.extend(run_bars.iter());
|
||||
let fill = &run.style.fill;
|
||||
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
|
||||
// The outlines left to fill as paths: all of them, or those real text leaves.
|
||||
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
|
||||
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
|
||||
Some(text) => {
|
||||
let mut left = ex.glyph_text(s, &layout, i, text, fill);
|
||||
left.extend(run_bars.iter());
|
||||
Cow::Owned(left)
|
||||
}
|
||||
None => Cow::Borrowed(&bp),
|
||||
};
|
||||
let opaque = (1.0, BlendMode::Normal);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ fn group_layer(
|
|||
pub const OFF_ARTBOARD_NOTE: &str =
|
||||
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
|
||||
|
||||
/// Warning: art reaching past the page box was clipped to it.
|
||||
pub(crate) const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it";
|
||||
|
||||
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
|
||||
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
|
||||
let original = crate::pages::open(bytes, opts.password.as_deref())?;
|
||||
|
|
@ -227,6 +230,29 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
}
|
||||
parts.retain(|(_, art)| !art.is_empty());
|
||||
}
|
||||
// Viewers show a page through its crop box, so art reaching past it (a mock-up backdrop
|
||||
// around the page, say) never showed. A clip to the page box that the file states is
|
||||
// dropped as redundant (by hayro and by `push_clip_path`), so restore the box here:
|
||||
// each object reaching past it keeps its own clip, which Fit to Artwork Bounds and
|
||||
// `CropTo::Bounding` then respect.
|
||||
// The crop box is what viewers show, whichever box the artboard takes. Not in a
|
||||
// PDF-compatible `.ai`: its PDF part stands in for the editable document, whose art
|
||||
// crossing the artboard edge isn't clipped, so it opens whole (#646).
|
||||
let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop));
|
||||
let page_box = shown.inflate(0.01, 0.01);
|
||||
let mut clipped = false;
|
||||
for (_, art) in parts.iter_mut().filter(|_| !ai) {
|
||||
for n in art.iter_mut() {
|
||||
if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) {
|
||||
let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero));
|
||||
*n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true }));
|
||||
clipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if clipped {
|
||||
b.warn(PAST_PAGE_NOTE);
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= ai && only_text(&children);
|
||||
let mut right = ab.x1;
|
||||
|
|
@ -460,8 +486,12 @@ fn matching_face(
|
|||
return Some(face.clone());
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let others =
|
||||
// Other installed versions of its family and style (a font library keeps old ones), then the
|
||||
// family's other styles.
|
||||
let versions = db.versions(face).into_iter().skip(1).take(16);
|
||||
let styles =
|
||||
db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s));
|
||||
let others = versions.chain(styles);
|
||||
let mut best = own.map(|d| (d, face.clone()));
|
||||
for f in others {
|
||||
if let Some(d) = glyph_deviation(&f, text, o, vertical)
|
||||
|
|
@ -636,6 +666,9 @@ struct Builder<'p> {
|
|||
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
|
||||
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
|
||||
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
|
||||
/// Per font: the installed version its type names, when it isn't the one its family and style
|
||||
/// resolve to (see `CharStyle::font_version`).
|
||||
versions: HashMap<u128, Option<String>>,
|
||||
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
masked: Vec<Arc<Node>>,
|
||||
|
|
@ -784,6 +817,7 @@ impl<'p> Builder<'p> {
|
|||
cid_text: HashMap::new(),
|
||||
font_chars: HashMap::new(),
|
||||
matched: HashMap::new(),
|
||||
versions: HashMap::new(),
|
||||
mask: None,
|
||||
masked: vec![],
|
||||
masks: HashMap::new(),
|
||||
|
|
@ -1356,12 +1390,25 @@ impl<'p> Builder<'p> {
|
|||
};
|
||||
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
|
||||
}
|
||||
// A version of the family and style that the two alone don't resolve to: the type names it.
|
||||
// Kept per font once its face is decided (until then each glyph asks).
|
||||
let named = |f: &Option<Arc<vectorcraft_text::FontFace>>| {
|
||||
let f = f.as_ref()?;
|
||||
let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?;
|
||||
(default.version != f.version).then(|| f.version.clone())
|
||||
};
|
||||
let version = match self.versions.get(&key) {
|
||||
Some(v) => v.clone(),
|
||||
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
|
||||
None => named(&info.face),
|
||||
};
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
let look = Look {
|
||||
font: key,
|
||||
family: info.family,
|
||||
style: info.style,
|
||||
version,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
fill: stroke.is_none().then_some(paint),
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ pub(crate) struct Look {
|
|||
pub font: u128,
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
/// The installed version of the font when it isn't the one family and style resolve to.
|
||||
pub version: Option<String>,
|
||||
pub size: f64,
|
||||
pub h_scale: f64,
|
||||
pub fill: Option<Paint>,
|
||||
|
|
@ -97,6 +99,7 @@ impl Look {
|
|||
CharStyle {
|
||||
font_family: self.family.clone(),
|
||||
font_style: self.style.clone(),
|
||||
font_version: self.version.clone(),
|
||||
size: round(self.size),
|
||||
h_scale: round(self.h_scale),
|
||||
fill: self.fill.clone().unwrap_or(Paint::None),
|
||||
|
|
|
|||
|
|
@ -446,3 +446,21 @@ fn a_stroke_under_a_stretched_transform_stays_a_stroke() {
|
|||
let b = objs[0].geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_past_the_page_clip_keeps_the_clip() {
|
||||
// A page-sized clip, then a backdrop far larger than the page and a square on it: the square
|
||||
// needs no clip, the backdrop keeps it, so the art's bounds stay on the page.
|
||||
let r = open_with(&one_page("0 0 100 100 re W n 0 g -500 -500 1100 1100 re f 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {});
|
||||
let d = &r.document;
|
||||
let art = d.art_bounds().expect("art");
|
||||
assert!(art.x0 >= -0.01 && art.y0 >= -0.01 && art.x1 <= 100.01 && art.y1 <= 100.01, "the art stays on the page: {art:?}");
|
||||
let clips: Vec<Node> = all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||||
let [c] = clips.as_slice() else { panic!("one clip group, the backdrop's: {clips:?}") };
|
||||
assert_eq!(c.children().map(Vec::len), Some(2), "the clip path and the backdrop");
|
||||
assert!(r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
|
||||
// Art inside the page gets no clip and no warning.
|
||||
let r = open_with(&one_page("0 0 100 100 re W n 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {});
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,6 +367,25 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
|
|||
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Art reaching past the page keeps a clip to it in a plain PDF (as viewers show it), but not in
|
||||
/// a PDF-compatible `.ai`, whose art crossing the artboard edge opens whole and editable (#646).
|
||||
#[test]
|
||||
fn art_past_the_page_is_clipped_in_a_pdf_but_not_in_an_ai_file() {
|
||||
for ai in [false, true] {
|
||||
let page = PdfPage {
|
||||
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
|
||||
..PdfPage::new(100.0, 100.0, "0 0 100 100 re W n 1 0 0 rg 50 50 200 200 re f")
|
||||
};
|
||||
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
|
||||
let mut clips = 0;
|
||||
r.document.walk(|n| clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
let past = r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE);
|
||||
assert_eq!((clips, past), if ai { (0, false) } else { (1, true) }, "ai: {ai}: {:?}", r.warnings);
|
||||
let art = r.document.art_bounds().unwrap();
|
||||
assert_eq!(art.x1 > 100.5, ai, "ai: {ai}: {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order.
|
||||
#[test]
|
||||
fn an_ai_files_private_data_is_its_streams_joined() {
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
|||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
version: None,
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
|
|
@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
|||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
version: None,
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
|
|
|
|||
|
|
@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() {
|
|||
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type set in an installed font's older version (a library keeps both, one name) reopens as type
|
||||
/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type
|
||||
/// names the version, which the family and style alone don't pick.
|
||||
#[test]
|
||||
fn type_in_another_installed_version_reopens_in_that_version() {
|
||||
use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font};
|
||||
// The newer version first: the one the family and style resolve to.
|
||||
FontDb::global().add_font(twin_font(true).unwrap());
|
||||
FontDb::global().add_font(twin_font(false).unwrap());
|
||||
for version in [None, Some(TWIN_VERSIONS[0])] {
|
||||
let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||||
let mut found = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
found.push((t.plain_text(), t.first_style().font_version.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ impl RasterExportOptions {
|
|||
self.ppi / 72.0
|
||||
}
|
||||
|
||||
/// What the renderer draws for `format`: template layers left out, over the background
|
||||
/// What the renderer draws for `format`: template layers and guides left out, over the background
|
||||
/// (white for a JPEG or a flat PSD without one).
|
||||
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
|
||||
let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers);
|
||||
|
|
@ -193,7 +193,7 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
|
|||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(c)))
|
||||
}
|
||||
_ if fx::has_fx(n) => fx::visual_bounds(n),
|
||||
|
|
|
|||
|
|
@ -189,3 +189,35 @@ fn art_bounds_cover_visible_art_only() {
|
|||
let empty = Document::new(10.0, 10.0);
|
||||
assert_eq!(art_bounds(&empty), None);
|
||||
}
|
||||
|
||||
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
|
||||
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
if let NodeKind::Path { guide: g, .. } = &mut n.kind {
|
||||
*g = guide;
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_dont_count_in_the_arts_bounds() {
|
||||
let mut d = circle_doc();
|
||||
add_rect(&mut d, Rect::new(0.0, 15.0, 400.0, 15.0), true);
|
||||
let b = art_bounds(&d).unwrap();
|
||||
assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6, "the guide doesn't count: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_bounds_end_at_a_clipping_layers_clip() {
|
||||
let mut d = Document::new(40.0, 30.0);
|
||||
// The layer clips its art to its first object (added last): what lies beyond isn't drawn.
|
||||
add_rect(&mut d, Rect::new(-100.0, -100.0, 200.0, 200.0), false);
|
||||
add_rect(&mut d, Rect::new(2.0, 3.0, 20.0, 10.0), false);
|
||||
let layer = std::sync::Arc::make_mut(&mut d.layers[0]);
|
||||
if let NodeKind::Layer { clip, .. } = &mut layer.kind {
|
||||
*clip = true;
|
||||
}
|
||||
assert_eq!(art_bounds(&d), Some(Rect::new(2.0, 3.0, 20.0, 10.0)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ impl Renderer {
|
|||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
|
||||
/// as exported: template layers are left out.
|
||||
/// as exported: template layers and guides are left out.
|
||||
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
|
||||
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() };
|
||||
self.render_region_with(doc, region, scale, &opts)
|
||||
|
|
@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
|||
.runs
|
||||
.iter()
|
||||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||||
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
|
||||
// The version the type names, when it's installed (see `FontDb::face_version`).
|
||||
Some((f, m)) => {
|
||||
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
|
||||
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
|
||||
}
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -1676,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
|||
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
|
||||
target.extend(cell.iter());
|
||||
}
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
if let Some(r) = runs.get_mut(run) {
|
||||
r.extend(bar.iter());
|
||||
}
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1638,8 +1638,13 @@ impl Writer<'_> {
|
|||
let a = self.attrs(&css::transparency(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let mut all = lay.to_bezpath();
|
||||
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
let pd = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
|
|
@ -1671,12 +1676,15 @@ impl Writer<'_> {
|
|||
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
|
||||
/// the whole text, as on the canvas).
|
||||
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
|
||||
for g in &lay.glyphs {
|
||||
match runs.last_mut() {
|
||||
Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()),
|
||||
_ => runs.push((g.run, g.outline.clone())),
|
||||
// Underline and strikethrough bars join their run's outlines.
|
||||
let bars = vectorcraft_text::decorations(&lay, db, t);
|
||||
for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) {
|
||||
match runs.iter_mut().find(|(r, _)| *r == run) {
|
||||
Some((_, bp)) => bp.extend(outline.iter()),
|
||||
None => runs.push((run, outline.clone())),
|
||||
}
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
//! Font files for tests, made from the bundled fonts when a test runs: no font file is committed.
|
||||
|
||||
/// The bundled Source Sans 3 Regular with its family renamed `family` in every record of its name
|
||||
/// table, UTF-16 and ASCII alike. `family` must be as long as "Source Sans 3" (13 bytes). The
|
||||
/// PostScript name stays `SourceSans3-Regular`.
|
||||
pub fn renamed(family: &str) -> Vec<u8> {
|
||||
const ORIGINAL: &str = "Source Sans 3";
|
||||
assert_eq!(family.len(), ORIGINAL.len(), "`{family}` must be 13 bytes long");
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let mut data = std::fs::read(path).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16(ORIGINAL), utf16(family)), (ORIGINAL.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// A resource fork holding `fonts` as `sfnt` resources and one other resource (a `vers`), as a font
|
||||
/// utility writes a suitcase font's fork, and as vectorcraft-text's suitcase tests build one. macOS
|
||||
/// keeps a file's resource fork at `<file>/..namedfork/rsrc`.
|
||||
pub fn suitcase_fork(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) {
|
||||
offsets.push(data.len() as u32);
|
||||
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
data.extend_from_slice(body);
|
||||
}
|
||||
let n = fonts.len();
|
||||
// The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references.
|
||||
let list = 2 + 2 * 8;
|
||||
let mut types = Vec::new();
|
||||
types.extend_from_slice(&1u16.to_be_bytes());
|
||||
types.extend_from_slice(b"vers");
|
||||
types.extend_from_slice(&0u16.to_be_bytes());
|
||||
types.extend_from_slice(&(list as u16).to_be_bytes());
|
||||
types.extend_from_slice(b"sfnt");
|
||||
types.extend_from_slice(&((n - 1) as u16).to_be_bytes());
|
||||
types.extend_from_slice(&((list + 12) as u16).to_be_bytes());
|
||||
let reference = |id: i16, offset: u32| {
|
||||
let mut r = Vec::new();
|
||||
r.extend_from_slice(&id.to_be_bytes());
|
||||
r.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||||
r.extend_from_slice(&offset.to_be_bytes());
|
||||
r.extend_from_slice(&[0; 4]);
|
||||
r
|
||||
};
|
||||
types.extend(reference(1, offsets[n]));
|
||||
for (i, o) in offsets[..n].iter().enumerate() {
|
||||
types.extend(reference(5000 + i as i16, *o));
|
||||
}
|
||||
// The map: a copy of the header, a handle, attributes, then the offsets of the lists.
|
||||
let mut map = vec![0; 24];
|
||||
map.extend_from_slice(&28u16.to_be_bytes());
|
||||
map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes());
|
||||
map.extend(types);
|
||||
let mut fork = Vec::new();
|
||||
for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] {
|
||||
fork.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fork.resize(256, 0);
|
||||
fork.extend(data);
|
||||
fork.extend(map);
|
||||
fork
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
|
||||
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
|
||||
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
|
||||
//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests.
|
||||
//!
|
||||
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
|
||||
// Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
pub mod ai;
|
||||
pub mod ase;
|
||||
pub mod fixtures;
|
||||
pub mod fonts;
|
||||
pub mod geom;
|
||||
pub mod invariants;
|
||||
pub mod pdf;
|
||||
|
|
|
|||
|
|
@ -7,25 +7,49 @@
|
|||
//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no
|
||||
//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file
|
||||
//! limits (Cloudflare Pages: 25 MiB).
|
||||
//!
|
||||
//! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from
|
||||
//! `fonts/<sha16>/` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them
|
||||
//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left
|
||||
//! out with a warning, never an error, and never costs the desktop its fonts.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[path = "build/web_fonts.rs"]
|
||||
mod web_fonts;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
println!("cargo::rerun-if-changed=web-fonts.txt");
|
||||
println!("cargo::rerun-if-changed=build/web_fonts.rs");
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let (mut fonts, mut web) = (String::new(), String::new());
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) {
|
||||
let dir = from_workspace(dir);
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Ok(entries) => fonts = entries,
|
||||
Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
// Separately: a bad web list must not cost the desktop build its craft fonts. A listed face
|
||||
// the checkout lacks (an older craft-fonts pin) is left out with a warning, never an error,
|
||||
// so the apps don't depend on which craft-fonts commit CI pins: that build just fetches
|
||||
// fewer fonts.
|
||||
match web_font_entries(&dir) {
|
||||
Ok((entries, missing)) => {
|
||||
web = entries;
|
||||
if !missing.is_empty() {
|
||||
let msg = format!("{} not in CRAFT_FONTS_DIR={} (an older craft-fonts?)", missing.join(", "), dir.display());
|
||||
println!("cargo::warning=web build won't fetch: {msg}");
|
||||
}
|
||||
}
|
||||
Err(e) if required => println!("cargo::error=web-fonts.txt: {e}"),
|
||||
Err(e) => println!("cargo::warning=web build fetches no craft fonts: web-fonts.txt: {e}"),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
|
|
@ -72,3 +96,37 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
|
|||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One `WebFont { .. }` initialiser per `web-fonts.txt` line, from its craft-fonts manifest row, and
|
||||
/// the listed faces the manifest lacks (an older craft-fonts checkout), which are left out.
|
||||
fn web_font_entries(dir: &Path) -> Result<(String, Vec<String>), String> {
|
||||
let list_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("web-fonts.txt");
|
||||
let list = std::fs::read_to_string(&list_path).map_err(|e| format!("{}: {e}", list_path.display()))?;
|
||||
let manifest_path = dir.join("fonts/manifest.txt");
|
||||
// WEB_FONTS follows the manifest (URLs and hashes), on every target.
|
||||
println!("cargo::rerun-if-changed={}", manifest_path.display());
|
||||
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
let rows: Vec<Vec<&str>> =
|
||||
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
|
||||
let (mut out, mut missing) = (String::new(), Vec::new());
|
||||
for e in web_fonts::parse_list(&list)? {
|
||||
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
|
||||
missing.push(format!("{} {}", e.family, e.style));
|
||||
continue;
|
||||
};
|
||||
let (Some(file), Some(sha)) = (row.get(2), row.get(6)) else {
|
||||
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
|
||||
};
|
||||
let integrity = web_fonts::sri(sha).ok_or_else(|| format!("bad sha256 for {} {}", e.family, e.style))?;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" WebFont {{ family: {:?}, style: {:?}, startup: {}, url: {:?}, integrity: {:?} }},",
|
||||
e.family,
|
||||
e.style,
|
||||
e.startup,
|
||||
web_fonts::url(sha, file),
|
||||
integrity,
|
||||
);
|
||||
}
|
||||
Ok((out, missing))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
//! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the
|
||||
//! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included
|
||||
//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`].
|
||||
|
||||
/// One line of `web-fonts.txt`: `when | family | style`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Listed<'a> {
|
||||
/// `startup` (fetched before the app starts) rather than `background`.
|
||||
pub startup: bool,
|
||||
pub family: &'a str,
|
||||
pub style: &'a str,
|
||||
}
|
||||
|
||||
/// Parse `web-fonts.txt`. Blank lines and `#` comments are skipped; line numbers are 1-based.
|
||||
pub fn parse_list(text: &str) -> Result<Vec<Listed<'_>>, String> {
|
||||
let mut out = Vec::new();
|
||||
for (i, raw) in text.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [when, family, style] = fields.as_slice() else {
|
||||
return Err(format!("web-fonts.txt line {}: expected `when | family | style`", i + 1));
|
||||
};
|
||||
let startup = match *when {
|
||||
"startup" => true,
|
||||
"background" => false,
|
||||
other => return Err(format!("web-fonts.txt line {}: `{other}` is neither startup nor background", i + 1)),
|
||||
};
|
||||
if family.is_empty() || style.is_empty() {
|
||||
return Err(format!("web-fonts.txt line {}: empty family or style", i + 1));
|
||||
}
|
||||
out.push(Listed { startup, family, style });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding).
|
||||
pub fn base64(data: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0));
|
||||
let n = (b(0) << 16) | (b(1) << 8) | b(2);
|
||||
for k in 0..4 {
|
||||
if k <= chunk.len() {
|
||||
let six = ((n >> (18 - 6 * k)) & 63) as usize;
|
||||
s.push(TABLE.get(six).map_or('=', |&c| char::from(c)));
|
||||
} else {
|
||||
s.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The Subresource Integrity value (`sha256-<base64>`) for a 64-digit hex SHA-256; `None` if `hex`
|
||||
/// isn't one.
|
||||
pub fn sri(hex: &str) -> Option<String> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let bytes: Option<Vec<u8>> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
|
||||
Some(format!("sha256-{}", base64(&bytes?)))
|
||||
}
|
||||
|
||||
/// The site-relative URL a face is served at: `fonts/<first 16 hex digits of its SHA-256>/<file
|
||||
/// name>`. Relative, so the site works under any path; content-addressed, so it can be cached
|
||||
/// forever.
|
||||
pub fn url(sha256: &str, file: &str) -> String {
|
||||
let name = file.rsplit('/').next().unwrap_or(file);
|
||||
format!("fonts/{}/{name}", sha256.get(..16).unwrap_or(sha256))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648_vectors() {
|
||||
for (input, want) in
|
||||
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
|
||||
{
|
||||
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sri_of_the_empty_files_sha256() {
|
||||
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
assert_eq!(sri(empty).as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="));
|
||||
assert_eq!(sri("abc"), None);
|
||||
assert_eq!(sri(&"zz".repeat(32)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_is_relative_and_content_addressed() {
|
||||
let sha = "63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e";
|
||||
assert_eq!(url(sha, "fonts/noto-sans-arabic/NotoSansArabic.ttf"), "fonts/63111b5b2e074dd4/NotoSansArabic.ttf");
|
||||
assert!(!url(sha, "x.ttf").starts_with('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_parsing() {
|
||||
let l = parse_list("# c\n\nstartup | Noto Sans Arabic | Regular\nbackground | Amiri | Bold Italic\n").unwrap();
|
||||
assert_eq!(
|
||||
l,
|
||||
[
|
||||
Listed { startup: true, family: "Noto Sans Arabic", style: "Regular" },
|
||||
Listed { startup: false, family: "Amiri", style: "Bold Italic" }
|
||||
]
|
||||
);
|
||||
assert!(parse_list("soon | A | B").unwrap_err().contains("line 1"));
|
||||
assert!(parse_list("startup | A").is_err());
|
||||
assert!(parse_list("startup | | B").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
|
||||
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
|
||||
//! manifest (Japanese UI and document fonts, and Arabic fonts); built without it, it is empty and Japanese
|
||||
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
|
||||
//! and craftrules `standards/fonts.md`.
|
||||
|
||||
|
|
@ -14,6 +14,20 @@ pub struct CraftFont {
|
|||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
/// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding,
|
||||
/// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by
|
||||
/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`.
|
||||
pub struct WebFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// Fetched before the app starts (a fallback the first frame needs), else in the background.
|
||||
pub startup: bool,
|
||||
/// Site-relative URL, `fonts/<first 16 hex digits of the SHA-256>/<file name>`.
|
||||
pub url: &'static str,
|
||||
/// Subresource Integrity value, `sha256-<base64>`; the browser rejects other bytes.
|
||||
pub integrity: &'static str,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
impl CraftFont {
|
||||
|
|
@ -21,6 +35,10 @@ impl CraftFont {
|
|||
pub fn is_japanese(&self) -> bool {
|
||||
self.scripts.contains(&"Jpan")
|
||||
}
|
||||
/// Is the font for Arabic text?
|
||||
pub fn is_arabic(&self) -> bool {
|
||||
self.scripts.contains(&"Arab")
|
||||
}
|
||||
/// A serif (Mincho) family, for document text.
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
|
|
@ -43,6 +61,16 @@ pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
|||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces the document font database loads: the Japanese ones in
|
||||
/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic
|
||||
/// first: the fallback for Arabic text; the other families are picked by name). Empty when built
|
||||
/// without craft-fonts.
|
||||
pub fn document_fonts() -> Vec<&'static CraftFont> {
|
||||
let mut v = japanese_document_fonts();
|
||||
v.extend(CRAFT_FONTS.iter().filter(|f| f.is_arabic() && !f.is_japanese()));
|
||||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
|
||||
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
|
||||
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
|
|
@ -50,3 +78,34 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
|||
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_fonts_follow_the_list() {
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
return;
|
||||
}
|
||||
// The listed faces this craft-fonts checkout has (an older one lacks some; build.rs leaves
|
||||
// those out with a warning).
|
||||
let listed: Vec<_> = crate::web_fonts_build::parse_list(include_str!("../web-fonts.txt"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|l| CRAFT_FONTS.iter().any(|f| f.family == l.family && f.style == l.style))
|
||||
.collect();
|
||||
assert_eq!(WEB_FONTS.len(), listed.len());
|
||||
for (l, w) in listed.iter().zip(WEB_FONTS) {
|
||||
assert_eq!((l.family, l.style, l.startup), (w.family, w.style, w.startup));
|
||||
let (dir, name) = w.url.strip_prefix("fonts/").and_then(|r| r.split_once('/')).unwrap();
|
||||
assert!(dir.len() == 16 && dir.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "{}", w.url);
|
||||
assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url);
|
||||
assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity);
|
||||
}
|
||||
let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect();
|
||||
let noto = CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic");
|
||||
assert_eq!(startup, if noto { vec!["Noto Sans Arabic"] } else { vec![] });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ pub struct FontFace {
|
|||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
|
||||
/// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]).
|
||||
pub version: String,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
|
|
@ -69,6 +72,10 @@ pub struct FontFace {
|
|||
/// values).
|
||||
pub(crate) cap_height: f64,
|
||||
pub(crate) x_height: f64,
|
||||
/// Underline and strikeout: (offset from the baseline up to the line's top, thickness), in
|
||||
/// font units (the `post` and OS/2 tables', else a common placement).
|
||||
pub(crate) underline: (f64, f64),
|
||||
pub(crate) strikeout: (f64, f64),
|
||||
pub(crate) shaper: harfrust::ShaperData,
|
||||
/// The axis settings of the variable font's named instance this face is (user units: `wght`
|
||||
/// 700); empty for a static face or a variable font's default instance.
|
||||
|
|
@ -270,9 +277,10 @@ impl FontFace {
|
|||
use skrifa::raw::TableProvider;
|
||||
self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type())
|
||||
}
|
||||
/// May the font file be copied along with a document (its licence doesn't restrict embedding)?
|
||||
/// May the font file be copied along with a document? Its license allows embedding its
|
||||
/// outlines, as exports check it ([`Self::embedding`]).
|
||||
pub fn embeddable(&self) -> bool {
|
||||
self.fs_type() & 0x000f != 0x0002
|
||||
self.embedding() != crate::embed::Embedding::Forbidden
|
||||
}
|
||||
/// The face's index in its font file ([`Self::file_data`]; collections hold several).
|
||||
pub fn face_index(&self) -> u32 {
|
||||
|
|
@ -323,6 +331,10 @@ pub struct FontDb {
|
|||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lowercased) whose installed files were all loaded to find other versions of their
|
||||
/// faces (see [`FontDb::versions`]); a rescan forgets them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
version_scanned: RwLock<std::collections::HashSet<String>>,
|
||||
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
||||
family_cache: Mutex<Option<Arc<[String]>>>,
|
||||
menu_cache: Mutex<Option<MenuFamilies>>,
|
||||
|
|
@ -783,28 +795,51 @@ fn font_files(path: &Path) -> Vec<Vec<u8>> {
|
|||
std::fs::read(path).into_iter().collect()
|
||||
}
|
||||
|
||||
/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with
|
||||
/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
|
||||
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
|
||||
/// megabytes long.
|
||||
/// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of
|
||||
/// them megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table)
|
||||
/// are left out.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
|
||||
/// The most faces read from a (possibly damaged) collection.
|
||||
const MAX_FACES: u32 = 256;
|
||||
file_face_names_within(path, MAX_FACES, u64::MAX)
|
||||
}
|
||||
|
||||
/// [`file_face_names`], reading at most `max_faces` faces and `max_read` bytes of the file in all.
|
||||
/// A suitcase font's resource fork is read whole when it is at most `max_read` bytes long, and the
|
||||
/// limits then apply to each of its fonts; a longer fork gives none.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_face_names_within(path: &Path, max_faces: u32, max_read: u64) -> Vec<FaceStyle> {
|
||||
if crate::suitcase::is_suitcase(path) {
|
||||
return crate::suitcase::read_fonts(path).into_iter().flat_map(|font| reader_face_names(std::io::Cursor::new(font))).collect();
|
||||
if crate::suitcase::fork_len(path) > max_read {
|
||||
return vec![];
|
||||
}
|
||||
return crate::suitcase::read_fonts(path)
|
||||
.into_iter()
|
||||
.flat_map(|font| reader_face_names_within(std::io::Cursor::new(font), max_faces, max_read))
|
||||
.collect();
|
||||
}
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => reader_face_names(file),
|
||||
Ok(file) => reader_face_names_within(file, max_faces, max_read),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// [`file_face_names`] for the font file `file` reads.
|
||||
/// [`file_face_names_within`] for the font file `file` reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceStyle> {
|
||||
fn reader_face_names_within(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64) -> Vec<FaceStyle> {
|
||||
use std::io::SeekFrom;
|
||||
/// Caps on what a (possibly damaged) file can make the scan read.
|
||||
const MAX_FACES: u32 = 256;
|
||||
/// The largest table read from a (possibly damaged) file.
|
||||
const MAX_NAME_TABLE: u32 = 1 << 20;
|
||||
let mut read = 0u64;
|
||||
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
||||
read = read.checked_add(u64::try_from(len).ok()?).filter(|r| *r <= max_read)?;
|
||||
let mut buf = vec![0; len];
|
||||
file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
file.read_exact(&mut buf).ok()?;
|
||||
|
|
@ -814,7 +849,7 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceSt
|
|||
let Some(head) = read_at(0, 12) else { return vec![] };
|
||||
// A collection lists where each face's table directory starts.
|
||||
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
||||
let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize;
|
||||
let n = be32(&head, 8).unwrap_or(0).min(max_faces) as usize;
|
||||
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
vec![0]
|
||||
|
|
@ -825,6 +860,9 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceSt
|
|||
let dir = read_at(start.into(), 12)?;
|
||||
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
|
||||
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
||||
if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
|
||||
return None;
|
||||
}
|
||||
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
||||
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
|
|
@ -853,6 +891,157 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceSt
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the file at `path` is a font file by its extension (`.ttf`, `.otf`, `.ttc` or `.otc`,
|
||||
/// in any case), as the font scan reads the files in a folder.
|
||||
pub fn is_font_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["ttf", "otf", "ttc", "otc"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
|
||||
/// Whether the file at `path` is a suitcase font ([`crate::suitcase`]): on macOS, a file with
|
||||
/// nothing in its data and its fonts in its resource fork. Never on the web.
|
||||
pub fn is_suitcase(path: &Path) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::suitcase::is_suitcase(path)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A font a document names that isn't available, to look for in font files ([`WantedFonts`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WantedFont {
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
/// When the family is installed but not the style ([`FontMatch::Style`]): the family of the
|
||||
/// face [`FontDb::resolve`] gives instead. `None` when the family is missing.
|
||||
pub installed: Option<String>,
|
||||
}
|
||||
|
||||
/// Fonts to look for in font files: [`WantedFonts::provided_by`] tells which of them a file
|
||||
/// provides.
|
||||
#[derive(Debug, Default)]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct WantedFonts {
|
||||
fonts: Vec<WantedFont>,
|
||||
/// The indexes of the fonts by every name a face could answer to them by: the family as given
|
||||
/// and the installed family (ASCII-lowercased), and the family normalized ([`norm`]).
|
||||
by_name: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl WantedFonts {
|
||||
/// The most faces and bytes of one file a search reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SEARCH_FACES: u32 = 64;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SEARCH_READ: u64 = 8 << 20;
|
||||
|
||||
pub fn new(fonts: &[WantedFont]) -> Self {
|
||||
let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
for (i, w) in fonts.iter().enumerate() {
|
||||
let mut names = vec![w.family.to_ascii_lowercase(), norm(&w.family)];
|
||||
names.extend(w.installed.as_ref().map(|c| c.to_ascii_lowercase()));
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for n in names.into_iter().filter(|n| !n.is_empty()) {
|
||||
by_name.entry(n).or_default().push(i);
|
||||
}
|
||||
}
|
||||
Self { fonts: fonts.to_vec(), by_name }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.fonts.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fonts.is_empty()
|
||||
}
|
||||
|
||||
/// The indexes of the fonts that the font file at `path` provides: copied into a folder the
|
||||
/// font scan reads, the file makes [`FontDb::resolve`] find each of them exactly, in one of its
|
||||
/// faces or a variable font's named instances. The file is read as the scan reads it (its
|
||||
/// table directories and its `name`, `fvar` and `OS/2` tables), at most 64 faces and 8 MB of
|
||||
/// it, when it is a font file by its extension ([`is_font_file`]) or a suitcase font whose
|
||||
/// resource fork is at most 8 MB long. Sorted; none on the web.
|
||||
pub fn provided_by(&self, path: &Path) -> Vec<usize> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if !is_font_file(path) && !crate::suitcase::is_suitcase(path) {
|
||||
return Vec::new();
|
||||
}
|
||||
let faces = file_face_names_within(path, Self::SEARCH_FACES, Self::SEARCH_READ);
|
||||
self.provided_by_faces(&faces)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::provided_by`] for the faces of a file.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn provided_by_faces(&self, faces: &[FaceStyle]) -> Vec<usize> {
|
||||
let mut candidates: Vec<usize> = Vec::new();
|
||||
for f in faces {
|
||||
let names = std::iter::once(f.family.to_ascii_lowercase())
|
||||
.chain(f.keys.families.iter().cloned())
|
||||
.chain(f.keys.legacy.iter().map(|(family, _)| family.clone()))
|
||||
.chain(f.keys.postscript.iter().cloned());
|
||||
for n in names {
|
||||
candidates.extend(self.by_name.get(&n).into_iter().flatten());
|
||||
}
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
candidates.retain(|i| self.fonts.get(*i).is_some_and(|w| provides(w, faces)));
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the faces of a font file, cataloged by a rescan, make [`FontDb::resolve`] find `w`
|
||||
/// exactly in one of them: [`FontDb::canonical`] and [`FontDb::find`] over these faces, as they
|
||||
/// would run once the file is cataloged.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn provides(w: &WantedFont, faces: &[FaceStyle]) -> bool {
|
||||
let sk = norm(&w.style);
|
||||
// A face of `family` with the style among its style names, as `find` looks for it once the
|
||||
// family's files are loaded.
|
||||
let styled = |family: &str| faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.keys.styles.contains(&sk));
|
||||
let named_as_given = faces.iter().any(|f| f.family.eq_ignore_ascii_case(&w.family));
|
||||
if let Some(installed) = &w.installed {
|
||||
// A family cataloged by the name as given is found by it.
|
||||
if named_as_given && !w.family.eq_ignore_ascii_case(installed) {
|
||||
return styled(&w.family);
|
||||
}
|
||||
// The installed family is loaded: a face of it is loaded from the file only when its own
|
||||
// style is the one asked for (`is_cataloged`).
|
||||
return faces.iter().any(|f| f.family.eq_ignore_ascii_case(installed) && norm(&f.style) == sk);
|
||||
}
|
||||
if named_as_given {
|
||||
return styled(&w.family);
|
||||
}
|
||||
let key = norm(&w.family);
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Another of a family's names keeps the style asked for.
|
||||
if let Some(f) = faces.iter().find(|f| f.keys.families.contains(&key)) {
|
||||
return styled(&f.family);
|
||||
}
|
||||
// A legacy family with its legacy style, or a PostScript name, names its face whatever style
|
||||
// is asked for.
|
||||
if faces.iter().any(|f| f.keys.legacy.iter().any(|(family, style)| *family == key && *style == sk) || f.keys.postscript.contains(&key)) {
|
||||
return true;
|
||||
}
|
||||
// A legacy family with another style: that style of the family.
|
||||
faces.iter().find(|f| f.keys.legacy.iter().any(|(family, _)| *family == key)).is_some_and(|f| styled(&f.family))
|
||||
}
|
||||
|
||||
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
|
||||
#[cfg(any(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
|
|
@ -890,7 +1079,9 @@ static PLATFORM_FONT_FILES: std::sync::OnceLock<PlatformFontFiles> = std::sync::
|
|||
/// folders, asked again by each scan and each check for installed fonts
|
||||
/// ([`FontDb::installed_fonts_changed`]). The desktop app lists DirectWrite's system font
|
||||
/// collection on Windows: fonts a font service such as Adobe Fonts loads in place, from files
|
||||
/// outside the font folders and unknown to the registry (#579). Only the first call counts.
|
||||
/// outside the font folders and unknown to the registry (#579). On macOS it lists the fonts
|
||||
/// CoreText's font manager has available, which apps and font managers can register from any
|
||||
/// folder. Only the first call counts.
|
||||
pub fn set_platform_font_files(list: PlatformFontFiles) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
// A second call keeps the first lister, as documented.
|
||||
|
|
@ -924,9 +1115,34 @@ pub fn user_font_dirs() -> Vec<PathBuf> {
|
|||
Vec::new()
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's) and the font files known to it
|
||||
/// outside them (on Windows those registered with it, and those [`set_platform_font_files`]
|
||||
/// lists), scanned by [`FontDb::global`].
|
||||
/// VectorCraft's own Fonts folder, set by the app ([`set_app_font_dir`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static APP_FONT_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Have [`system_font_dirs`] read `dir` with its subfolders: VectorCraft's own Fonts folder, which
|
||||
/// `text.addFontFiles` copies font files into. The desktop app and `vectorcraft-cli` set it before
|
||||
/// the first scan, as they install [`set_platform_font_files`]. A folder that doesn't exist yet is
|
||||
/// read once it does. Only the first call counts. Ignored on wasm.
|
||||
pub fn set_app_font_dir(dir: PathBuf) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
// A second call keeps the first folder, as documented.
|
||||
let _ = APP_FONT_DIR.set(dir);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = dir;
|
||||
}
|
||||
|
||||
/// The folder [`set_app_font_dir`] set: none before that, and none on wasm.
|
||||
pub fn app_font_dir() -> Option<&'static Path> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return APP_FONT_DIR.get().map(PathBuf::as_path);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), VectorCraft's own Fonts folder
|
||||
/// ([`set_app_font_dir`]), the font files known to the platform outside them (on Windows those
|
||||
/// registered with it, and those [`set_platform_font_files`] lists) and the folders the user added
|
||||
/// ([`set_user_font_dirs`]), scanned by [`FontDb::global`].
|
||||
pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
|
|
@ -967,6 +1183,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
|||
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
}
|
||||
dirs.extend(app_font_dir().map(Path::to_path_buf));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(list) = PLATFORM_FONT_FILES.get() {
|
||||
let files = fonts_outside(list(), &dirs);
|
||||
|
|
@ -1020,6 +1237,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
|||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
|
||||
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
|
||||
// A named instance: outlines, metrics and advances at its axis settings.
|
||||
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
|
|
@ -1028,22 +1246,32 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
|||
let shaper = harfrust::ShaperData::new(&hb);
|
||||
let instance =
|
||||
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
|
||||
let upem = m.units_per_em.max(1) as f64;
|
||||
let x_height = m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5);
|
||||
let decoration = |d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| {
|
||||
d.map(|d| (f64::from(d.offset), f64::from(d.thickness))).filter(|(o, t)| o.is_finite() && *t > 0.0 && t.is_finite()).unwrap_or(fallback)
|
||||
};
|
||||
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
|
||||
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
version,
|
||||
weight: weight.unwrap_or(a.weight.value()),
|
||||
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
|
||||
variations,
|
||||
location,
|
||||
instance,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
upem,
|
||||
ascent: m.ascent as f64,
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
x_height,
|
||||
underline,
|
||||
strikeout,
|
||||
shaper,
|
||||
bytes,
|
||||
index,
|
||||
|
|
@ -1118,9 +1346,10 @@ impl FontDb {
|
|||
|
||||
fn new(font_paths: FontPaths) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
|
||||
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
||||
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
// The bundled fonts, then the craft-fonts faces (when built with them): Japanese, Mincho
|
||||
// first, then Arabic with Noto Sans Arabic first. They are fallbacks for those scripts
|
||||
// after the requested and bundled fonts, and families users can pick by name.
|
||||
let craft = crate::craft_fonts::document_fonts().into_iter().map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for (i, spec) in enumerate_faces(data) {
|
||||
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
|
||||
|
|
@ -1137,6 +1366,8 @@ impl FontDb {
|
|||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
version_scanned: RwLock::new(std::collections::HashSet::new()),
|
||||
family_cache: Mutex::new(None),
|
||||
menu_cache: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
|
|
@ -1229,8 +1460,13 @@ impl FontDb {
|
|||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let (family, _) = self.canonical(family, "");
|
||||
let family = family.as_str();
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
||||
let mut v: Vec<(bool, f32, String)> = Vec::new();
|
||||
// Each style once, as its first loaded version describes it (versions can disagree on weight).
|
||||
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) {
|
||||
v.push((f.italic, f.weight, f.style.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
for cf in &c.faces {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
|
||||
|
|
@ -1254,7 +1490,14 @@ impl FontDb {
|
|||
let data = Arc::new(bytes);
|
||||
let mut added = 0;
|
||||
for (i, spec) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
|
||||
// Another version of a family and style is kept: a document may name it (the first one
|
||||
// loaded stays the one the family and style alone resolve to).
|
||||
let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();
|
||||
if self
|
||||
.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
|
||||
|
|
@ -1316,6 +1559,8 @@ impl FontDb {
|
|||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
// Files may have been installed since: other versions are looked for again.
|
||||
self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
|
|
@ -1351,8 +1596,7 @@ impl FontDb {
|
|||
// A file named by itself is a font whatever its name (font services keep fonts in
|
||||
// files without an extension); one of a folder's only with a font's extension, or
|
||||
// a suitcase font (whose fonts are in its resource fork, whatever its name).
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && !crate::suitcase::is_suitcase(&p) {
|
||||
if !named && !is_font_file(&p) && !crate::suitcase::is_suitcase(&p) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
|
|
@ -1507,6 +1751,49 @@ impl FontDb {
|
|||
.or_else(|| self.read_faces().first().cloned())
|
||||
}
|
||||
|
||||
/// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when
|
||||
/// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives.
|
||||
pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option<Arc<FontFace>> {
|
||||
let face = self.face(family, style)?;
|
||||
let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) };
|
||||
let same =
|
||||
|f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version;
|
||||
if let Some(f) = self.read_faces().iter().find(same) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
// Not loaded: the family's installed files may hold it (the catalog doesn't list versions).
|
||||
self.load_family_files(&face.family);
|
||||
let found = self.read_faces().iter().find(same).cloned();
|
||||
Some(found.unwrap_or(face))
|
||||
}
|
||||
|
||||
/// Every installed version of `face`'s family and style, `face` itself first.
|
||||
pub fn versions(&self, face: &Arc<FontFace>) -> Vec<Arc<FontFace>> {
|
||||
self.load_family_files(&face.family);
|
||||
let mut out = vec![face.clone()];
|
||||
out.extend(
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id)
|
||||
.cloned(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Load the installed files of `family` once (other versions of faces already loaded from
|
||||
/// elsewhere); a no-op on wasm, where no fonts are installed.
|
||||
fn load_family_files(&self, family: &str) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = family.to_ascii_lowercase();
|
||||
if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) {
|
||||
self.load_cataloged(family);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = family;
|
||||
}
|
||||
|
||||
/// Is `style` of `family` among the installed fonts?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_cataloged(&self, family: &str, style: &str) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,109 @@
|
|||
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
|
||||
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
|
||||
//! letters before or [`MIN_AFTER`] after the hyphen.
|
||||
//!
|
||||
//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words:
|
||||
//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See
|
||||
//! [`set_hyphenation_exceptions`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
|
||||
pub const MIN_WORD: usize = 6;
|
||||
pub const MIN_BEFORE: usize = 3;
|
||||
pub const MIN_AFTER: usize = 3;
|
||||
|
||||
/// An exception for one word: never break it, or break only at the listed character indices.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Exception {
|
||||
Never,
|
||||
Only(Vec<usize>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Exceptions {
|
||||
source: String,
|
||||
map: HashMap<String, Exception>,
|
||||
}
|
||||
|
||||
fn store() -> MutexGuard<'static, Exceptions> {
|
||||
static LOCK: OnceLock<Mutex<Exceptions>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(Exceptions::default())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn parse_list(source: &str) -> Exceptions {
|
||||
let mut map = HashMap::new();
|
||||
for entry in source.split(',') {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (word, ex) = parse_exception(entry);
|
||||
if word.is_empty() {
|
||||
continue;
|
||||
}
|
||||
map.insert(word, ex);
|
||||
}
|
||||
Exceptions { source: source.to_string(), map }
|
||||
}
|
||||
|
||||
/// Preferences › Hyphenation › Exceptions: comma-separated words. A word with no hyphens is never
|
||||
/// hyphenated; hyphens mark the only allowed break points (`ty-pog-ra-phy`). Matching is
|
||||
/// case-insensitive. Empty clears the list.
|
||||
pub fn set_hyphenation_exceptions(source: &str) {
|
||||
*store() = parse_list(source);
|
||||
}
|
||||
|
||||
/// The last string passed to [`set_hyphenation_exceptions`] (empty when none).
|
||||
pub fn hyphenation_exceptions() -> String {
|
||||
store().source.clone()
|
||||
}
|
||||
|
||||
fn parse_exception(entry: &str) -> (String, Exception) {
|
||||
// Illustrator: bare word = never break; hyphens mark the only allowed breaks. No other syntax.
|
||||
if !entry.contains('-') {
|
||||
return (normalize_word(entry), Exception::Never);
|
||||
}
|
||||
let mut word = String::new();
|
||||
let mut points = Vec::new();
|
||||
for ch in entry.chars() {
|
||||
if ch == '-' {
|
||||
let at = word.chars().count();
|
||||
if at > 0 {
|
||||
points.push(at);
|
||||
}
|
||||
} else {
|
||||
for c in ch.to_lowercase() {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = word.chars().count();
|
||||
points.retain(|&p| p > 0 && p < n);
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
(word, Exception::Only(points))
|
||||
}
|
||||
|
||||
fn normalize_word(word: &str) -> String {
|
||||
word.chars().flat_map(|c| c.to_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// Look the word up under the preferences lock. Only the matched [`Exception`] is cloned, never
|
||||
/// the whole list.
|
||||
#[cfg(not(test))]
|
||||
fn exception_for(word: &str) -> Option<Exception> {
|
||||
store().map.get(&normalize_word(word)).cloned()
|
||||
}
|
||||
|
||||
/// Unit tests read only their own thread's list: tests running in parallel set the process-wide
|
||||
/// one.
|
||||
#[cfg(test)]
|
||||
fn exception_for(word: &str) -> Option<Exception> {
|
||||
TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&normalize_word(word)).cloned()))
|
||||
}
|
||||
|
||||
fn is_vowel(c: char) -> bool {
|
||||
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||
}
|
||||
|
|
@ -25,7 +122,14 @@ fn onset_pair(a: char, b: char) -> bool {
|
|||
|
||||
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
|
||||
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
|
||||
/// Preferences exceptions override the pattern for a matching word.
|
||||
pub fn hyphen_points(word: &str) -> Vec<usize> {
|
||||
if let Some(ex) = exception_for(word) {
|
||||
return match ex {
|
||||
Exception::Never => vec![],
|
||||
Exception::Only(pts) => pts,
|
||||
};
|
||||
}
|
||||
let c: Vec<char> = word.chars().collect();
|
||||
let n = c.len();
|
||||
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
|
||||
|
|
@ -69,3 +173,63 @@ pub fn hyphenate_word(word: &str) -> String {
|
|||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Per-thread list so unit tests don't race the process-wide preferences store.
|
||||
static TEST_OVERRIDE: std::cell::RefCell<Option<Exceptions>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with Preferences › Hyphenation › Exceptions applied on this thread only (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_hyphenation_exceptions_for_test(source: &str, f: impl FnOnce()) {
|
||||
TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source)));
|
||||
f();
|
||||
TEST_OVERRIDE.with(|c| *c.borrow_mut() = None);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_exceptions(source: &str, f: impl FnOnce()) {
|
||||
with_hyphenation_exceptions_for_test(source, f);
|
||||
}
|
||||
|
||||
/// #394 Preferences › Hyphenation › Exceptions contract (parse + override).
|
||||
#[test]
|
||||
fn hyphenation_exceptions_override_the_pattern() {
|
||||
let pattern = hyphen_points("typography");
|
||||
assert_eq!(pattern, vec![4, 7], "pattern: typo-gra-phy");
|
||||
|
||||
// Bare word: never break (case-insensitive).
|
||||
with_exceptions("typography", || {
|
||||
assert!(hyphen_points("typography").is_empty());
|
||||
assert!(hyphen_points("Typography").is_empty(), "case-insensitive");
|
||||
});
|
||||
|
||||
// Hyphens mark the only allowed breaks (stricter than the pattern).
|
||||
with_exceptions("typo-graphy", || {
|
||||
assert_eq!(hyphen_points("typography"), vec![4], "only the marked break");
|
||||
assert_eq!(hyphenate_word("typography"), "typo-graphy");
|
||||
});
|
||||
|
||||
// Comma-separated list; empty / whitespace entries ignored.
|
||||
with_exceptions(" , typography , hap-pen , ", || {
|
||||
assert!(hyphen_points("typography").is_empty());
|
||||
assert_eq!(hyphen_points("happen"), vec![3]);
|
||||
assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern");
|
||||
});
|
||||
|
||||
assert_eq!(hyphen_points("typography"), pattern, "override cleared");
|
||||
}
|
||||
|
||||
/// Process-wide store used by `prefs.set` / restore.
|
||||
#[test]
|
||||
fn set_hyphenation_exceptions_round_trips_the_source_string() {
|
||||
set_hyphenation_exceptions("typography, hap-pen");
|
||||
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
|
||||
set_hyphenation_exceptions("");
|
||||
assert!(hyphenation_exceptions().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,46 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
|
|||
layout_with(db, t, &opts)
|
||||
}
|
||||
|
||||
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
|
||||
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
|
||||
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
|
||||
/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet.
|
||||
pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> {
|
||||
let mut out = vec![];
|
||||
if layout.on_path || layout.vertical {
|
||||
return out;
|
||||
}
|
||||
let glyphs = &layout.glyphs;
|
||||
let mut i = 0;
|
||||
while let Some(g) = glyphs.get(i) {
|
||||
// The stretch: the glyphs of this run on this line, in a row.
|
||||
let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n);
|
||||
let stretch = glyphs.get(i..end).unwrap_or_default();
|
||||
i = end.max(i + 1);
|
||||
let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue };
|
||||
if !(style.underline || style.strikethrough) {
|
||||
continue;
|
||||
}
|
||||
let Some(face) = db.face_by_id(g.font_id) else { continue };
|
||||
let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
|
||||
let (s, e) = (h.origin.x, h.origin.x + h.advance);
|
||||
(a.min(s).min(e), b.max(s).max(e))
|
||||
});
|
||||
for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
// Font units run y down in the glyph's transform: the bar's top is at -offset.
|
||||
let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y);
|
||||
let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom));
|
||||
if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() {
|
||||
out.push((g.run, bar.to_path(0.1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
|
||||
const SHRINK_PASSES: usize = 11;
|
||||
|
||||
|
|
@ -263,7 +303,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
|
|||
TextKind::OnPath { .. } => Affine::IDENTITY,
|
||||
TextKind::Point => {
|
||||
let first = cx.style_at(0);
|
||||
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre =
|
||||
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
|
||||
}
|
||||
_ => QUARTER_TURN,
|
||||
|
|
@ -1392,7 +1433,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
|||
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
|
||||
let st = cx.style_at(pr.start);
|
||||
let (cap, xh) = cap_x_heights(cx.db, st);
|
||||
let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre =
|
||||
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
|
||||
};
|
||||
if pi > 0 {
|
||||
|
|
|
|||
|
|
@ -24,14 +24,15 @@ mod suitcase;
|
|||
pub mod test_fonts;
|
||||
pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
|
||||
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs,
|
||||
style_weight, system_font_dirs, user_font_dirs,
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir,
|
||||
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use layout::{decorations, layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
|
||||
|
|
@ -522,4 +523,9 @@ mod tests_typo;
|
|||
#[cfg(test)]
|
||||
mod tests_variable;
|
||||
#[cfg(test)]
|
||||
mod tests_versions;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
#[cfg(test)]
|
||||
#[path = "../build/web_fonts.rs"]
|
||||
mod web_fonts_build;
|
||||
|
|
|
|||
|
|
@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool {
|
|||
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
|
||||
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||||
let vs = st.v_scale / 100.0;
|
||||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
|
||||
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
|
||||
};
|
||||
let k = st.size / face.upem;
|
||||
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
|
||||
}
|
||||
|
|
@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
|||
/// Cap height and x height (points) of a style's resolved face.
|
||||
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
|
||||
let vs = st.v_scale / 100.0;
|
||||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
|
||||
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
|
||||
};
|
||||
let k = st.size / face.upem * vs;
|
||||
(face.cap_height * k, face.x_height * k)
|
||||
}
|
||||
|
|
@ -291,12 +295,12 @@ pub(crate) fn shape_range(
|
|||
let a = bytes.start;
|
||||
let b = bytes.end;
|
||||
if let Some(art) = art {
|
||||
if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) {
|
||||
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
|
||||
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
|
||||
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
|
||||
let pmap = primary.skrifa().map(|f| f.charmap());
|
||||
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
|
||||
let small_caps = st.small_caps.is_some() && !st.all_caps;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ fn fork_path(path: &Path) -> std::path::PathBuf {
|
|||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fork_len(path: &Path) -> u64 {
|
||||
pub(crate) fn fork_len(path: &Path) -> u64 {
|
||||
std::fs::metadata(fork_path(path)).map_or(0, |m| m.len())
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ fn read_fork(path: &Path) -> Option<Vec<u8>> {
|
|||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn fork_len(_: &Path) -> u64 {
|
||||
pub(crate) fn fork_len(_: &Path) -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue